Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4727c7dc20 |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -15,6 +15,3 @@ worktrees/
|
|||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
/.dev/
|
/.dev/
|
||||||
|
|
||||||
# Per-machine Claude Code tool permissions
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle/
|
|
||||||
/local.properties
|
|
||||||
/.idea/
|
|
||||||
.DS_Store
|
|
||||||
/build/
|
|
||||||
/captures/
|
|
||||||
.externalNativeBuild/
|
|
||||||
.cxx/
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.application)
|
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.compose)
|
|
||||||
alias(libs.plugins.kotlin.serialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "pt.naps.maestro"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "pt.naps.maestro"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
getByName("debug") {
|
|
||||||
storeFile = file("debug.keystore")
|
|
||||||
storePassword = "android"
|
|
||||||
keyAlias = "androiddebugkey"
|
|
||||||
keyPassword = "android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.process)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.ui)
|
|
||||||
implementation(libs.androidx.ui.graphics)
|
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.material3)
|
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
implementation(libs.okhttp)
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
|
||||||
testImplementation(libs.turbine)
|
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
|
||||||
}
|
|
||||||
Binary file not shown.
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.kts.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class MainActivityTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun placeholderScreen_showsAppName() {
|
|
||||||
composeTestRule.onNodeWithText("Maestro").assertExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
android:theme="@style/Theme.Maestro">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/Theme.Maestro">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.connection.DaemonLink
|
|
||||||
import pt.naps.maestro.connection.DaemonPrefs
|
|
||||||
import pt.naps.maestro.connection.LinkState
|
|
||||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
|
||||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private lateinit var daemonLink: DaemonLink
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
val prefs = DaemonPrefs(applicationContext)
|
|
||||||
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
|
||||||
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
MaestroTheme {
|
|
||||||
val linkState by daemonLink.state.collectAsState()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
drawerContent = {
|
|
||||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
|
||||||
SidebarContent(
|
|
||||||
catalogStore = daemonLink.catalog,
|
|
||||||
onOpenSession = { scope.launch { drawerState.close() } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("Maestro") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
|
||||||
Text("☰")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
daemonLink.close()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Maestro",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.placeholder_message),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Daemon: ${linkState.name.lowercase()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreenPreview() {
|
|
||||||
MaestroTheme {
|
|
||||||
PlaceholderScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
|
|
||||||
/** A `/api` failure with the daemon's own error envelope kept intact, so a
|
|
||||||
* caller can branch on [code] and render [unsavedWork] — the kill guard —
|
|
||||||
* rather than re-parsing [detail]. Mirrors the web client's
|
|
||||||
* `ApiRequestError`. */
|
|
||||||
class MaestroApiException(
|
|
||||||
val status: Int,
|
|
||||||
val code: String,
|
|
||||||
val detail: String,
|
|
||||||
val unsavedWork: InTheWay? = null,
|
|
||||||
) : Exception(buildMessage(status, code, detail)) {
|
|
||||||
private companion object {
|
|
||||||
fun buildMessage(status: Int, code: String, detail: String): String {
|
|
||||||
val info = if (code.isNotEmpty()) "$code: $detail" else detail
|
|
||||||
return if (info.isNotEmpty()) "$status — $info" else "$status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import pt.naps.maestro.data.ApiError
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.CreateSessionRequest
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
import pt.naps.maestro.data.DirListing
|
|
||||||
import pt.naps.maestro.data.GitRoot
|
|
||||||
import pt.naps.maestro.data.NameBatch
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.RecentDirs
|
|
||||||
import pt.naps.maestro.data.RenameSessionRequest
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SetFocusRequest
|
|
||||||
import pt.naps.maestro.data.StructuredAttachmentResponse
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.StructuredConfigRequest
|
|
||||||
import pt.naps.maestro.data.StructuredPromptRequest
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
|
|
||||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
|
||||||
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed client for the daemon's `/api` surface (REST half). Mirrors
|
|
||||||
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
|
|
||||||
*
|
|
||||||
* There is no authentication layer: reaching the daemon's bind address is
|
|
||||||
* the authorization, same trust model as tmux itself.
|
|
||||||
*/
|
|
||||||
class MaestroClient(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) {
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
|
|
||||||
|
|
||||||
suspend fun createSession(request: CreateSessionRequest): Session =
|
|
||||||
post(Routes.SESSIONS, request)
|
|
||||||
|
|
||||||
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
|
|
||||||
* clears the custom one, so the card falls back to the command. */
|
|
||||||
suspend fun renameSession(id: Uuid, name: String): Session =
|
|
||||||
patch(Routes.session(id), RenameSessionRequest(name))
|
|
||||||
|
|
||||||
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
|
|
||||||
* kill refusal over a worktree holding unsaved work and leaves that
|
|
||||||
* worktree on disk. */
|
|
||||||
suspend fun killSession(id: Uuid, force: Boolean = false) {
|
|
||||||
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
|
|
||||||
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
|
|
||||||
* the one currently showing [id] — not merely highlighted in a
|
|
||||||
* sidebar. */
|
|
||||||
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.sessionFocus(id),
|
|
||||||
body = json.encodeToString(SetFocusRequest(focused)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
|
|
||||||
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
|
|
||||||
* it against its home dir. */
|
|
||||||
suspend fun listDir(path: String): DirListing =
|
|
||||||
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
|
|
||||||
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
|
|
||||||
|
|
||||||
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
|
|
||||||
* deduped by dir. */
|
|
||||||
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
|
|
||||||
|
|
||||||
/** `GET /api/projects`: every discovered git repository/worktree,
|
|
||||||
* newest-activity-first. */
|
|
||||||
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
|
|
||||||
|
|
||||||
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
|
|
||||||
* when [path] isn't inside a repo. */
|
|
||||||
suspend fun gitRoot(path: String): GitRoot =
|
|
||||||
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** `GET /api/names?count=`: a batch of fresh session handles. */
|
|
||||||
suspend fun fetchNames(count: Int): NameBatch =
|
|
||||||
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
|
|
||||||
|
|
||||||
/** `GET /api/branches?path=`: one repository's local branches, with
|
|
||||||
* worktree occupancy. */
|
|
||||||
suspend fun listBranches(path: String): List<BranchInfo> =
|
|
||||||
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** Send a prompt to a structured session's ACP turn. `409
|
|
||||||
* turn_in_flight` when one is already active. */
|
|
||||||
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredPrompt(id),
|
|
||||||
body = json.encodeToString(StructuredPromptRequest(prompt)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upload a file attachment for a structured session's next prompt. */
|
|
||||||
suspend fun uploadStructuredAttachment(
|
|
||||||
id: Uuid,
|
|
||||||
fileName: String,
|
|
||||||
contents: ByteArray,
|
|
||||||
): StructuredAttachmentResponse {
|
|
||||||
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("name", fileName)
|
|
||||||
.build()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
|
|
||||||
.build()
|
|
||||||
return decode(execute(request))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel a structured session's active ACP turn. */
|
|
||||||
suspend fun cancelStructuredTurn(id: Uuid) {
|
|
||||||
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Change one option advertised by a live ACP session. */
|
|
||||||
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredConfig(id),
|
|
||||||
body = json.encodeToString(StructuredConfigRequest(configId, value)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer a pending ACP permission request. Always resolves once the
|
|
||||||
* session itself is a live structured session — a nonce that is unknown
|
|
||||||
* or already consumed still answers success. */
|
|
||||||
suspend fun resolveStructuredApproval(
|
|
||||||
id: Uuid,
|
|
||||||
nonce: String,
|
|
||||||
decision: StructuredApprovalDecision,
|
|
||||||
) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredApproval(id, nonce),
|
|
||||||
body = json.encodeToString(decision),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- plumbing ------------------------------------------------------
|
|
||||||
|
|
||||||
private suspend inline fun <reified T> get(
|
|
||||||
path: String,
|
|
||||||
query: List<Pair<String, String>> = emptyList(),
|
|
||||||
): T = decode(execute(newRequest("GET", path, query = query)))
|
|
||||||
|
|
||||||
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
|
|
||||||
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
|
|
||||||
|
|
||||||
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
|
|
||||||
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
|
|
||||||
|
|
||||||
private fun newRequest(
|
|
||||||
method: String,
|
|
||||||
path: String,
|
|
||||||
query: List<Pair<String, String>> = emptyList(),
|
|
||||||
body: String? = null,
|
|
||||||
): Request {
|
|
||||||
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
|
|
||||||
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
|
|
||||||
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
|
|
||||||
// expects none (e.g. the structured-turn cancel) — an empty body
|
|
||||||
// satisfies it without implying a JSON payload.
|
|
||||||
val requestBody = when {
|
|
||||||
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
|
|
||||||
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
return Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.method(method, requestBody)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun execute(request: Request): Response =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
val call = httpClient.newCall(request)
|
|
||||||
cont.invokeOnCancellation { call.cancel() }
|
|
||||||
call.enqueue(
|
|
||||||
object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (!cont.isCancelled) cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
cont.resume(response)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun executeNoContent(request: Request) {
|
|
||||||
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> decode(response: Response): T =
|
|
||||||
response.use {
|
|
||||||
if (!it.isSuccessful) throw toApiException(it)
|
|
||||||
json.decodeFromString<T>(it.body?.string().orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toApiException(response: Response): MaestroApiException {
|
|
||||||
val text = response.body?.string().orEmpty()
|
|
||||||
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
|
|
||||||
return MaestroApiException(
|
|
||||||
status = response.code,
|
|
||||||
code = info?.code ?: "",
|
|
||||||
detail = info?.message ?: text,
|
|
||||||
unsavedWork = info?.unsaved_work,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okio.ByteString
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.ClientControl
|
|
||||||
import pt.naps.maestro.data.StructuredEvent
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
private val wsJson = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
|
|
||||||
* [ClientControl] text message. */
|
|
||||||
sealed class AttachFrame {
|
|
||||||
data class Pty(val bytes: ByteArray) : AttachFrame()
|
|
||||||
data class Control(val message: ClientControl) : AttachFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
|
|
||||||
* both directions; control messages ([ClientControl]) travel in text
|
|
||||||
* frames. */
|
|
||||||
class AttachConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val frames: Flow<AttachFrame>,
|
|
||||||
) {
|
|
||||||
/** Type raw bytes into the session's pane, as if typed on an attached
|
|
||||||
* terminal. */
|
|
||||||
fun sendInput(bytes: ByteArray) {
|
|
||||||
socket.send(ByteString.of(*bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resize(cols: Int, rows: Int) {
|
|
||||||
sendControl(ClientControl.Resize(cols, rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ping() {
|
|
||||||
sendControl(ClientControl.Ping)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendControl(message: ClientControl) {
|
|
||||||
socket.send(wsJson.encodeToString(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
|
|
||||||
* and live deltas for one structured session. */
|
|
||||||
class StructuredConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val events: Flow<StructuredEvent>,
|
|
||||||
) {
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
|
|
||||||
class EventsConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val events: Flow<CatalogEvent>,
|
|
||||||
) {
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
|
|
||||||
* helpers in `crates/maestro-protocol/src/routes.rs`.
|
|
||||||
*
|
|
||||||
* Each `connect*` call opens the socket immediately and returns a handle
|
|
||||||
* whose `Flow` starts buffering frames right away — a slow collector never
|
|
||||||
* misses a frame, it only sees it later. Call `close()` on the returned
|
|
||||||
* connection when done; there is no automatic reconnect.
|
|
||||||
*/
|
|
||||||
class MaestroWebSocket(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) {
|
|
||||||
/** `WS /api/events`: catalog snapshot + session deltas. */
|
|
||||||
fun connectEvents(): EventsConnection {
|
|
||||||
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.eventsWsUrl(baseUrl),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
|
|
||||||
.onSuccess { channel.trySend(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return EventsConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
|
|
||||||
* as text JSON. */
|
|
||||||
fun connectAttach(id: Uuid): AttachConnection {
|
|
||||||
val channel = Channel<AttachFrame>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.attachWsUrl(baseUrl, id),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<ClientControl>(text) }
|
|
||||||
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
|
||||||
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return AttachConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
|
|
||||||
* for one structured session. */
|
|
||||||
fun connectStructured(id: Uuid): StructuredConnection {
|
|
||||||
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.structuredWsUrl(baseUrl, id),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
|
|
||||||
.onSuccess { channel.trySend(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return StructuredConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun open(url: String, listener: WebSocketListener): WebSocket {
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
return httpClient.newWebSocket(request, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
|
|
||||||
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
|
|
||||||
* `web/src/routes.ts`. */
|
|
||||||
object Routes {
|
|
||||||
const val API_PREFIX = "/api"
|
|
||||||
|
|
||||||
const val SESSIONS = "/api/sessions"
|
|
||||||
const val INFO = "/api/info"
|
|
||||||
const val DIRS = "/api/dirs"
|
|
||||||
const val RECENT_DIRS = "/api/recent-dirs"
|
|
||||||
const val PROJECTS = "/api/projects"
|
|
||||||
const val GITROOT = "/api/gitroot"
|
|
||||||
const val NAMES = "/api/names"
|
|
||||||
const val BRANCHES = "/api/branches"
|
|
||||||
const val EVENTS = "/api/events"
|
|
||||||
|
|
||||||
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
|
|
||||||
const val PATH_QUERY = "path"
|
|
||||||
|
|
||||||
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
|
|
||||||
* `force=true` overrides the kill refusal over a guarded worktree. */
|
|
||||||
const val FORCE_QUERY = "force"
|
|
||||||
|
|
||||||
/** Name of the query parameter [NAMES] takes. */
|
|
||||||
const val COUNT_QUERY = "count"
|
|
||||||
|
|
||||||
fun session(id: Uuid): String = "$SESSIONS/$id"
|
|
||||||
|
|
||||||
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
|
|
||||||
|
|
||||||
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
|
|
||||||
|
|
||||||
/** `WS /api/structured/:id` — transcript/state snapshots and live
|
|
||||||
* deltas. */
|
|
||||||
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
|
||||||
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
|
|
||||||
|
|
||||||
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
|
||||||
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/config` — change an ACP session
|
|
||||||
* option. */
|
|
||||||
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
|
|
||||||
* permission request. */
|
|
||||||
fun structuredApproval(id: Uuid, nonce: String): String =
|
|
||||||
"${session(id)}/structured/approvals/$nonce"
|
|
||||||
|
|
||||||
/** Strip a trailing slash so `{base}{path}` never doubles up. */
|
|
||||||
fun normalizeBase(base: String): String = base.trimEnd('/')
|
|
||||||
|
|
||||||
/** Join a daemon base URL and one of this object's paths. */
|
|
||||||
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
|
|
||||||
|
|
||||||
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
|
|
||||||
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
|
|
||||||
* through. */
|
|
||||||
fun wsBase(httpBase: String): String {
|
|
||||||
val base = normalizeBase(httpBase)
|
|
||||||
return when {
|
|
||||||
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
|
|
||||||
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
|
|
||||||
base.startsWith("wss://") || base.startsWith("ws://") -> base
|
|
||||||
else -> throw IllegalArgumentException(
|
|
||||||
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
|
|
||||||
|
|
||||||
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
|
|
||||||
|
|
||||||
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exponential backoff for daemon reconnect attempts: doubles from
|
|
||||||
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
|
||||||
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
|
||||||
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
|
||||||
* so this backs off instead of hammering the daemon while nothing changed.
|
|
||||||
*/
|
|
||||||
class Backoff(
|
|
||||||
private val initialMs: Long = INITIAL_MS,
|
|
||||||
private val maxMs: Long = MAX_MS,
|
|
||||||
) {
|
|
||||||
private var currentMs = initialMs
|
|
||||||
|
|
||||||
/** Delay before the next attempt; advances the sequence for the one after. */
|
|
||||||
fun nextDelayMs(): Long {
|
|
||||||
val delay = currentMs
|
|
||||||
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
|
||||||
return delay
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Back to [initialMs] after a successful connection. */
|
|
||||||
fun reset() {
|
|
||||||
currentMs = initialMs
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val INITIAL_MS = 500L
|
|
||||||
const val MAX_MS = 10_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
|
||||||
* replaces the map outright; deltas mutate one entry.
|
|
||||||
*/
|
|
||||||
class CatalogStore {
|
|
||||||
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
|
||||||
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
|
||||||
|
|
||||||
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
|
||||||
* the caller re-fetches a full snapshot over REST and calls [replace],
|
|
||||||
* mirroring how the web client reacts to a dropped broadcast frame. */
|
|
||||||
fun apply(event: CatalogEvent): Long? =
|
|
||||||
when (event) {
|
|
||||||
is CatalogEvent.Snapshot -> {
|
|
||||||
_sessions.value = event.sessions.associateBy { it.id }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionAdded -> {
|
|
||||||
_sessions.value = _sessions.value + (event.session.id to event.session)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionUpdated -> {
|
|
||||||
_sessions.value = _sessions.value + (event.session.id to event.session)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionRemoved -> {
|
|
||||||
_sessions.value = _sessions.value - event.id
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.Lagged -> event.skipped
|
|
||||||
}
|
|
||||||
|
|
||||||
fun replace(sessions: List<Session>) {
|
|
||||||
_sessions.value = sessions.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
_sessions.value = emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies [onAvailable] whenever a usable network appears — including the
|
|
||||||
* wifi/cellular handoff, which Android reports as one network disappearing
|
|
||||||
* and another becoming available rather than a single "changed" event.
|
|
||||||
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
|
||||||
* inspecting which network it is.
|
|
||||||
*/
|
|
||||||
class ConnectivityObserver(
|
|
||||||
context: Context,
|
|
||||||
private val onAvailable: () -> Unit,
|
|
||||||
) {
|
|
||||||
private val connectivityManager =
|
|
||||||
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
|
|
||||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) = onAvailable.invoke()
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
|
||||||
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
||||||
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
||||||
) {
|
|
||||||
onAvailable.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var registered = false
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (registered) return
|
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
|
||||||
registered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
if (!registered) return
|
|
||||||
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
|
||||||
registered = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
/** What happened between two `/api/info` replies. */
|
|
||||||
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare two daemon replies. Mirrors `compareIdentity` in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
|
||||||
* `crates/maestro-tui/src/daemon_link.rs`.
|
|
||||||
*
|
|
||||||
* The commit decides first: a different one is new server code. `instance`
|
|
||||||
* catches everything else — a restart of the same build — and is the only
|
|
||||||
* signal for a daemon built outside a git checkout, where the commit is
|
|
||||||
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
|
||||||
*/
|
|
||||||
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
|
||||||
val a = before.build.commit
|
|
||||||
val b = after.build.commit
|
|
||||||
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
|
||||||
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
|
||||||
return RestartKind.RESTARTED
|
|
||||||
}
|
|
||||||
return RestartKind.SAME
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
|
||||||
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
|
||||||
fun formatRelease(build: BuildInfo): String {
|
|
||||||
if (build.commit.isEmpty()) return "unknown release"
|
|
||||||
val parts = mutableListOf(build.commit)
|
|
||||||
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
|
||||||
if (build.dirty) parts.add("(dirty)")
|
|
||||||
return parts.joinToString(" ")
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import pt.naps.maestro.api.EventsConnection
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.api.MaestroWebSocket
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
|
||||||
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
|
||||||
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
|
||||||
private const val GRACE_MS = 1_200L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the connection to one daemon for the app's whole life: liveness,
|
|
||||||
* identity, and the session catalog. A Kotlin port of the state machine in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
|
||||||
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
|
||||||
* [Backoff] standing in for their flat poll, since a phone's network changes
|
|
||||||
* are bigger and longer-lived than the LAN blips those clients ride out.
|
|
||||||
*
|
|
||||||
* Everything that can change the link — a probe landing, the events socket
|
|
||||||
* dropping, the app resuming, the network coming back, a new daemon URL —
|
|
||||||
* goes through one [signals] channel consumed by one coroutine, so state
|
|
||||||
* transitions never race each other.
|
|
||||||
*/
|
|
||||||
class DaemonLink(
|
|
||||||
context: Context,
|
|
||||||
private val prefs: DaemonPrefs,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) : DefaultLifecycleObserver {
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
|
||||||
val state: StateFlow<LinkState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val _build = MutableStateFlow<BuildInfo?>(null)
|
|
||||||
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
|
||||||
|
|
||||||
private val _attempts = MutableStateFlow(0)
|
|
||||||
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
|
||||||
|
|
||||||
val catalog = CatalogStore()
|
|
||||||
|
|
||||||
private val backoff = Backoff()
|
|
||||||
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
|
||||||
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
|
||||||
|
|
||||||
private var generation = 0
|
|
||||||
private var currentBaseUrl: String = prefs.baseUrl.value
|
|
||||||
private var client = MaestroClient(currentBaseUrl, httpClient)
|
|
||||||
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
|
||||||
private var identity: DaemonInfo? = null
|
|
||||||
|
|
||||||
private var eventsJob: Job? = null
|
|
||||||
private var eventsConnection: EventsConnection? = null
|
|
||||||
private var retryJob: Job? = null
|
|
||||||
private var graceJob: Job? = null
|
|
||||||
private var loopJob: Job? = null
|
|
||||||
private var started = false
|
|
||||||
|
|
||||||
private sealed class Signal {
|
|
||||||
object Start : Signal()
|
|
||||||
object Resume : Signal()
|
|
||||||
object NetworkAvailable : Signal()
|
|
||||||
object SocketDropped : Signal()
|
|
||||||
data class BaseUrlChanged(val url: String) : Signal()
|
|
||||||
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
|
||||||
data class Lagged(val skipped: Long) : Signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
|
||||||
* kicks off the first probe. Call once. */
|
|
||||||
fun start() {
|
|
||||||
if (started) return
|
|
||||||
started = true
|
|
||||||
loopJob = scope.launch { runLoop() }
|
|
||||||
connectivity.start()
|
|
||||||
scope.launch(Dispatchers.Main.immediate) {
|
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
prefs.baseUrl.collect { url ->
|
|
||||||
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
signals.trySend(Signal.Start)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tears everything down. Only needed by tests and by an owner that is
|
|
||||||
* itself going away for good (this link is meant to outlive an Activity). */
|
|
||||||
fun close() {
|
|
||||||
connectivity.stop()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
closeEvents()
|
|
||||||
loopJob?.cancel()
|
|
||||||
signals.close()
|
|
||||||
scope.launch(Dispatchers.Main.immediate) {
|
|
||||||
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
|
||||||
signals.trySend(Signal.Resume)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runLoop() {
|
|
||||||
for (signal in signals) {
|
|
||||||
when (signal) {
|
|
||||||
is Signal.Start -> probeNow()
|
|
||||||
is Signal.Resume -> {
|
|
||||||
probeNow()
|
|
||||||
ensureEventsConnected()
|
|
||||||
}
|
|
||||||
is Signal.NetworkAvailable -> {
|
|
||||||
cancelRetry()
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
is Signal.SocketDropped -> handleSocketDropped()
|
|
||||||
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
|
||||||
is Signal.ProbeResult -> {
|
|
||||||
if (signal.generation == generation) handleProbeResult(signal.info)
|
|
||||||
}
|
|
||||||
is Signal.Lagged -> handleLagged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun probeNow() {
|
|
||||||
val gen = generation
|
|
||||||
val currentClient = client
|
|
||||||
scope.launch {
|
|
||||||
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
|
||||||
signals.trySend(Signal.ProbeResult(gen, info))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleProbeResult(info: DaemonInfo?) {
|
|
||||||
if (info == null) {
|
|
||||||
onProbeFailed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val before = identity
|
|
||||||
identity = info
|
|
||||||
_build.value = info.build
|
|
||||||
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
|
||||||
_state.value = LinkState.UPDATING
|
|
||||||
fullReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
backoff.reset()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
_attempts.value = 0
|
|
||||||
_state.value = LinkState.ONLINE
|
|
||||||
ensureEventsConnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onProbeFailed() {
|
|
||||||
_attempts.value += 1
|
|
||||||
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
|
||||||
_state.value = LinkState.RECONNECTING
|
|
||||||
armGrace()
|
|
||||||
}
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun armGrace() {
|
|
||||||
if (graceJob?.isActive == true) return
|
|
||||||
graceJob = scope.launch {
|
|
||||||
delay(GRACE_MS)
|
|
||||||
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelGrace() {
|
|
||||||
graceJob?.cancel()
|
|
||||||
graceJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleRetry() {
|
|
||||||
if (retryJob?.isActive == true) return
|
|
||||||
val delayMs = backoff.nextDelayMs()
|
|
||||||
retryJob = scope.launch {
|
|
||||||
delay(delayMs)
|
|
||||||
retryJob = null
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelRetry() {
|
|
||||||
retryJob?.cancel()
|
|
||||||
retryJob = null
|
|
||||||
backoff.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A build-stamp change: drop the events socket and the catalog it fed,
|
|
||||||
* then probe again shortly. The re-probe sees the same identity it just
|
|
||||||
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
|
||||||
* the socket — this *is* the full reconnect. */
|
|
||||||
private fun fullReconnect() {
|
|
||||||
closeEvents()
|
|
||||||
catalog.clear()
|
|
||||||
backoff.reset()
|
|
||||||
scope.launch {
|
|
||||||
delay(200)
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSocketDropped() {
|
|
||||||
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
|
||||||
_state.value = LinkState.RECONNECTING
|
|
||||||
armGrace()
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleBaseUrlChanged(url: String) {
|
|
||||||
currentBaseUrl = url
|
|
||||||
generation += 1
|
|
||||||
client = MaestroClient(url, httpClient)
|
|
||||||
socket = MaestroWebSocket(url, httpClient)
|
|
||||||
identity = null
|
|
||||||
_build.value = null
|
|
||||||
_attempts.value = 0
|
|
||||||
backoff.reset()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
closeEvents()
|
|
||||||
catalog.clear()
|
|
||||||
_state.value = LinkState.CONNECTING
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
|
||||||
* rather than trusting the map to still be complete. */
|
|
||||||
private fun handleLagged() {
|
|
||||||
val currentClient = client
|
|
||||||
scope.launch {
|
|
||||||
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureEventsConnected() {
|
|
||||||
if (eventsJob?.isActive == true) return
|
|
||||||
val connection = socket.connectEvents()
|
|
||||||
eventsConnection = connection
|
|
||||||
eventsJob = scope.launch {
|
|
||||||
try {
|
|
||||||
connection.events.collect { event ->
|
|
||||||
val skipped = catalog.apply(event)
|
|
||||||
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
|
||||||
}
|
|
||||||
signals.trySend(Signal.SocketDropped)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
signals.trySend(Signal.SocketDropped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun closeEvents() {
|
|
||||||
eventsJob?.cancel()
|
|
||||||
eventsJob = null
|
|
||||||
eventsConnection?.close()
|
|
||||||
eventsConnection = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the daemon base URL lives between launches. A settings screen and
|
|
||||||
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
|
||||||
* reads [baseUrl] and reconnects on change.
|
|
||||||
*/
|
|
||||||
class DaemonPrefs(context: Context) {
|
|
||||||
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
private val _baseUrl =
|
|
||||||
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
|
||||||
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
|
||||||
|
|
||||||
fun setBaseUrl(url: String) {
|
|
||||||
val trimmed = url.trim()
|
|
||||||
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
|
||||||
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
|
||||||
_baseUrl.value = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val FILE = "daemon_prefs"
|
|
||||||
private const val KEY_BASE_URL = "base_url"
|
|
||||||
|
|
||||||
/** Shown until the user points the app at their own daemon. */
|
|
||||||
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
|
||||||
|
|
||||||
/** Intent extra a launcher may set to point this launch at a specific
|
|
||||||
* daemon without going through settings. Persisted on read, so it
|
|
||||||
* sticks after the launch that carried it. */
|
|
||||||
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection lifecycle to the daemon. Mirrors the states in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
|
||||||
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
|
||||||
*
|
|
||||||
* - [CONNECTING] — first probe hasn't landed yet.
|
|
||||||
* - [ONLINE] — the daemon answered and identity is settled.
|
|
||||||
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
|
||||||
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
|
||||||
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
|
||||||
*/
|
|
||||||
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session activity, orthogonal to [SessionStatus]. Mirrors
|
|
||||||
* `maestro_protocol::ActivityState`.
|
|
||||||
*
|
|
||||||
* Deserializes leniently: an unknown variant from a newer daemon decodes as
|
|
||||||
* [Idle] instead of failing the whole [Session].
|
|
||||||
*/
|
|
||||||
@Serializable(with = ActivityStateSerializer::class)
|
|
||||||
enum class ActivityState {
|
|
||||||
Active,
|
|
||||||
Idle,
|
|
||||||
AwaitingInput,
|
|
||||||
Error,
|
|
||||||
Starting,
|
|
||||||
}
|
|
||||||
|
|
||||||
object ActivityStateSerializer : KSerializer<ActivityState> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: ActivityState) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): ActivityState =
|
|
||||||
when (decoder.decodeString()) {
|
|
||||||
"Active" -> ActivityState.Active
|
|
||||||
"AwaitingInput" -> ActivityState.AwaitingInput
|
|
||||||
"Error" -> ActivityState.Error
|
|
||||||
"Starting" -> ActivityState.Starting
|
|
||||||
else -> ActivityState.Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** Structured failure body inside an [ApiError] envelope. Mirrors
|
|
||||||
* `maestro_protocol::ErrorInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class ErrorInfo(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
/** Set only by the kill guard: what blocked an unforced
|
|
||||||
* `DELETE /api/sessions/:id`. */
|
|
||||||
val unsaved_work: InTheWay? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
|
|
||||||
* Mirrors `maestro_protocol::ApiError`. */
|
|
||||||
@Serializable
|
|
||||||
data class ApiError(val error: ErrorInfo)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** One local branch of a repository, from `GET /api/branches`. A client
|
|
||||||
* disables the entry when [is_head] is set or [worktree] is present — git
|
|
||||||
* refuses to check the same branch out in two worktrees at once. Mirrors
|
|
||||||
* `maestro_protocol::BranchInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class BranchInfo(
|
|
||||||
/** Short branch name (`main`, `feature/foo`). */
|
|
||||||
val name: String,
|
|
||||||
/** Whether this branch is the main checkout's current HEAD. */
|
|
||||||
val is_head: Boolean,
|
|
||||||
/** Toplevel path of the linked worktree holding this branch, if any. */
|
|
||||||
val worktree: String? = null,
|
|
||||||
)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
|
|
||||||
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
|
|
||||||
*
|
|
||||||
* A subscriber receives a single [Snapshot] immediately on connect, then a
|
|
||||||
* stream of deltas as sessions are created, updated, and killed. Skipped
|
|
||||||
* events (broadcast channel lag) surface as [Lagged].
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed class CatalogEvent {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Snapshot")
|
|
||||||
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionAdded")
|
|
||||||
data class SessionAdded(val session: Session) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionRemoved")
|
|
||||||
data class SessionRemoved(val id: Uuid) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionUpdated")
|
|
||||||
data class SessionUpdated(val session: Session) : CatalogEvent()
|
|
||||||
|
|
||||||
/** Server-issued notification that the broadcast queue dropped frames.
|
|
||||||
* Clients should re-fetch a full snapshot. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Lagged")
|
|
||||||
data class Lagged(val skipped: Long) : CatalogEvent()
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
|
|
||||||
* bytes travel in binary frames and are not modeled here. Mirrors
|
|
||||||
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class ClientControl {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Resize")
|
|
||||||
data class Resize(val cols: Int, val rows: Int) : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Ping")
|
|
||||||
object Ping : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Pong")
|
|
||||||
object Pong : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Error")
|
|
||||||
data class Error(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
val unsaved_work: InTheWay? = null,
|
|
||||||
) : ClientControl()
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
|
|
||||||
* `maestro_protocol::DaemonInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class DaemonInfo(
|
|
||||||
val hostname: String = "",
|
|
||||||
/** The protocol version the daemon implements. */
|
|
||||||
val version: String = "",
|
|
||||||
/** Home dir of the user the daemon runs as; empty for older daemons. */
|
|
||||||
val home_dir: String = "",
|
|
||||||
val presets: List<SpawnPreset> = emptyList(),
|
|
||||||
val build: BuildInfo = BuildInfo(),
|
|
||||||
/** Identity of the daemon *process*, regenerated on every start — a
|
|
||||||
* changed value across a reconnect means the daemon restarted. */
|
|
||||||
val instance: String = "",
|
|
||||||
/** Canonical leader key the operator configured (`[tui].leader`), so a
|
|
||||||
* client binds the same chord the TUI does. Empty means "use your
|
|
||||||
* default". */
|
|
||||||
val leader: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A spawn-form command preset advertised by the daemon. */
|
|
||||||
@Serializable
|
|
||||||
data class SpawnPreset(
|
|
||||||
val id: String,
|
|
||||||
val label: String,
|
|
||||||
val command: String,
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Build provenance of the running daemon. Every field is empty when the
|
|
||||||
* binary was built outside a git checkout. */
|
|
||||||
@Serializable
|
|
||||||
data class BuildInfo(
|
|
||||||
val commit: String = "",
|
|
||||||
val commit_date: String = "",
|
|
||||||
val dirty: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
|
|
||||||
* `maestro_protocol::DirListing`. */
|
|
||||||
@Serializable
|
|
||||||
data class DirListing(
|
|
||||||
/** Resolved absolute directory that was listed (after ~/relative
|
|
||||||
* expansion). */
|
|
||||||
val path: String,
|
|
||||||
/** Immediate subdirectory names, sorted case-insensitively. */
|
|
||||||
val entries: List<String> = emptyList(),
|
|
||||||
/** True when the listing was capped server-side. */
|
|
||||||
val truncated: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One deduplicated recent working dir for the spawn form's recents picker.
|
|
||||||
* Mirrors `maestro_protocol::RecentDir`. */
|
|
||||||
@Serializable
|
|
||||||
data class RecentDir(
|
|
||||||
val cwd: String,
|
|
||||||
val last_command: String,
|
|
||||||
val last_used: IsoTimestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
|
|
||||||
* and deduped by `cwd`. */
|
|
||||||
@Serializable
|
|
||||||
data class RecentDirs(
|
|
||||||
val dirs: List<RecentDir> = emptyList(),
|
|
||||||
val truncated: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
|
|
||||||
* `null` when the path isn't inside a repo. Mirrors
|
|
||||||
* `maestro_protocol::GitRoot`. */
|
|
||||||
@Serializable
|
|
||||||
data class GitRoot(val root: String? = null)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
|
|
||||||
typealias Uuid = String
|
|
||||||
|
|
||||||
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
|
|
||||||
typealias IsoTimestamp = String
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** What currently blocks an unforced kill of a session's worktree,
|
|
||||||
* recomputed by the daemon on every read. Mirrors
|
|
||||||
* `maestro_protocol::InTheWay`. */
|
|
||||||
@Serializable
|
|
||||||
data class InTheWay(
|
|
||||||
val uncommitted_files: Int = 0,
|
|
||||||
val untracked_files: Int = 0,
|
|
||||||
val unpushed_commits: Int = 0,
|
|
||||||
/** Lines added across the tracked changes, staged and unstaged together. */
|
|
||||||
val insertions: Int = 0,
|
|
||||||
/** Lines removed across the same tracked changes. */
|
|
||||||
val deletions: Int = 0,
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
|
|
||||||
* `maestro_protocol::NameBatch`. */
|
|
||||||
@Serializable
|
|
||||||
data class NameBatch(val names: List<String>)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
|
|
||||||
* `maestro_protocol::NameSource`.
|
|
||||||
*
|
|
||||||
* Deserializes leniently: an unknown spelling decodes as [User], the settled
|
|
||||||
* variant — a name wrongly painted as settled is only a missed dimming; one
|
|
||||||
* wrongly painted as provisional invites overwriting a name a person chose.
|
|
||||||
*/
|
|
||||||
@Serializable(with = NameSourceSerializer::class)
|
|
||||||
enum class NameSource {
|
|
||||||
User,
|
|
||||||
Generated,
|
|
||||||
Haiku,
|
|
||||||
}
|
|
||||||
|
|
||||||
object NameSourceSerializer : KSerializer<NameSource> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: NameSource) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): NameSource =
|
|
||||||
when (decoder.decodeString()) {
|
|
||||||
"Generated" -> NameSource.Generated
|
|
||||||
"Haiku" -> NameSource.Haiku
|
|
||||||
else -> NameSource.User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** One discovered git repository (or linked worktree) for `GET
|
|
||||||
* /api/projects`. Mirrors `maestro_protocol::Project`. */
|
|
||||||
@Serializable
|
|
||||||
data class Project(
|
|
||||||
/** Absolute path of the repository/worktree toplevel. */
|
|
||||||
val path: String,
|
|
||||||
/** Basename of [path]. */
|
|
||||||
val name: String,
|
|
||||||
val is_worktree: Boolean = false,
|
|
||||||
/** The main checkout this worktree belongs to; `null` on plain repos. */
|
|
||||||
val base_repo: String? = null,
|
|
||||||
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
|
||||||
* any directory inside it; `null` when neither is known. */
|
|
||||||
val last_activity: IsoTimestamp? = null,
|
|
||||||
/** Command last spawned in this project, from spawn history. */
|
|
||||||
val last_command: String? = null,
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
|
|
||||||
* older payload omits them — mirrors the daemon's Unix-epoch default. */
|
|
||||||
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
/** A live or recently-dead tmux session managed by the daemon. Mirrors
|
|
||||||
* `maestro_protocol::Session`. */
|
|
||||||
@Serializable
|
|
||||||
data class Session(
|
|
||||||
val id: Uuid,
|
|
||||||
val cwd: String,
|
|
||||||
val command: String,
|
|
||||||
val env: Map<String, String> = emptyMap(),
|
|
||||||
val metadata: Map<String, JsonElement> = emptyMap(),
|
|
||||||
/** The worktree this session owns, when it was spawned with one. */
|
|
||||||
val worktree: SessionWorktree? = null,
|
|
||||||
val status: SessionStatus,
|
|
||||||
val created_at: IsoTimestamp,
|
|
||||||
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
|
|
||||||
* daemon's activity watcher observed it. `null` until observed. */
|
|
||||||
val foreground: String? = null,
|
|
||||||
/** Inferred activity dimension, orthogonal to [status]. */
|
|
||||||
val activity: ActivityState = ActivityState.Idle,
|
|
||||||
/** Short human string an agent reported alongside its activity — e.g. why
|
|
||||||
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
|
|
||||||
val activity_detail: String? = null,
|
|
||||||
val last_activity_at: IsoTimestamp = EPOCH,
|
|
||||||
val activity_since: IsoTimestamp = EPOCH,
|
|
||||||
/** The pane's *current* working directory, distinct from [cwd] (the spawn
|
|
||||||
* dir). `null` until first observed. */
|
|
||||||
val current_cwd: String? = null,
|
|
||||||
/** What currently blocks an unforced kill of this session, as the
|
|
||||||
* daemon's watcher last probed it. `null` while clear or not yet probed. */
|
|
||||||
val blocked_work: InTheWay? = null,
|
|
||||||
/** When this session last became the pane a client was showing. `null`
|
|
||||||
* until the session has been focused at least once. */
|
|
||||||
val last_focused_at: IsoTimestamp? = null,
|
|
||||||
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
|
|
||||||
* immutable for the session's life. */
|
|
||||||
val view: SessionView = SessionView.Terminal,
|
|
||||||
/** The ACP session id the selected provider adapter returned from
|
|
||||||
* `session/new`. Only a [SessionView.Structured] session whose handshake
|
|
||||||
* succeeded has one. */
|
|
||||||
val acp_session_id: String? = null,
|
|
||||||
/** When `true`, the daemon auto-approves every ACP permission request for
|
|
||||||
* this session instead of surfacing them to the client. */
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
/** Prompts queued on the daemon while a turn was in flight or the adapter
|
|
||||||
* was still booting. */
|
|
||||||
val queued_prompts: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class SessionStatus { Running, Dead }
|
|
||||||
|
|
||||||
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
|
|
||||||
* `Terminal` is the default. */
|
|
||||||
@Serializable
|
|
||||||
enum class SessionView { Terminal, Structured }
|
|
||||||
|
|
||||||
/** A session's daemon-created worktree. `base_repo` is the repository's main
|
|
||||||
* checkout, which is what the session tree groups by. */
|
|
||||||
@Serializable
|
|
||||||
data class SessionWorktree(
|
|
||||||
val path: String,
|
|
||||||
val base_repo: String,
|
|
||||||
)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** `POST /api/sessions` body. Mirrors
|
|
||||||
* `maestro_protocol::CreateSessionRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class CreateSessionRequest(
|
|
||||||
val cwd: String,
|
|
||||||
val command: String,
|
|
||||||
val env: Map<String, String> = emptyMap(),
|
|
||||||
val metadata: Map<String, JsonElement> = emptyMap(),
|
|
||||||
val worktree: WorktreeSpec? = null,
|
|
||||||
/** The process shape to spawn. Defaults to a `Terminal` session. */
|
|
||||||
val view: SessionView = SessionView.Terminal,
|
|
||||||
/** When `true`, the daemon auto-approves every ACP permission request for
|
|
||||||
* this structured session. */
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
|
|
||||||
* `maestro_protocol::WorktreeSpec`. */
|
|
||||||
@Serializable
|
|
||||||
data class WorktreeSpec(
|
|
||||||
val base_repo: String,
|
|
||||||
val branch: String,
|
|
||||||
/** When `false`, [branch] already exists and is checked out as-is.
|
|
||||||
* Defaults to `true` (create a new branch). */
|
|
||||||
val create_branch: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
|
|
||||||
* clears the custom one, so the card falls back to the command. Mirrors
|
|
||||||
* `maestro_protocol::RenameSessionRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class RenameSessionRequest(val name: String)
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
|
|
||||||
* the one currently showing this session. Mirrors
|
|
||||||
* `maestro_protocol::SetFocusRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class SetFocusRequest(val focused: Boolean)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** The semantic shape of a [TranscriptRow]. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
|
||||||
@Serializable
|
|
||||||
enum class TranscriptRowKind {
|
|
||||||
@SerialName("user") User,
|
|
||||||
@SerialName("agent_text") AgentText,
|
|
||||||
@SerialName("agent_thought") AgentThought,
|
|
||||||
@SerialName("tool_call") ToolCall,
|
|
||||||
@SerialName("tool_diff") ToolDiff,
|
|
||||||
@SerialName("approval") Approval,
|
|
||||||
@SerialName("plan") Plan,
|
|
||||||
@SerialName("notice") Notice,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One client-neutral item in a structured session transcript. [data]
|
|
||||||
* preserves the ACP payload verbatim without committing to an ACP
|
|
||||||
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
|
|
||||||
@Serializable
|
|
||||||
data class TranscriptRow(
|
|
||||||
/** Stable within one session, so later deltas can address this row. */
|
|
||||||
val id: String,
|
|
||||||
val kind: TranscriptRowKind,
|
|
||||||
val data: JsonElement,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The complete transcript at a durable event-log position — a structured
|
|
||||||
* session's first `WS /api/structured/:id` message. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptSnapshot`. */
|
|
||||||
@Serializable
|
|
||||||
data class TranscriptSnapshot(
|
|
||||||
val cursor: Long,
|
|
||||||
val rows: List<TranscriptRow> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A change to one transcript row, addressed by [id]. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class TranscriptDelta {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append")
|
|
||||||
data class Append(val row: TranscriptRow) : TranscriptDelta()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("patch")
|
|
||||||
data class Patch(val row: TranscriptRow) : TranscriptDelta()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("remove")
|
|
||||||
data class Remove(val id: String) : TranscriptDelta()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
|
||||||
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
|
|
||||||
@Serializable
|
|
||||||
data class TurnState(
|
|
||||||
val responding: Boolean = false,
|
|
||||||
val thinking: Boolean = false,
|
|
||||||
val active_tool_calls: List<String> = emptyList(),
|
|
||||||
/** Wall-clock start of the active prompt, so a reconnecting client keeps
|
|
||||||
* one continuous elapsed-time display instead of restarting at zero. */
|
|
||||||
val started_at: IsoTimestamp? = null,
|
|
||||||
val started_context_tokens: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A whole-session view derived from ACP notifications. Mirrors
|
|
||||||
* `maestro_protocol::ReducedState`.
|
|
||||||
*
|
|
||||||
* [pending_approval], [plan], [usage], [context] and [config_options] carry
|
|
||||||
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
|
|
||||||
* compatibility guarantee, so they are kept as [JsonElement] rather than
|
|
||||||
* modeled strongly.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ReducedState(
|
|
||||||
val turn: TurnState = TurnState(),
|
|
||||||
val pending_approval: JsonElement? = null,
|
|
||||||
val plan: JsonElement? = null,
|
|
||||||
val usage: JsonElement? = null,
|
|
||||||
val context: JsonElement? = null,
|
|
||||||
val model: String? = null,
|
|
||||||
val mode: String? = null,
|
|
||||||
val config_options: JsonElement? = null,
|
|
||||||
/** Prompts queued on the daemon while a turn is in flight. */
|
|
||||||
val queued_prompts: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Server messages on `WS /api/structured/:id`. Mirrors
|
|
||||||
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class StructuredEvent {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("transcript_snapshot")
|
|
||||||
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("reduced_state")
|
|
||||||
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("projection_update")
|
|
||||||
data class ProjectionUpdate(
|
|
||||||
val transcript: TranscriptDelta? = null,
|
|
||||||
val state: ReducedState,
|
|
||||||
) : StructuredEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/prompt` body. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredPromptRequest(val prompt: String)
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/config` body: change one option
|
|
||||||
* advertised by a live ACP session. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
|
|
||||||
|
|
||||||
/** Operator decision for a pending ACP permission request
|
|
||||||
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
|
|
||||||
* string on the wire, not `{decision: ...}`. Mirrors
|
|
||||||
* `maestro_protocol::StructuredApprovalDecision`. */
|
|
||||||
@Serializable
|
|
||||||
enum class StructuredApprovalDecision {
|
|
||||||
@SerialName("allow_once") AllowOnce,
|
|
||||||
@SerialName("allow_for_session") AllowForSession,
|
|
||||||
@SerialName("deny") Deny,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response of `POST /api/sessions/:id/structured/attachments`. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredAttachmentResponse(val path: String)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.InfiniteTransition
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.ActivityState
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
|
|
||||||
* density-independent pixels rather than raw pixels. */
|
|
||||||
val ACTIVITY_RAIL_WIDTH = 7.dp
|
|
||||||
|
|
||||||
private val RAIL_ACTIVE = Color(0xFF3DDC84)
|
|
||||||
private val RAIL_IDLE = Color(0xFF9AA0A6)
|
|
||||||
private val RAIL_AWAITING = Color(0xFFF5A623)
|
|
||||||
private val RAIL_ERROR = Color(0xFFE5484D)
|
|
||||||
|
|
||||||
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
|
|
||||||
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
|
|
||||||
* rendering never fails (Precedence). */
|
|
||||||
fun activityColor(activity: ActivityState): Color =
|
|
||||||
when (activity) {
|
|
||||||
ActivityState.Active -> RAIL_ACTIVE
|
|
||||||
ActivityState.AwaitingInput -> RAIL_AWAITING
|
|
||||||
ActivityState.Error -> RAIL_ERROR
|
|
||||||
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The accessible name the rail carries, since colour is never the only
|
|
||||||
* signal (docs/SIDEBAR.md). */
|
|
||||||
fun activityStateWord(activity: ActivityState): String =
|
|
||||||
when (activity) {
|
|
||||||
ActivityState.Active -> "Active"
|
|
||||||
ActivityState.Idle -> "Idle"
|
|
||||||
ActivityState.Starting -> "Starting"
|
|
||||||
ActivityState.AwaitingInput -> "Awaiting input"
|
|
||||||
ActivityState.Error -> "Error"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
|
||||||
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
|
||||||
@Composable
|
|
||||||
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
|
||||||
val alpha by animateFloat(
|
|
||||||
initialValue = 1f,
|
|
||||||
targetValue = minAlpha,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse,
|
|
||||||
),
|
|
||||||
label = "activity-rail-alpha",
|
|
||||||
)
|
|
||||||
return alpha
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The card's rail: a coloured bar in the gutter, full card height. It paints
|
|
||||||
* for every card, `Idle` included, and carries motion only for states that
|
|
||||||
* need attention — a hard blink for `AwaitingInput`, a fast flash for
|
|
||||||
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
|
|
||||||
@Composable
|
|
||||||
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
|
||||||
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
|
||||||
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
|
||||||
val alpha = when (activity) {
|
|
||||||
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
|
|
||||||
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
|
|
||||||
else -> 1f
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(ACTIVITY_RAIL_WIDTH)
|
|
||||||
.semantics { contentDescription = activityStateWord(activity) }
|
|
||||||
.background(color.copy(alpha = alpha)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
|
|
||||||
* folder's cards are one row away, so it carries no highlight of its own —
|
|
||||||
* only a collapsed header is ever a keyboard-walk stop, but that distinction
|
|
||||||
* belongs to the caller driving the walk, not this composable. */
|
|
||||||
@Composable
|
|
||||||
fun FolderNode(
|
|
||||||
label: String,
|
|
||||||
collapsed: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
depthPadding: Int = 0,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (collapsed) "▸" else "▾",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(visible = !collapsed) {
|
|
||||||
Column { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
|
|
||||||
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
|
|
||||||
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
|
|
||||||
* only through the callbacks the caller supplies. */
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun SessionCard(
|
|
||||||
session: Session,
|
|
||||||
nowMs: Long,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
depthPadding: Int = 0,
|
|
||||||
onOpen: (Session) -> Unit = {},
|
|
||||||
onRename: (Session, String) -> Unit = { _, _ -> },
|
|
||||||
onKill: (Session) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
|
||||||
var renaming by remember { mutableStateOf(false) }
|
|
||||||
var confirmingKill by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val running = session.status == SessionStatus.Running
|
|
||||||
val name = sessionName(session)
|
|
||||||
val muted = isProvisionalName(session)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = { onOpen(session) },
|
|
||||||
onLongClick = { menuExpanded = true },
|
|
||||||
)
|
|
||||||
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = when {
|
|
||||||
!running -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
muted -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
MetaLine(session = session, nowMs = nowMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Rename") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
renaming = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Spawn similar") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
onSpawnSimilar(session)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Kill") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
confirmingKill = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renaming) {
|
|
||||||
RenameDialog(
|
|
||||||
initial = customSessionName(session) ?: "",
|
|
||||||
onCommit = {
|
|
||||||
renaming = false
|
|
||||||
onRename(session, it)
|
|
||||||
},
|
|
||||||
onCancel = { renaming = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirmingKill) {
|
|
||||||
KillConfirmDialog(
|
|
||||||
session = session,
|
|
||||||
onConfirm = {
|
|
||||||
confirmingKill = false
|
|
||||||
onKill(session)
|
|
||||||
},
|
|
||||||
onCancel = { confirmingKill = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
|
|
||||||
* — the line itself is unconditional, only its fragments are adaptive. */
|
|
||||||
@Composable
|
|
||||||
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
|
|
||||||
val heldMs = activityHeldMs(session.activity_since, nowMs)
|
|
||||||
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
|
|
||||||
activityDurationLabel(heldMs)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val blockers = sessionBlockers(session)
|
|
||||||
|
|
||||||
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (duration != null) {
|
|
||||||
Text(
|
|
||||||
text = duration,
|
|
||||||
color = activityColor(session.activity),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
if (session.activity_detail != null) {
|
|
||||||
Text(
|
|
||||||
text = session.activity_detail,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = programName(session.command),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
if (blockers != null) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
BlockedFragment(blockers)
|
|
||||||
}
|
|
||||||
if (session.status == SessionStatus.Dead) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "exited",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BlockedFragment(blockers: InTheWay) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
blockerStats(blockers).forEachIndexed { index, stat ->
|
|
||||||
if (index > 0) Spacer(Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stat.text,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = when (stat.kind) {
|
|
||||||
"add" -> MaterialTheme.colorScheme.primary
|
|
||||||
"del" -> MaterialTheme.colorScheme.error
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
|
|
||||||
var text by remember { mutableStateOf(initial) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
title = { Text("Rename session") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
|
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
|
||||||
val blockers = sessionBlockers(session)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
title = { Text("Kill “${sessionName(session)}”?") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (blockers != null) {
|
|
||||||
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
|
|
||||||
} else {
|
|
||||||
"This ends the session's process. This can't be undone."
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
|
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.NameSource
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
|
||||||
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
|
||||||
|
|
||||||
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
|
||||||
* anything implausibly old is "never observed" rather than a huge duration. */
|
|
||||||
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
|
||||||
|
|
||||||
/** Milliseconds held in the current activity state, or `null` when `since` is
|
|
||||||
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
|
||||||
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
|
||||||
if (since.isNullOrEmpty()) return null
|
|
||||||
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
|
||||||
if (t < PLAUSIBLE_EPOCH_MS) return null
|
|
||||||
return (nowMs - t).coerceAtLeast(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
|
||||||
* everywhere so the counter never shows a duration the state hasn't reached. */
|
|
||||||
fun activityDurationLabel(heldMs: Long): String {
|
|
||||||
val seconds = heldMs / 1000
|
|
||||||
if (seconds < 60) return "${seconds}s"
|
|
||||||
val minutes = seconds / 60
|
|
||||||
if (minutes < 60) return "${minutes}m"
|
|
||||||
return "${minutes / 60}h"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session's own name (#125), without the command fallback: `null` for a
|
|
||||||
* session that was never given one. */
|
|
||||||
fun customSessionName(session: Session): String? {
|
|
||||||
val element = session.metadata["name"] ?: return null
|
|
||||||
val primitive = element as? JsonPrimitive ?: return null
|
|
||||||
if (!primitive.isString) return null
|
|
||||||
return primitive.content.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line 1's name: `metadata.name` if present, else the command line. */
|
|
||||||
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
|
||||||
|
|
||||||
/** Where the session's name came from (#131), read leniently: anything
|
|
||||||
* unrecognised — including a session named before the field existed — reads
|
|
||||||
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
|
||||||
fun sessionNameSource(session: Session): NameSource {
|
|
||||||
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
|
||||||
return when (element.jsonPrimitive.content) {
|
|
||||||
"Generated" -> NameSource.Generated
|
|
||||||
"Haiku" -> NameSource.Haiku
|
|
||||||
else -> NameSource.User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
|
||||||
* generated at spawn and may still replace with one derived from the first
|
|
||||||
* prompt. */
|
|
||||||
fun isProvisionalName(session: Session): Boolean =
|
|
||||||
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
|
||||||
|
|
||||||
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
|
||||||
* picks the colour; `text` is what the card prints. */
|
|
||||||
data class BlockerStat(val kind: String, val text: String)
|
|
||||||
|
|
||||||
/** #83: this session's blocked-work breakdown when something actually blocks
|
|
||||||
* an unforced kill; `null` while clear or not yet probed. */
|
|
||||||
fun sessionBlockers(session: Session): InTheWay? {
|
|
||||||
val w = session.blocked_work ?: return null
|
|
||||||
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
|
||||||
return if (blocked) w else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
|
||||||
* only tracked change carries no line delta falls back to the file count
|
|
||||||
* (`~2`) rather than claiming `+0 -0`. */
|
|
||||||
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
|
||||||
val stats = mutableListOf<BlockerStat>()
|
|
||||||
if (w.insertions > 0 || w.deletions > 0) {
|
|
||||||
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
|
||||||
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
|
||||||
} else if (w.uncommitted_files > 0) {
|
|
||||||
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
|
||||||
}
|
|
||||||
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
|
||||||
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The spelled-out phrasing shared by the kill refusal and every client's
|
|
||||||
* tooltip, so no two describe the same tree differently. */
|
|
||||||
fun formatInTheWay(w: InTheWay): String {
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
fun push(n: Int, unit: String) {
|
|
||||||
if (n <= 0) return
|
|
||||||
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
|
||||||
}
|
|
||||||
push(w.uncommitted_files, "uncommitted file")
|
|
||||||
push(w.untracked_files, "untracked file")
|
|
||||||
push(w.unpushed_commits, "unpushed commit")
|
|
||||||
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
||||||
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
|
||||||
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
|
||||||
|
|
||||||
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
|
||||||
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
|
||||||
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
|
||||||
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
|
||||||
* is returned as written rather than as an empty string. */
|
|
||||||
fun programName(command: String): String {
|
|
||||||
val trimmed = command.trim()
|
|
||||||
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
|
||||||
if (words.isEmpty()) return trimmed
|
|
||||||
|
|
||||||
var i = 0
|
|
||||||
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
|
||||||
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
|
||||||
i++
|
|
||||||
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
|
||||||
}
|
|
||||||
if (i >= words.size) return basename(words.last())
|
|
||||||
|
|
||||||
when {
|
|
||||||
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
|
||||||
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
|
||||||
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
|
||||||
}
|
|
||||||
val program = words.getOrNull(i) ?: return basename(words.last())
|
|
||||||
return basename(program)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun basename(path: String): String = path.substringAfterLast('/')
|
|
||||||
|
|
||||||
/** Sessions are grouped by the repository they belong to (#265): a
|
|
||||||
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
|
||||||
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
|
||||||
|
|
||||||
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
|
||||||
* sorted by name. */
|
|
||||||
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
|
||||||
|
|
||||||
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
|
||||||
* groups themselves sorted by label. */
|
|
||||||
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
|
||||||
sessions
|
|
||||||
.groupBy(::sessionGroupKey)
|
|
||||||
.map { (key, members) ->
|
|
||||||
SessionGroup(
|
|
||||||
key = key,
|
|
||||||
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
|
||||||
sessions = members.sortedBy { sessionName(it).lowercase() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sortedBy { it.label.lowercase() }
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import pt.naps.maestro.connection.CatalogStore
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
|
|
||||||
* faster than that so a duration's cutover lands within one visible frame. */
|
|
||||||
@Composable
|
|
||||||
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
|
|
||||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
|
||||||
LaunchedEffect(periodMs) {
|
|
||||||
while (true) {
|
|
||||||
delay(periodMs)
|
|
||||||
now = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
|
|
||||||
* grouped into collapsible folders by repository, each rendered as a
|
|
||||||
* two-line [SessionCard] behind its [ActivityRail]. */
|
|
||||||
@Composable
|
|
||||||
fun SidebarContent(
|
|
||||||
catalogStore: CatalogStore,
|
|
||||||
onOpenSession: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
|
||||||
onKillSession: (Uuid) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val sessions by catalogStore.sessions.collectAsState()
|
|
||||||
SidebarContent(
|
|
||||||
sessions = sessions.values.toList(),
|
|
||||||
onOpenSession = onOpenSession,
|
|
||||||
modifier = modifier,
|
|
||||||
onRenameSession = onRenameSession,
|
|
||||||
onKillSession = onKillSession,
|
|
||||||
onSpawnSimilar = onSpawnSimilar,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as the [CatalogStore]-driven overload, but takes the session list
|
|
||||||
* directly — the shape used by tests and previews. */
|
|
||||||
@Composable
|
|
||||||
fun SidebarContent(
|
|
||||||
sessions: List<Session>,
|
|
||||||
onOpenSession: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
|
||||||
onKillSession: (Uuid) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val nowMs = rememberTickingNowMs()
|
|
||||||
val groups = remember(sessions) { groupSessions(sessions) }
|
|
||||||
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
Text(
|
|
||||||
text = "Sessions",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
if (groups.isEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "No sessions",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
for (group in groups) {
|
|
||||||
val isCollapsed = collapsed[group.key] == true
|
|
||||||
item(key = "folder:${group.key}") {
|
|
||||||
FolderNode(
|
|
||||||
label = group.label,
|
|
||||||
collapsed = isCollapsed,
|
|
||||||
onToggle = { collapsed[group.key] = !isCollapsed },
|
|
||||||
depthPadding = 8,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
for (session in group.sessions) {
|
|
||||||
SessionCard(
|
|
||||||
session = session,
|
|
||||||
nowMs = nowMs,
|
|
||||||
depthPadding = 16,
|
|
||||||
onOpen = onOpenSession,
|
|
||||||
onRename = { s, name -> onRenameSession(s.id, name) },
|
|
||||||
onKill = { s -> onKillSession(s.id) },
|
|
||||||
onSpawnSimilar = onSpawnSimilar,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import pt.naps.maestro.data.CreateSessionRequest
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
import pt.naps.maestro.data.WorktreeSpec
|
|
||||||
|
|
||||||
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
|
||||||
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
|
||||||
|
|
||||||
/** Commands that just launch another program, looked through to find the
|
|
||||||
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
|
||||||
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
|
||||||
|
|
||||||
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
|
||||||
|
|
||||||
/** Program a command line would run, stripped of leading `FOO=bar`
|
|
||||||
* assignments, known wrappers, its own arguments, and any path prefix. A
|
|
||||||
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
|
||||||
* client uses — good enough for the preset commands and the common
|
|
||||||
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
|
||||||
internal fun resolveProgram(command: String): String {
|
|
||||||
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
|
||||||
var i = 0
|
|
||||||
while (i < words.size) {
|
|
||||||
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
|
||||||
if (i >= words.size) return ""
|
|
||||||
val word = words[i].substringAfterLast('/').lowercase()
|
|
||||||
if (word !in WRAPPER_PROGRAMS) return word
|
|
||||||
i++
|
|
||||||
while (i < words.size && words[i].startsWith("-")) i++
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
|
||||||
|
|
||||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
|
||||||
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
enum class WorktreeBranchMode { New, Existing }
|
|
||||||
|
|
||||||
/** The spawn form's current values, gathered at submit time. Mirrors
|
|
||||||
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
|
||||||
data class SpawnFormValues(
|
|
||||||
val command: String,
|
|
||||||
val cwd: String,
|
|
||||||
val name: String,
|
|
||||||
val structuredView: Boolean,
|
|
||||||
val autoApprove: Boolean,
|
|
||||||
val worktreeEnabled: Boolean,
|
|
||||||
val worktreeBranch: String,
|
|
||||||
val worktreeBranchMode: WorktreeBranchMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface SpawnBuildResult {
|
|
||||||
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
|
||||||
data class Error(val message: String) : SpawnBuildResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
|
||||||
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
|
||||||
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
|
||||||
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
|
||||||
|
|
||||||
val name = values.name.trim()
|
|
||||||
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
|
||||||
|
|
||||||
var worktree: WorktreeSpec? = null
|
|
||||||
if (values.worktreeEnabled) {
|
|
||||||
val branch = values.worktreeBranch.trim()
|
|
||||||
if (baseRepo.isNullOrBlank()) {
|
|
||||||
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
|
||||||
}
|
|
||||||
if (branch.isEmpty()) {
|
|
||||||
return SpawnBuildResult.Error(
|
|
||||||
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
|
||||||
else "Worktree requires a branch name.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
worktree = WorktreeSpec(
|
|
||||||
base_repo = baseRepo,
|
|
||||||
branch = branch,
|
|
||||||
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SpawnBuildResult.Ok(
|
|
||||||
CreateSessionRequest(
|
|
||||||
cwd = values.cwd,
|
|
||||||
command = values.command,
|
|
||||||
metadata = metadata,
|
|
||||||
worktree = worktree,
|
|
||||||
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
|
||||||
SessionView.Structured
|
|
||||||
} else {
|
|
||||||
SessionView.Terminal
|
|
||||||
},
|
|
||||||
auto_approve = values.autoApprove,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
|
||||||
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
|
||||||
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
|
||||||
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
|
||||||
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
|
||||||
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
|
||||||
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
|
||||||
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
|
||||||
)
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.Snackbar
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
|
|
||||||
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
|
||||||
* a preset chip row, a searchable project list, a raw-path autocomplete
|
|
||||||
* fallback, a branch/worktree picker, a session name, and the
|
|
||||||
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
|
||||||
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
|
||||||
* tests. */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SpawnSheet(
|
|
||||||
viewModel: SpawnViewModel,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSpawned: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsState()
|
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
|
||||||
SpawnSheetContent(
|
|
||||||
state = state,
|
|
||||||
onSelectPreset = viewModel::selectPreset,
|
|
||||||
onCustomCommandChange = viewModel::setCustomCommand,
|
|
||||||
onStructuredViewChange = viewModel::setStructuredView,
|
|
||||||
onProjectQueryChange = viewModel::setProjectQuery,
|
|
||||||
onSelectProject = viewModel::selectProject,
|
|
||||||
onCwdChange = viewModel::setCwd,
|
|
||||||
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
|
||||||
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
|
||||||
onBranchModeChange = viewModel::setBranchMode,
|
|
||||||
onNewBranchChange = viewModel::setNewBranch,
|
|
||||||
onExistingBranchChange = viewModel::setExistingBranch,
|
|
||||||
onNameChange = viewModel::setName,
|
|
||||||
onReroll = viewModel::rerollName,
|
|
||||||
onDismissError = viewModel::dismissError,
|
|
||||||
onSubmit = { viewModel.submit(onSpawned) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SpawnSheetContent(
|
|
||||||
state: SpawnUiState,
|
|
||||||
onSelectPreset: (String) -> Unit,
|
|
||||||
onCustomCommandChange: (String) -> Unit,
|
|
||||||
onStructuredViewChange: (Boolean) -> Unit,
|
|
||||||
onProjectQueryChange: (String) -> Unit,
|
|
||||||
onSelectProject: (Project) -> Unit,
|
|
||||||
onCwdChange: (String) -> Unit,
|
|
||||||
onPickDirSuggestion: (String) -> Unit,
|
|
||||||
onWorktreeEnabledChange: (Boolean) -> Unit,
|
|
||||||
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
|
||||||
onNewBranchChange: (String) -> Unit,
|
|
||||||
onExistingBranchChange: (String) -> Unit,
|
|
||||||
onNameChange: (String) -> Unit,
|
|
||||||
onReroll: () -> Unit,
|
|
||||||
onDismissError: () -> Unit,
|
|
||||||
onSubmit: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
LaunchedEffect(state.error) {
|
|
||||||
val message = state.error ?: return@LaunchedEffect
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
onDismissError()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
PresetRow(
|
|
||||||
presets = state.presets,
|
|
||||||
selectedPresetId = state.selectedPresetId,
|
|
||||||
onSelectPreset = onSelectPreset,
|
|
||||||
)
|
|
||||||
if (state.isCustomSelected) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.customCommand,
|
|
||||||
onValueChange = onCustomCommandChange,
|
|
||||||
label = { Text("Command") },
|
|
||||||
placeholder = { Text("bash -l") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ProjectPicker(
|
|
||||||
query = state.projectQuery,
|
|
||||||
onQueryChange = onProjectQueryChange,
|
|
||||||
projects = state.visibleProjects,
|
|
||||||
loading = state.projectsLoading,
|
|
||||||
selectedPath = state.selectedProjectPath,
|
|
||||||
homeDir = state.homeDir,
|
|
||||||
onSelectProject = onSelectProject,
|
|
||||||
)
|
|
||||||
|
|
||||||
DirAutocompleteField(
|
|
||||||
cwd = state.cwd,
|
|
||||||
suggestions = state.dirSuggestions,
|
|
||||||
onCwdChange = onCwdChange,
|
|
||||||
onPickSuggestion = onPickDirSuggestion,
|
|
||||||
)
|
|
||||||
|
|
||||||
BranchSection(
|
|
||||||
enabled = state.worktreeEnabled,
|
|
||||||
onEnabledChange = onWorktreeEnabledChange,
|
|
||||||
baseRepo = state.baseRepo,
|
|
||||||
mode = state.branchMode,
|
|
||||||
onModeChange = onBranchModeChange,
|
|
||||||
newBranch = state.newBranch,
|
|
||||||
onNewBranchChange = onNewBranchChange,
|
|
||||||
branches = state.branches,
|
|
||||||
branchesLoading = state.branchesLoading,
|
|
||||||
existingBranch = state.effectiveExistingBranch,
|
|
||||||
onExistingBranchChange = onExistingBranchChange,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = onNameChange,
|
|
||||||
label = { Text("Session name") },
|
|
||||||
placeholder = { Text("named after your first prompt") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
TextButton(onClick = onReroll) { Text("Reroll") }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportsStructuredView(state.command)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
|
||||||
Text("Structured view")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSubmit,
|
|
||||||
enabled = !state.submitting,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (state.submitting) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Text("Spawn")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
|
||||||
Snackbar(snackbarData = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A row is its own small touch target: tap anywhere in it, not just the
|
|
||||||
* leading control, to select it. */
|
|
||||||
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
|
||||||
this.clickable(enabled = enabled, onClick = onClick)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
|
||||||
) {
|
|
||||||
for (preset in presets) {
|
|
||||||
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
|
||||||
FilterChip(
|
|
||||||
selected = preset.id == selectedPresetId,
|
|
||||||
onClick = { onSelectPreset(preset.id) },
|
|
||||||
label = { Text(label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProjectPicker(
|
|
||||||
query: String,
|
|
||||||
onQueryChange: (String) -> Unit,
|
|
||||||
projects: List<Project>,
|
|
||||||
loading: Boolean,
|
|
||||||
selectedPath: String?,
|
|
||||||
homeDir: String,
|
|
||||||
onSelectProject: (Project) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Project", style = MaterialTheme.typography.labelLarge)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = onQueryChange,
|
|
||||||
placeholder = { Text("Filter projects…") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
when {
|
|
||||||
loading -> Text(
|
|
||||||
"Loading projects…",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
projects.isEmpty() -> Text(
|
|
||||||
"No projects match.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
|
||||||
items(projects, key = { it.path }) { project ->
|
|
||||||
ProjectRow(
|
|
||||||
project = project,
|
|
||||||
active = project.path == selectedPath,
|
|
||||||
homeDir = homeDir,
|
|
||||||
onClick = { onSelectProject(project) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
|
||||||
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.background(background)
|
|
||||||
.clickableRow(onClick = onClick)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
Text(
|
|
||||||
abbreviateHome(parentDir(project.path), homeDir),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abbreviateHome(path: String, homeDir: String): String =
|
|
||||||
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DirAutocompleteField(
|
|
||||||
cwd: String,
|
|
||||||
suggestions: List<String>,
|
|
||||||
onCwdChange: (String) -> Unit,
|
|
||||||
onPickSuggestion: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = cwd,
|
|
||||||
onValueChange = onCwdChange,
|
|
||||||
label = { Text("Or type a directory") },
|
|
||||||
placeholder = { Text("/home/user/project") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
if (suggestions.isNotEmpty()) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
|
||||||
items(suggestions) { entry ->
|
|
||||||
Text(
|
|
||||||
text = "$entry/",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickableRow { onPickSuggestion(entry) }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BranchSection(
|
|
||||||
enabled: Boolean,
|
|
||||||
onEnabledChange: (Boolean) -> Unit,
|
|
||||||
baseRepo: String?,
|
|
||||||
mode: WorktreeBranchMode,
|
|
||||||
onModeChange: (WorktreeBranchMode) -> Unit,
|
|
||||||
newBranch: String,
|
|
||||||
onNewBranchChange: (String) -> Unit,
|
|
||||||
branches: List<BranchInfo>,
|
|
||||||
branchesLoading: Boolean,
|
|
||||||
existingBranch: String,
|
|
||||||
onExistingBranchChange: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
|
||||||
Text("Create git worktree")
|
|
||||||
}
|
|
||||||
if (!enabled) return@Column
|
|
||||||
|
|
||||||
if (baseRepo == null) {
|
|
||||||
Text(
|
|
||||||
"Choose a directory inside a git repository first.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
|
||||||
Text("New branch")
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
|
||||||
Text("Existing branch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode == WorktreeBranchMode.New) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = newBranch,
|
|
||||||
onValueChange = onNewBranchChange,
|
|
||||||
label = { Text("New branch name") },
|
|
||||||
placeholder = { Text("feat/foo") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
when {
|
|
||||||
branchesLoading -> Text(
|
|
||||||
"Loading branches…",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
branches.isEmpty() -> Text(
|
|
||||||
"No local branches found.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
|
||||||
items(branches, key = { it.name }) { branch ->
|
|
||||||
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = branch.name == existingBranch,
|
|
||||||
onClick = { onExistingBranchChange(branch.name) },
|
|
||||||
enabled = held == null,
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
if (held != null) {
|
|
||||||
Text(
|
|
||||||
"held by $held",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.api.MaestroApiException
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
|
|
||||||
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
|
||||||
* `/api/gitroot` fire (#269). */
|
|
||||||
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
|
||||||
|
|
||||||
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
|
||||||
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
|
||||||
* `parentDir` in `web/src/paths.ts`. */
|
|
||||||
internal fun parentDir(path: String): String {
|
|
||||||
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
|
||||||
val slash = trimmed.lastIndexOf('/')
|
|
||||||
return when {
|
|
||||||
slash < 0 -> ""
|
|
||||||
slash == 0 -> "/"
|
|
||||||
else -> trimmed.substring(0, slash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits a path into its directory and trailing (possibly partial) segment.
|
|
||||||
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
|
||||||
internal fun splitPath(value: String): Pair<String, String> {
|
|
||||||
val slash = value.lastIndexOf('/')
|
|
||||||
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SpawnUiState(
|
|
||||||
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
|
||||||
val selectedPresetId: String = "",
|
|
||||||
val customCommand: String = "",
|
|
||||||
val commandTouched: Boolean = false,
|
|
||||||
val structuredView: Boolean = false,
|
|
||||||
val structuredViewTouched: Boolean = false,
|
|
||||||
val homeDir: String = "",
|
|
||||||
val projects: List<Project> = emptyList(),
|
|
||||||
val projectsLoading: Boolean = true,
|
|
||||||
val projectQuery: String = "",
|
|
||||||
val selectedProjectPath: String? = null,
|
|
||||||
val cwd: String = "",
|
|
||||||
val dirSuggestions: List<String> = emptyList(),
|
|
||||||
val baseRepo: String? = null,
|
|
||||||
val worktreeEnabled: Boolean = false,
|
|
||||||
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
|
||||||
val newBranch: String = "",
|
|
||||||
val existingBranch: String = "",
|
|
||||||
val branches: List<BranchInfo> = emptyList(),
|
|
||||||
val branchesLoading: Boolean = false,
|
|
||||||
val name: String = "",
|
|
||||||
val submitting: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
) {
|
|
||||||
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
|
||||||
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
|
||||||
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
|
||||||
|
|
||||||
val visibleProjects: List<Project>
|
|
||||||
get() {
|
|
||||||
val query = projectQuery.trim().lowercase()
|
|
||||||
val filtered = if (query.isEmpty()) {
|
|
||||||
projects
|
|
||||||
} else {
|
|
||||||
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
|
||||||
}
|
|
||||||
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val effectiveExistingBranch: String
|
|
||||||
get() = existingBranch.ifEmpty {
|
|
||||||
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
|
||||||
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
|
||||||
* for a short-lived form, and this keeps the class trivially testable with a
|
|
||||||
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
|
||||||
*/
|
|
||||||
class SpawnViewModel(
|
|
||||||
private val client: MaestroClient,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
) {
|
|
||||||
private val _state = MutableStateFlow(SpawnUiState())
|
|
||||||
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var dirJob: Job? = null
|
|
||||||
private var branchesJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
|
||||||
_state.update { s ->
|
|
||||||
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
|
||||||
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
|
||||||
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
|
||||||
} else {
|
|
||||||
s.selectedPresetId
|
|
||||||
}
|
|
||||||
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
|
||||||
.withSyncedStructuredView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
|
||||||
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectPreset(id: String) {
|
|
||||||
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCustomCommand(text: String) {
|
|
||||||
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setStructuredView(enabled: Boolean) {
|
|
||||||
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-derives the structured-view checkbox from the command this state
|
|
||||||
* carries, unless the operator has already ticked/unticked it by hand. */
|
|
||||||
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
|
||||||
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
|
||||||
|
|
||||||
fun setProjectQuery(query: String) {
|
|
||||||
_state.update { it.copy(projectQuery = query) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectProject(project: Project) {
|
|
||||||
dirJob?.cancel()
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
selectedProjectPath = project.path,
|
|
||||||
cwd = project.path,
|
|
||||||
dirSuggestions = emptyList(),
|
|
||||||
baseRepo = project.base_repo ?: project.path,
|
|
||||||
branches = emptyList(),
|
|
||||||
existingBranch = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The operator typed a path by hand: clears any project pick, and
|
|
||||||
* schedules a debounced refresh of both the directory-suggestion
|
|
||||||
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
|
||||||
fun setCwd(raw: String) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
cwd = raw,
|
|
||||||
selectedProjectPath = null,
|
|
||||||
baseRepo = null,
|
|
||||||
branches = emptyList(),
|
|
||||||
existingBranch = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dirJob?.cancel()
|
|
||||||
dirJob = scope.launch {
|
|
||||||
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
|
||||||
refreshDirSuggestions(raw)
|
|
||||||
refreshBaseRepo(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
|
||||||
* drill-in gesture the web `DirAutocomplete` uses. */
|
|
||||||
fun pickDirSuggestion(entry: String) {
|
|
||||||
val (dir, _) = splitPath(_state.value.cwd)
|
|
||||||
setCwd("$dir$entry/")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshDirSuggestions(raw: String) {
|
|
||||||
val (dir, prefix) = splitPath(raw)
|
|
||||||
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
|
||||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
|
||||||
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
|
||||||
_state.update { it.copy(dirSuggestions = filtered) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshBaseRepo(raw: String) {
|
|
||||||
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
|
||||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
|
||||||
_state.update { it.copy(baseRepo = root) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWorktreeEnabled(enabled: Boolean) {
|
|
||||||
_state.update { it.copy(worktreeEnabled = enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBranchMode(mode: WorktreeBranchMode) {
|
|
||||||
_state.update { it.copy(branchMode = mode) }
|
|
||||||
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNewBranch(text: String) {
|
|
||||||
_state.update { it.copy(newBranch = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setExistingBranch(text: String) {
|
|
||||||
_state.update { it.copy(existingBranch = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadBranchesIfNeeded() {
|
|
||||||
val cwd = _state.value.cwd
|
|
||||||
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
|
||||||
branchesJob?.cancel()
|
|
||||||
_state.update { it.copy(branchesLoading = true) }
|
|
||||||
branchesJob = scope.launch {
|
|
||||||
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
|
||||||
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
|
||||||
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setName(text: String) {
|
|
||||||
_state.update { it.copy(name = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/names?count=1`: replaces the session-name field with a
|
|
||||||
* freshly generated handle. */
|
|
||||||
fun rerollName() {
|
|
||||||
scope.launch {
|
|
||||||
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
|
||||||
if (handle != null) _state.update { it.copy(name = handle) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
|
||||||
* session. Any failure — validation or API — lands in
|
|
||||||
* [SpawnUiState.error] instead. */
|
|
||||||
fun submit(onSuccess: (Session) -> Unit) {
|
|
||||||
val s = _state.value
|
|
||||||
val values = SpawnFormValues(
|
|
||||||
command = s.command,
|
|
||||||
cwd = s.cwd,
|
|
||||||
name = s.name,
|
|
||||||
structuredView = s.structuredView,
|
|
||||||
autoApprove = s.activePreset?.auto_approve ?: false,
|
|
||||||
worktreeEnabled = s.worktreeEnabled,
|
|
||||||
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
|
||||||
worktreeBranchMode = s.branchMode,
|
|
||||||
)
|
|
||||||
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
|
||||||
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
|
||||||
is SpawnBuildResult.Ok -> {
|
|
||||||
_state.update { it.copy(submitting = true, error = null) }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val session = client.createSession(built.request)
|
|
||||||
_state.update { it.copy(submitting = false) }
|
|
||||||
onSuccess(session)
|
|
||||||
} catch (e: MaestroApiException) {
|
|
||||||
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissError() {
|
|
||||||
_state.update { it.copy(error = null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
|
||||||
val Pink40 = Color(0xFF7D5260)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = Purple80,
|
|
||||||
secondary = PurpleGrey80,
|
|
||||||
tertiary = Pink80,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
|
||||||
primary = Purple40,
|
|
||||||
secondary = PurpleGrey40,
|
|
||||||
tertiary = Pink40,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MaestroTheme(
|
|
||||||
darkTheme: Boolean = true,
|
|
||||||
dynamicColor: Boolean = false,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val useDarkTheme = darkTheme || isSystemInDarkTheme()
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
useDarkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = Typography,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#1B1B1B"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#D0BCFF"
|
|
||||||
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">Maestro</string>
|
|
||||||
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
|
||||||
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class BackoffTest {
|
|
||||||
@Test
|
|
||||||
fun `doubles from 500ms and caps at 10s`() {
|
|
||||||
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
|
||||||
val delays = List(8) { backoff.nextDelayMs() }
|
|
||||||
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reset returns to the initial delay`() {
|
|
||||||
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
|
||||||
repeat(4) { backoff.nextDelayMs() }
|
|
||||||
backoff.reset()
|
|
||||||
assertEquals(500L, backoff.nextDelayMs())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
|
|
||||||
class CatalogStoreTest {
|
|
||||||
private fun session(id: String) = Session(
|
|
||||||
id = id,
|
|
||||||
cwd = "/tmp",
|
|
||||||
command = "bash",
|
|
||||||
status = SessionStatus.Running,
|
|
||||||
created_at = "2026-05-24T12:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `snapshot replaces the map keyed by id`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
|
||||||
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `added and updated upsert one entry`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(emptyList()))
|
|
||||||
store.apply(CatalogEvent.SessionAdded(session("a")))
|
|
||||||
assertEquals(1, store.sessions.value.size)
|
|
||||||
|
|
||||||
val renamed = session("a").copy(command = "zsh")
|
|
||||||
store.apply(CatalogEvent.SessionUpdated(renamed))
|
|
||||||
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `removed drops the entry`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
|
||||||
store.apply(CatalogEvent.SessionRemoved("a"))
|
|
||||||
assertEquals(setOf("b"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `lagged reports the skip count without touching the map`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
|
||||||
val skipped = store.apply(CatalogEvent.Lagged(3))
|
|
||||||
assertEquals(3L, skipped)
|
|
||||||
assertEquals(setOf("a"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-lagged events report no skip`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
class DaemonIdentityTest {
|
|
||||||
private fun info(commit: String = "", instance: String = "") =
|
|
||||||
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `same commit and instance is Same`() {
|
|
||||||
val a = info(commit = "abc123", instance = "i1")
|
|
||||||
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `different commit is Upgraded`() {
|
|
||||||
val before = info(commit = "abc123", instance = "i1")
|
|
||||||
val after = info(commit = "def456", instance = "i2")
|
|
||||||
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `same commit but different instance is Restarted`() {
|
|
||||||
val before = info(commit = "abc123", instance = "i1")
|
|
||||||
val after = info(commit = "abc123", instance = "i2")
|
|
||||||
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty commits on both sides never read as an upgrade`() {
|
|
||||||
val before = info(commit = "", instance = "i1")
|
|
||||||
val after = info(commit = "", instance = "i2")
|
|
||||||
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `format release names the commit and short date`() {
|
|
||||||
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
|
||||||
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `dirty build is flagged`() {
|
|
||||||
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
|
||||||
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty commit is unknown release`() {
|
|
||||||
assertEquals("unknown release", formatRelease(BuildInfo()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Wire-format checks against fixtures mirrored from
|
|
||||||
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
|
|
||||||
* that drifts from the Rust source of truth fails here first. */
|
|
||||||
class WireSerializationTest {
|
|
||||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
|
||||||
|
|
||||||
private fun legacySessionJson(extra: String = "") = """
|
|
||||||
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
|
|
||||||
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `legacy session payload defaults new fields`() {
|
|
||||||
val s = json.decodeFromString<Session>(legacySessionJson())
|
|
||||||
assertEquals(ActivityState.Idle, s.activity)
|
|
||||||
assertEquals(SessionView.Terminal, s.view)
|
|
||||||
assertNull(s.foreground)
|
|
||||||
assertNull(s.blocked_work)
|
|
||||||
assertEquals(EPOCH, s.last_activity_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown activity state degrades to Idle`() {
|
|
||||||
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
|
|
||||||
assertEquals(ActivityState.Idle, s.activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown name source degrades to User`() {
|
|
||||||
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
|
|
||||||
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `catalog event tags by type`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"type":"Lagged","skipped":3}""",
|
|
||||||
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
|
|
||||||
)
|
|
||||||
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
|
|
||||||
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `client control tags by type`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"type":"Resize","cols":120,"rows":40}""",
|
|
||||||
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
|
|
||||||
)
|
|
||||||
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `structured approval decision is a bare string`() {
|
|
||||||
assertEquals(
|
|
||||||
StructuredApprovalDecision.AllowOnce,
|
|
||||||
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"\"allow_for_session\"",
|
|
||||||
json.encodeToString(StructuredApprovalDecision.AllowForSession),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `structured event tags snake_case`() {
|
|
||||||
val event = json.decodeFromString<StructuredEvent>(
|
|
||||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
|
|
||||||
)
|
|
||||||
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
|
|
||||||
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `transcript delta tags snake_case and round trips`() {
|
|
||||||
val row = TranscriptRow(
|
|
||||||
id = "tool:read-1",
|
|
||||||
kind = TranscriptRowKind.ToolCall,
|
|
||||||
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
|
|
||||||
)
|
|
||||||
val patch = TranscriptDelta.Patch(row)
|
|
||||||
val encoded = json.encodeToString<TranscriptDelta>(patch)
|
|
||||||
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
|
|
||||||
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
|
|
||||||
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rename request encodes plainly`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"name":"amber-falcon"}""",
|
|
||||||
json.encodeToString(RenameSessionRequest("amber-falcon")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `api error decodes nested unsaved work with defaults`() {
|
|
||||||
val apiError = json.decodeFromString<ApiError>(
|
|
||||||
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
|
|
||||||
)
|
|
||||||
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
|
|
||||||
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
import pt.naps.maestro.data.SessionWorktree
|
|
||||||
|
|
||||||
private fun session(
|
|
||||||
command: String = "claude",
|
|
||||||
cwd: String = "/home/user/project",
|
|
||||||
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
|
||||||
worktree: SessionWorktree? = null,
|
|
||||||
blockedWork: InTheWay? = null,
|
|
||||||
activitySince: String = "1970-01-01T00:00:00Z",
|
|
||||||
) = Session(
|
|
||||||
id = "s1",
|
|
||||||
cwd = cwd,
|
|
||||||
command = command,
|
|
||||||
metadata = metadata,
|
|
||||||
worktree = worktree,
|
|
||||||
status = SessionStatus.Running,
|
|
||||||
created_at = "2026-01-01T00:00:00Z",
|
|
||||||
blocked_work = blockedWork,
|
|
||||||
activity_since = activitySince,
|
|
||||||
)
|
|
||||||
|
|
||||||
class DurationFormattingTest {
|
|
||||||
@Test
|
|
||||||
fun `floors seconds under a minute`() {
|
|
||||||
assertEquals("30s", activityDurationLabel(30_000))
|
|
||||||
assertEquals("59s", activityDurationLabel(59_999))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `floors minutes under an hour`() {
|
|
||||||
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `floors hours with no days unit`() {
|
|
||||||
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `held ms is null for missing or implausible since`() {
|
|
||||||
assertNull(activityHeldMs(null, 1_000))
|
|
||||||
assertNull(activityHeldMs("", 1_000))
|
|
||||||
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
|
||||||
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `held ms is the gap to now for a plausible timestamp`() {
|
|
||||||
val since = "2026-01-01T00:00:00Z"
|
|
||||||
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
|
||||||
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionNameTest {
|
|
||||||
@Test
|
|
||||||
fun `falls back to the command when no name is set`() {
|
|
||||||
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `uses the custom name when set`() {
|
|
||||||
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
|
||||||
assertEquals("api-work", sessionName(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty stored name is treated as unset`() {
|
|
||||||
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
|
||||||
assertNull(customSessionName(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `generated name is provisional, user and haiku are not`() {
|
|
||||||
val generated = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("brave-otter"),
|
|
||||||
"name_source" to JsonPrimitive("Generated"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val user = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("api-work"),
|
|
||||||
"name_source" to JsonPrimitive("User"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val unknownSource = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("api-work"),
|
|
||||||
"name_source" to JsonPrimitive("something-new"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(isProvisionalName(generated))
|
|
||||||
assertFalse(isProvisionalName(user))
|
|
||||||
assertFalse(isProvisionalName(unknownSource))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockerStatsTest {
|
|
||||||
@Test
|
|
||||||
fun `renders insertions and deletions in diff notation`() {
|
|
||||||
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
|
||||||
val stats = blockerStats(w).associate { it.kind to it.text }
|
|
||||||
assertEquals("+124", stats["add"])
|
|
||||||
assertEquals("-18", stats["del"])
|
|
||||||
assertEquals("?3", stats["untracked"])
|
|
||||||
assertEquals("↑2", stats["unpushed"])
|
|
||||||
assertNull(stats["files"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to a file count when a tracked change carries no line delta`() {
|
|
||||||
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
|
||||||
val stats = blockerStats(w)
|
|
||||||
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `session blockers are null when nothing blocks a kill`() {
|
|
||||||
assertNull(sessionBlockers(session(blockedWork = null)))
|
|
||||||
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `format in the way spells out zero as no unsaved work`() {
|
|
||||||
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
|
||||||
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgramNameTest {
|
|
||||||
@Test
|
|
||||||
fun `drops arguments and basenames the path`() {
|
|
||||||
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
|
||||||
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through leading env assignments`() {
|
|
||||||
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through sudo and env wrappers with their own flags`() {
|
|
||||||
assertEquals("claude", programName("sudo claude"))
|
|
||||||
assertEquals("claude", programName("env -i FOO=bar claude"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through js-toolchain launchers`() {
|
|
||||||
assertEquals("claude-code", programName("npx claude-code"))
|
|
||||||
assertEquals("claude", programName("pnpm dlx claude"))
|
|
||||||
assertEquals("claude", programName("bunx claude"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a command that is nothing but wrappers is shown as written`() {
|
|
||||||
assertEquals("sudo", programName("sudo"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class GroupingTest {
|
|
||||||
@Test
|
|
||||||
fun `groups by worktree base_repo when present`() {
|
|
||||||
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
|
||||||
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
|
||||||
val groups = groupSessions(listOf(a, b))
|
|
||||||
assertEquals(1, groups.size)
|
|
||||||
assertEquals("maestro", groups.single().label)
|
|
||||||
assertEquals(2, groups.single().sessions.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to cwd with no worktree`() {
|
|
||||||
val a = session(cwd = "/home/user/scratch")
|
|
||||||
val groups = groupSessions(listOf(a))
|
|
||||||
assertEquals("scratch", groups.single().label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
|
|
||||||
private val base = SpawnFormValues(
|
|
||||||
command = "claude",
|
|
||||||
cwd = "/home/user/project",
|
|
||||||
name = "amber-fox",
|
|
||||||
structuredView = false,
|
|
||||||
autoApprove = false,
|
|
||||||
worktreeEnabled = false,
|
|
||||||
worktreeBranch = "",
|
|
||||||
worktreeBranchMode = WorktreeBranchMode.New,
|
|
||||||
)
|
|
||||||
|
|
||||||
class SpawnModelTest {
|
|
||||||
@Test
|
|
||||||
fun `builds a plain request with the picked cwd and command`() {
|
|
||||||
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("/home/user/project", result.request.cwd)
|
|
||||||
assertEquals("claude", result.request.command)
|
|
||||||
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
|
||||||
assertEquals(SessionView.Terminal, result.request.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `omits metadata name when the name is blank`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
|
||||||
assertTrue(result.request.metadata.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `requests structured view for resolved claude and codex commands`() {
|
|
||||||
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
|
||||||
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `does not request structured view for unsupported commands`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals(SessionView.Terminal, result.request.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `attaches a trimmed branch when worktree is enabled`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
|
||||||
"/home/user/project",
|
|
||||||
) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
|
||||||
assertEquals("feat/foo", result.request.worktree?.branch)
|
|
||||||
assertTrue(result.request.worktree?.create_branch == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `checks out an existing branch when the mode is existing`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
|
||||||
"/home/user/project",
|
|
||||||
) as SpawnBuildResult.Ok
|
|
||||||
assertEquals(false, result.request.worktree?.create_branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects an existing-mode worktree with no branch picked`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
|
||||||
"/repo",
|
|
||||||
)
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a missing working directory`() {
|
|
||||||
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a missing command`() {
|
|
||||||
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a worktree without an inferred base repo`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a worktree without a branch name`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `resolveProgram strips env assignments and paths`() {
|
|
||||||
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
|
||||||
assertEquals("claude", resolveProgram("claude"))
|
|
||||||
assertEquals("zsh", resolveProgram("zsh -l"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
|
||||||
import okhttp3.mockwebserver.MockResponse
|
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
|
|
||||||
/** Routes each enqueued response by request path rather than arrival order —
|
|
||||||
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
|
||||||
* so a plain FIFO queue can hand either endpoint the other's response. */
|
|
||||||
private class RoutedDispatcher : Dispatcher() {
|
|
||||||
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
|
||||||
|
|
||||||
fun enqueue(path: String, response: MockResponse) {
|
|
||||||
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
|
||||||
val path = request.path.orEmpty().substringBefore('?')
|
|
||||||
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpawnViewModelTest {
|
|
||||||
private lateinit var server: MockWebServer
|
|
||||||
private lateinit var dispatcher: RoutedDispatcher
|
|
||||||
private lateinit var client: MaestroClient
|
|
||||||
private lateinit var scope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
dispatcher = RoutedDispatcher()
|
|
||||||
server = MockWebServer()
|
|
||||||
server.dispatcher = dispatcher
|
|
||||||
server.start()
|
|
||||||
client = MaestroClient(server.url("/").toString())
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
scope.cancel()
|
|
||||||
server.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jsonResponse(body: String) =
|
|
||||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
|
||||||
|
|
||||||
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
|
||||||
* every test needs before it can observe anything past init. */
|
|
||||||
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
|
||||||
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
|
||||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newViewModel() = SpawnViewModel(client, scope)
|
|
||||||
|
|
||||||
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
|
||||||
var result: SpawnUiState? = null
|
|
||||||
state.test {
|
|
||||||
var item = awaitItem()
|
|
||||||
while (!predicate(item)) item = awaitItem()
|
|
||||||
result = item
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
return result ?: error("state predicate never satisfied")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
|
||||||
enqueueInit(
|
|
||||||
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
|
||||||
{"id":"claude","label":"Claude","command":"claude"},
|
|
||||||
{"id":"custom","label":"Custom…","command":""}
|
|
||||||
],"instance":"i"}""",
|
|
||||||
)
|
|
||||||
|
|
||||||
val vm = newViewModel()
|
|
||||||
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
assertEquals("claude", settled.command)
|
|
||||||
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
|
||||||
assertEquals("/home/x", settled.homeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
|
||||||
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
|
||||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
|
||||||
|
|
||||||
val vm = newViewModel()
|
|
||||||
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
|
||||||
|
|
||||||
assertEquals(FALLBACK_PRESETS, settled.presets)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
|
||||||
|
|
||||||
vm.selectPreset("zsh")
|
|
||||||
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
|
||||||
assertEquals("zsh", afterZsh.command)
|
|
||||||
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
|
||||||
|
|
||||||
vm.selectPreset("claude")
|
|
||||||
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
assertTrue(afterClaude.structuredView)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
vm.setStructuredView(false)
|
|
||||||
vm.selectPreset("codex")
|
|
||||||
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
|
||||||
assertTrue("operator's untick should stick", !after.structuredView)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
val after = vm.awaitState { it.cwd == "/repo" }
|
|
||||||
assertEquals("/repo", after.baseRepo)
|
|
||||||
assertEquals("/repo", after.selectedProjectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
|
||||||
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
|
||||||
|
|
||||||
vm.setCwd("/home/user/pro")
|
|
||||||
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
|
||||||
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
|
||||||
val withRepo = vm.awaitState { it.baseRepo != null }
|
|
||||||
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reroll fills the name field from the daemon`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
|
||||||
vm.rerollName()
|
|
||||||
val after = vm.awaitState { it.name == "amber-fox" }
|
|
||||||
assertEquals("amber-fox", after.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
var succeeded = false
|
|
||||||
vm.submit { succeeded = true }
|
|
||||||
val after = vm.awaitState { it.error != null }
|
|
||||||
assertNotNull(after.error)
|
|
||||||
assertTrue(!succeeded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit posts the built request and reports the new session`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
vm.awaitState { it.cwd == "/repo" }
|
|
||||||
|
|
||||||
dispatcher.enqueue(
|
|
||||||
"/api/sessions",
|
|
||||||
jsonResponse(
|
|
||||||
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
var result: pt.naps.maestro.data.Session? = null
|
|
||||||
vm.submit { result = it }
|
|
||||||
val after = vm.awaitState { !it.submitting && it.error == null }
|
|
||||||
assertNull(after.error)
|
|
||||||
assertEquals("s1", result?.id)
|
|
||||||
|
|
||||||
var request = server.takeRequest()
|
|
||||||
while (request.method != "POST") request = server.takeRequest()
|
|
||||||
assertEquals("/api/sessions", request.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
vm.awaitState { it.cwd == "/repo" }
|
|
||||||
|
|
||||||
dispatcher.enqueue(
|
|
||||||
"/api/sessions",
|
|
||||||
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
|
||||||
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
|
||||||
)
|
|
||||||
vm.submit {}
|
|
||||||
val after = vm.awaitState { it.error != null }
|
|
||||||
assertTrue(after.error!!.contains("cwd does not exist"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.application) apply false
|
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.parallel=true
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
[versions]
|
|
||||||
agp = "8.5.2"
|
|
||||||
kotlin = "2.0.21"
|
|
||||||
coreKtx = "1.13.1"
|
|
||||||
lifecycleRuntimeKtx = "2.8.6"
|
|
||||||
activityCompose = "1.9.2"
|
|
||||||
composeBom = "2024.09.03"
|
|
||||||
navigationCompose = "2.8.2"
|
|
||||||
okhttp = "4.12.0"
|
|
||||||
kotlinxSerializationJson = "1.7.3"
|
|
||||||
kotlinxCoroutines = "1.9.0"
|
|
||||||
turbine = "1.1.0"
|
|
||||||
|
|
||||||
[libraries]
|
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
|
||||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
|
||||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
|
||||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
|
||||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
|
||||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
|
||||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
|
||||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
|
||||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
|
||||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
|
||||||
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
|
||||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
|
||||||
|
|
||||||
[plugins]
|
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
|
||||||
BIN
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
Vendored
-237
@@ -1,237 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
#
|
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
|
||||||
#
|
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# issues, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated via the Gradle "wrapper" task
|
|
||||||
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
app_path=$0
|
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
|
||||||
while
|
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
|
||||||
[ -h "$app_path" ]
|
|
||||||
do
|
|
||||||
ls=$( ls -ld "$app_path" )
|
|
||||||
link=${ls#*' -> '}
|
|
||||||
case $link in #(
|
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# This is normally unused
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD=maximum
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "$( uname )" in #(
|
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
|
||||||
Darwin* ) darwin=true ;; #(
|
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
|
||||||
NONSTOP* ) nonstop=true ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
|
||||||
else
|
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD=java
|
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|
||||||
case $MAX_FD in #(
|
|
||||||
max*)
|
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
|
||||||
# Roll the args list around exactly as many times as the number of
|
|
||||||
# args, so each arg winds up back in the position where it started, but
|
|
||||||
# possibly modified.
|
|
||||||
#
|
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
|
||||||
# changing the positional parameters here affects neither the number of
|
|
||||||
# iterations, nor the values presented in `arg`.
|
|
||||||
shift # remove old arg
|
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command;
|
|
||||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
|
|
||||||
# shell script including quotes and variable substitutions, so put them in
|
|
||||||
# double quotes to make sure that they get re-expanded; and
|
|
||||||
# * put everything else in single quotes, so that it's not re-expanded.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
|
||||||
xargs -n1 |
|
|
||||||
sed ' s~[^-A-Za-z0-9_+.]~\\&~g; ' |
|
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
Vendored
-89
@@ -1,89 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "Maestro"
|
|
||||||
|
|
||||||
include(":app")
|
|
||||||
@@ -58,24 +58,6 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
|||||||
|
|
||||||
pub use agent_client_protocol::schema::v1 as schema;
|
pub use agent_client_protocol::schema::v1 as schema;
|
||||||
|
|
||||||
/// Item on the updates channel. Notifications and turn-done markers share
|
|
||||||
/// the same FIFO so the event pump processes every notification for a turn
|
|
||||||
/// before seeing its done signal.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AcpUpdate {
|
|
||||||
/// A raw `session/update` notification from the adapter.
|
|
||||||
Notification(Box<SessionNotification>),
|
|
||||||
/// The prompt response for `turn` arrived. All notifications for this
|
|
||||||
/// turn precede this marker on the wire, and therefore in this channel.
|
|
||||||
/// The event pump must fire `done` after persisting every preceding
|
|
||||||
/// notification.
|
|
||||||
TurnDone {
|
|
||||||
turn: u64,
|
|
||||||
result: Result<(), String>,
|
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ACP adapter selected from the structured session's resolved command.
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AcpAdapter {
|
pub enum AcpAdapter {
|
||||||
@@ -372,7 +354,7 @@ pub struct AcpSession {
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
commands: mpsc::UnboundedSender<Command>,
|
commands: mpsc::UnboundedSender<Command>,
|
||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
||||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
@@ -411,9 +393,9 @@ impl AcpSession {
|
|||||||
wait_exit(&self.exit).await
|
wait_exit(&self.exit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream of updates (notifications + turn-done markers), handed out
|
/// The stream of raw `session/update` notifications, handed out once.
|
||||||
/// once. Items that arrived before the take are still queued.
|
/// Notifications that arrived before the take are still queued on it.
|
||||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
|
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
|
||||||
self.updates.lock().expect("updates mutex").take()
|
self.updates.lock().expect("updates mutex").take()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +772,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
|
|||||||
async fn run_connection<T>(
|
async fn run_connection<T>(
|
||||||
transport: T,
|
transport: T,
|
||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
@@ -818,7 +800,7 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// A dropped receiver only means nobody wants updates any more.
|
// A dropped receiver only means nobody wants updates any more.
|
||||||
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
|
let _ = received_updates.send(notification);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_notification!(),
|
agent_client_protocol::on_receive_notification!(),
|
||||||
@@ -934,18 +916,18 @@ async fn run_connection<T>(
|
|||||||
models.as_ref(),
|
models.as_ref(),
|
||||||
)));
|
)));
|
||||||
if let Some(modes) = &modes {
|
if let Some(modes) = &modes {
|
||||||
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
let _ = updates_tx.send(SessionNotification::new(
|
||||||
SessionId::new(session_id.as_str()),
|
SessionId::new(session_id.as_str()),
|
||||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
modes.current_mode_id.clone(),
|
modes.current_mode_id.clone(),
|
||||||
)),
|
)),
|
||||||
))));
|
));
|
||||||
}
|
}
|
||||||
if !config_options.lock().expect("config options").is_empty() {
|
if !config_options.lock().expect("config options").is_empty() {
|
||||||
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
|
let _ = updates_tx.send(config_notification(
|
||||||
&session_id,
|
&session_id,
|
||||||
config_options.lock().expect("config options").clone(),
|
config_options.lock().expect("config options").clone(),
|
||||||
))));
|
));
|
||||||
}
|
}
|
||||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||||
|
|
||||||
@@ -973,23 +955,16 @@ async fn run_connection<T>(
|
|||||||
let done = Arc::new(Mutex::new(Some(done)));
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
let callback_done = done.clone();
|
let callback_done = done.clone();
|
||||||
let callback_turn = permission_turn.clone();
|
let callback_turn = permission_turn.clone();
|
||||||
let callback_updates = updates_tx.clone();
|
|
||||||
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||||
let callback_done = callback_done.clone();
|
let callback_done = callback_done.clone();
|
||||||
let callback_turn = callback_turn.clone();
|
let callback_turn = callback_turn.clone();
|
||||||
let callback_updates = callback_updates.clone();
|
|
||||||
async move {
|
async move {
|
||||||
callback_turn
|
callback_turn
|
||||||
.lock()
|
.lock()
|
||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
// Route through the same channel as notifications so the
|
|
||||||
// event pump sees this after every chunk for the turn.
|
|
||||||
let result = result.map(|_| ()).map_err(|e| e.to_string());
|
|
||||||
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||||
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
|
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
|
||||||
// Channel closed — nowhere left to signal.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1023,7 +998,7 @@ async fn run_connection<T>(
|
|||||||
}).on_receiving_result(move |result| async move {
|
}).on_receiving_result(move |result| async move {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
@@ -1042,13 +1017,13 @@ async fn run_connection<T>(
|
|||||||
)).on_receiving_result(move |result| async move {
|
)).on_receiving_result(move |result| async move {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
let _ = callback_updates.send(SessionNotification::new(
|
||||||
SessionId::new(callback_session.as_str()),
|
SessionId::new(callback_session.as_str()),
|
||||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||||
)),
|
)),
|
||||||
))));
|
));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
@@ -1066,7 +1041,7 @@ async fn run_connection<T>(
|
|||||||
let mut current = callback_options.lock().expect("config options");
|
let mut current = callback_options.lock().expect("config options");
|
||||||
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||||
current.extend(response.config_options.clone());
|
current.extend(response.config_options.clone());
|
||||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
|
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
|||||||
@@ -193,9 +193,6 @@ async fn session_updates_reach_the_taken_receiver() {
|
|||||||
.await
|
.await
|
||||||
.expect("update within 5s")
|
.expect("update within 5s")
|
||||||
.expect("channel open");
|
.expect("channel open");
|
||||||
let maestro_acp::AcpUpdate::Notification(first) = first else {
|
|
||||||
panic!("expected Notification");
|
|
||||||
};
|
|
||||||
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
||||||
match first.update {
|
match first.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||||
@@ -224,10 +221,7 @@ async fn advertised_settings_are_normalized_and_mutable() {
|
|||||||
.await
|
.await
|
||||||
.expect("settings update")
|
.expect("settings update")
|
||||||
.expect("channel open");
|
.expect("channel open");
|
||||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
|
|
||||||
break options.config_options;
|
break options.config_options;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -396,10 +390,7 @@ async fn drop_terminates_and_reaps_the_child() {
|
|||||||
fake.wait_for_exit().await;
|
fake.wait_for_exit().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
|
||||||
panic!("expected Notification, got {update:?}");
|
|
||||||
};
|
|
||||||
match &n.update {
|
match &n.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
ContentBlock::Text(t) => t.text.clone(),
|
ContentBlock::Text(t) => t.text.clone(),
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Send text to a running session. Terminal sessions receive raw
|
/// Type text into a running session, as if it had been typed on an
|
||||||
/// keystrokes; structured sessions receive an ACP prompt.
|
/// attached terminal.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
@@ -92,14 +92,6 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
|
||||||
/// structured (ACP) view it otherwise gets by default.
|
|
||||||
#[arg(long, conflicts_with = "structured")]
|
|
||||||
pub terminal: bool,
|
|
||||||
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
|
||||||
/// Codex commands have an adapter; the daemon refuses anything else.
|
|
||||||
#[arg(long)]
|
|
||||||
pub structured: bool,
|
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
@@ -133,7 +125,7 @@ pub struct KillArgs {
|
|||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
/// End the session even when its worktree holds unsaved work (#81). The
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is removed along with the session.
|
/// worktree is left on disk — never removed.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
@@ -51,7 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
view: spawn_view(&args),
|
view: Default::default(),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
@@ -62,22 +62,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
|
||||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
|
||||||
/// and the one both other clients now default to. `--terminal` opts out;
|
|
||||||
/// `--structured` forces the request through even for a command this side
|
|
||||||
/// does not recognise, leaving the refusal to the daemon.
|
|
||||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
|
||||||
if args.terminal {
|
|
||||||
return SessionView::Terminal;
|
|
||||||
}
|
|
||||||
if args.structured || supports_structured_view(&args.command) {
|
|
||||||
SessionView::Structured
|
|
||||||
} else {
|
|
||||||
SessionView::Terminal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -130,12 +114,9 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||||
/// there — ACP prompts are always complete messages).
|
/// the shape a heredoc or a pipe wants.
|
||||||
///
|
|
||||||
/// Text comes from the arguments, or from stdin when there is none — the
|
|
||||||
/// shape a heredoc or a pipe wants.
|
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -158,28 +139,19 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We always need the session list: even a bare UUID needs the view field
|
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||||
// to decide which endpoint to hit.
|
Ok(id) => id,
|
||||||
let sessions = client.list_sessions().await?;
|
Err(_) => {
|
||||||
let id = resolve_session(&sessions, &args.session)?;
|
let sessions = client.list_sessions().await?;
|
||||||
let view = sessions
|
resolve_session(&sessions, &args.session)?
|
||||||
.iter()
|
}
|
||||||
.find(|s| s.id == id)
|
};
|
||||||
.map(|s| s.view)
|
|
||||||
.unwrap_or(SessionView::Terminal);
|
|
||||||
|
|
||||||
match view {
|
let req = SendInputRequest {
|
||||||
SessionView::Structured => {
|
text,
|
||||||
client.send_structured_prompt(id, &text).await?;
|
submit: !args.no_enter,
|
||||||
}
|
};
|
||||||
SessionView::Terminal => {
|
client.send_input(id, &req).await?;
|
||||||
let req = SendInputRequest {
|
|
||||||
text,
|
|
||||||
submit: !args.no_enter,
|
|
||||||
};
|
|
||||||
client.send_input(id, &req).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +200,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
ActivityState::Error => "error",
|
ActivityState::Error => "error",
|
||||||
ActivityState::Starting => "starting",
|
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -269,62 +240,6 @@ mod tests {
|
|||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use crate::cli::{Cli, Command};
|
|
||||||
|
|
||||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
|
||||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
|
||||||
argv.extend_from_slice(extra);
|
|
||||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
|
||||||
let Command::Spawn(args) = cli.command else {
|
|
||||||
panic!("expected spawn")
|
|
||||||
};
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CLI follows the two interactive clients: a command with an ACP
|
|
||||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
|
||||||
/// `--structured` still forces the request for a command this side does
|
|
||||||
/// not recognise.
|
|
||||||
#[test]
|
|
||||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
|
||||||
use super::spawn_view;
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["claude"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
|
||||||
SessionView::Terminal
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_refuses_both_view_flags_at_once() {
|
|
||||||
let err = Cli::try_parse_from([
|
|
||||||
"maestro",
|
|
||||||
"spawn",
|
|
||||||
"--cwd",
|
|
||||||
"/tmp",
|
|
||||||
"--command",
|
|
||||||
"claude",
|
|
||||||
"--structured",
|
|
||||||
"--terminal",
|
|
||||||
])
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
view: Default::default(),
|
||||||
@@ -345,8 +260,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -202,35 +202,6 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
assert_eq!(sent, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A structured session routes through the ACP prompt endpoint, not the
|
|
||||||
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
|
||||||
/// complete messages).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn send_to_structured_session_uses_acp_prompt() {
|
|
||||||
let mock = MockDaemon::start().await;
|
|
||||||
let id = Uuid::from_u128(13);
|
|
||||||
let mut s = session(id, "/tmp", "claude");
|
|
||||||
s.view = SessionView::Structured;
|
|
||||||
mock.add_session(s);
|
|
||||||
|
|
||||||
let args = maestro_cli::cli::SendArgs {
|
|
||||||
session: id.to_string(),
|
|
||||||
text: vec!["fix".into(), "the".into(), "tests".into()],
|
|
||||||
no_enter: false,
|
|
||||||
};
|
|
||||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
|
||||||
.await
|
|
||||||
.expect("send");
|
|
||||||
|
|
||||||
assert_eq!(sent, id);
|
|
||||||
// Terminal input must stay empty — the message went through ACP.
|
|
||||||
assert!(mock.inputs().is_empty());
|
|
||||||
let prompts = mock.structured_prompts();
|
|
||||||
assert_eq!(prompts.len(), 1);
|
|
||||||
assert_eq!(prompts[0].0, id);
|
|
||||||
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -80,22 +80,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
fn non_repo_dir_is_none() {
|
||||||
// The fixture has no `.git`, so `find_root` must not return a path
|
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||||
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
|
||||||
// returns that ancestor — the test only asserts the fixture itself
|
|
||||||
// was not mistaken for a repo.
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sub = tmp.path().join("plain/dir");
|
let sub = tmp.path().join("plain/dir");
|
||||||
fs::create_dir_all(&sub).unwrap();
|
fs::create_dir_all(&sub).unwrap();
|
||||||
match find_root(&sub) {
|
assert_eq!(find_root(&sub), None);
|
||||||
None => {} // expected on most hosts
|
|
||||||
Some(root) => assert!(
|
|
||||||
!root.starts_with(tmp.path()),
|
|
||||||
"found .git inside the fixture at {root:?}, but none was created"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::body::Bytes;
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
use axum::routing::{any, delete, get, post};
|
||||||
@@ -71,10 +70,6 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
.route(
|
|
||||||
routes::patterns::STRUCTURED_ATTACHMENT,
|
|
||||||
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
|
||||||
)
|
|
||||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
.route(
|
.route(
|
||||||
@@ -180,7 +175,7 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
fn session_error_status(code: &str) -> StatusCode {
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
@@ -437,58 +432,17 @@ async fn structured_replay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AttachmentQuery {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn structured_attachment(
|
|
||||||
State(s): State<LocalApiState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Query(query): Query<AttachmentQuery>,
|
|
||||||
bytes: Bytes,
|
|
||||||
) -> Response {
|
|
||||||
let name = query.name;
|
|
||||||
if name.is_empty()
|
|
||||||
|| name.len() > 180
|
|
||||||
|| name == "."
|
|
||||||
|| name == ".."
|
|
||||||
|| name
|
|
||||||
.chars()
|
|
||||||
.any(|c| c.is_control() || c == '/' || c == '\\')
|
|
||||||
{
|
|
||||||
return error_response(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"invalid_filename",
|
|
||||||
"use a filename without directory separators, up to 180 bytes",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
|
||||||
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
|
||||||
Ok(None) => error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"not_structured",
|
|
||||||
"session is not a live structured session",
|
|
||||||
),
|
|
||||||
Err(e) => {
|
|
||||||
let info = e.to_error_info();
|
|
||||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn structured_prompt(
|
async fn structured_prompt(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<StructuredPromptRequest>,
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
||||||
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
Ok(QueueAcpPrompt::InFlight) => error_response(
|
||||||
Ok(QueueAcpPrompt::Full) => error_response(
|
StatusCode::CONFLICT,
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
"turn_in_flight",
|
||||||
"queue_full",
|
"the structured session already has an active turn",
|
||||||
"the prompt queue has reached its capacity",
|
|
||||||
),
|
),
|
||||||
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+118
-184
@@ -84,27 +84,15 @@ impl Fake {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||||
match &event.event {
|
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||||
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
|
panic!("unexpected daemon event")
|
||||||
match ¬ification.update {
|
};
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
match ¬ification.update {
|
||||||
ContentBlock::Text(text) => &text.text,
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
other => panic!("unexpected content {other:?}"),
|
ContentBlock::Text(text) => &text.text,
|
||||||
},
|
other => panic!("unexpected content {other:?}"),
|
||||||
other => panic!("unexpected update {other:?}"),
|
},
|
||||||
}
|
other => panic!("unexpected update {other:?}"),
|
||||||
}
|
|
||||||
maestro_daemon::sidecar::PersistedEvent::User { row } => row
|
|
||||||
.data
|
|
||||||
.pointer("/content/text")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.expect("user row text"),
|
|
||||||
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
|
|
||||||
.data
|
|
||||||
.pointer("/maestroTurnMeta/outcome")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.unwrap_or("[turn]"),
|
|
||||||
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,25 +130,6 @@ async fn wait_for_exit(pid: u32) {
|
|||||||
assert!(!pid_alive(pid), "pid {pid} still alive");
|
assert!(!pid_alive(pid), "pid {pid} still alive");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for a session's background ACP handshake to complete (activity
|
|
||||||
/// transitions out of `Starting`). Returns the refreshed session.
|
|
||||||
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
|
||||||
loop {
|
|
||||||
let session = manager
|
|
||||||
.snapshot()
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.find(|s| s.id == id)
|
|
||||||
.expect("session exists");
|
|
||||||
if session.activity != ActivityState::Starting {
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
assert!(Instant::now() < deadline, "handshake timed out");
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -169,7 +138,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||||
|
|
||||||
let created = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(
|
.create(structured_command(
|
||||||
cwd.path(),
|
cwd.path(),
|
||||||
@@ -179,12 +148,8 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|
||||||
assert_eq!(created.view, SessionView::Structured);
|
assert_eq!(session.view, SessionView::Structured);
|
||||||
assert_eq!(created.status, SessionStatus::Running);
|
assert_eq!(session.status, SessionStatus::Running);
|
||||||
assert_eq!(created.activity, ActivityState::Starting);
|
|
||||||
assert!(created.acp_session_id.is_none());
|
|
||||||
|
|
||||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
|
||||||
assert_eq!(session.activity, ActivityState::Idle);
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
||||||
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
||||||
@@ -199,7 +164,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
|
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
|
||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,14 +232,13 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
|||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "fail".into(), None)
|
.queue_acp_prompt(session.id, "fail".into())
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -298,10 +262,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "succeed".into(), None)
|
.queue_acp_prompt(session.id, "succeed".into())
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -331,12 +295,11 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
|||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
let created = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
|
||||||
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -434,14 +397,13 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "show my prompt".into(), None)
|
.queue_acp_prompt(session.id, "show my prompt".into())
|
||||||
.await
|
.await
|
||||||
.expect("queue prompt"),
|
.expect("queue prompt"),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
|
|
||||||
let (snapshot, _, _) = fx
|
let (snapshot, _, _) = fx
|
||||||
@@ -480,7 +442,6 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
|
|
||||||
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
||||||
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
||||||
@@ -524,19 +485,18 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "first".into(), None)
|
.queue_acp_prompt(session.id, "first".into())
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
let second = fx
|
let second = fx
|
||||||
.manager
|
.manager
|
||||||
.queue_acp_prompt(session.id, "second".into(), None)
|
.queue_acp_prompt(session.id, "second".into())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -563,22 +523,21 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "first".into(), None)
|
.queue_acp_prompt(session.id, "first".into())
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "second".into(), None)
|
.queue_acp_prompt(session.id, "second".into())
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
));
|
));
|
||||||
|
|
||||||
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
@@ -623,12 +582,11 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
||||||
|
|
||||||
let created = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
|
||||||
let acp_id = session.acp_session_id.clone().expect("acp id");
|
let acp_id = session.acp_session_id.clone().expect("acp id");
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -667,18 +625,25 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
revived.status(session.id).await,
|
revived.status(session.id).await,
|
||||||
Some(SessionStatus::Running)
|
Some(SessionStatus::Running)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revived
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
let log = fake.log();
|
let log = fake.log();
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
// Recovery adds: a continue-prompt User row, the adapter's post-load
|
|
||||||
// notification, and a turn-completed footer — 5 events total before the
|
|
||||||
// next manual tick.
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
revived
|
revived
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
.is_ok_and(|events| events.len() == 5)
|
.is_ok_and(|events| events.len() == 3)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -687,7 +652,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
.await
|
.await
|
||||||
.expect("replay read")
|
.expect("replay read")
|
||||||
.expect("recovered event subscription");
|
.expect("recovered event subscription");
|
||||||
assert_eq!(replay.len(), 5);
|
assert_eq!(replay.len(), 3);
|
||||||
assert!(live.try_recv().is_err(), "replay duplicated onto live");
|
assert!(live.try_recv().is_err(), "replay duplicated onto live");
|
||||||
let new_pid = fake.pid();
|
let new_pid = fake.pid();
|
||||||
assert_ne!(new_pid, old_pid);
|
assert_ne!(new_pid, old_pid);
|
||||||
@@ -698,23 +663,16 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
.await
|
.await
|
||||||
.expect("live event after recovery")
|
.expect("live event after recovery")
|
||||||
.expect("broadcast open");
|
.expect("broadcast open");
|
||||||
assert_eq!(event.seq, 6);
|
assert_eq!(event.seq, 4);
|
||||||
assert_eq!(event_text(&event), "tick 1");
|
assert_eq!(event_text(&event), "tick 1");
|
||||||
|
|
||||||
let stored = revived
|
let stored = revived
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
.expect("recovered log");
|
.expect("recovered log");
|
||||||
assert_eq!(stored.len(), 6, "session/load replay must not be appended");
|
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stored.iter().map(event_text).collect::<Vec<_>>(),
|
stored.iter().map(event_text).collect::<Vec<_>>(),
|
||||||
[
|
["hello from fake", "tick 1", "hello after load", "tick 1"]
|
||||||
"hello from fake",
|
|
||||||
"tick 1",
|
|
||||||
"The daemon restarted. Continue where you left off.",
|
|
||||||
"hello after load",
|
|
||||||
"completed",
|
|
||||||
"tick 1",
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
revived.kill(session.id, false).await.expect("kill");
|
revived.kill(session.id, false).await.expect("kill");
|
||||||
@@ -753,7 +711,6 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
|||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
assert_eq!(session.cwd, worktree);
|
assert_eq!(session.cwd, worktree);
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
@@ -802,7 +759,6 @@ async fn failed_recovery_cleans_its_fifo() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
let old_pid = fake.pid();
|
let old_pid = fake.pid();
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
wait_for_exit(old_pid).await;
|
wait_for_exit(old_pid).await;
|
||||||
@@ -836,15 +792,12 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
|||||||
empty.path().to_string_lossy().into_owned(),
|
empty.path().to_string_lossy().into_owned(),
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
let created = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), env, "codex"))
|
.create(structured_command(cwd.path(), env, "codex"))
|
||||||
.await
|
.await
|
||||||
.expect("create registers a Starting session");
|
.expect("a failed session, not an error");
|
||||||
assert_eq!(created.activity, ActivityState::Starting);
|
|
||||||
|
|
||||||
// The background handshake fails because the adapter binary is missing.
|
|
||||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
|
||||||
assert_eq!(session.status, SessionStatus::Dead);
|
assert_eq!(session.status, SessionStatus::Dead);
|
||||||
assert_eq!(session.activity, ActivityState::Error);
|
assert_eq!(session.activity, ActivityState::Error);
|
||||||
let detail = session.activity_detail.clone().expect("actionable detail");
|
let detail = session.activity_detail.clone().expect("actionable detail");
|
||||||
@@ -857,13 +810,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
|||||||
|
|
||||||
// It is a real session: listed, on disk, and dismissable like any other
|
// It is a real session: listed, on disk, and dismissable like any other
|
||||||
// dead one.
|
// dead one.
|
||||||
assert!(fx.manager.has(created.id).await);
|
assert!(fx.manager.has(session.id).await);
|
||||||
assert!(fx
|
assert!(fx
|
||||||
.sidecar_dir()
|
.sidecar_dir()
|
||||||
.join(format!("{}.json", created.id))
|
.join(format!("{}.json", session.id))
|
||||||
.exists());
|
.exists());
|
||||||
fx.manager.kill(created.id, false).await.expect("dismiss");
|
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||||
assert!(!fx.manager.has(created.id).await);
|
assert!(!fx.manager.has(session.id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -872,20 +825,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
|
|||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let err = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||||
.await
|
.await
|
||||||
.expect_err("unsupported command is rejected at request validation");
|
.expect("failed structured session is persisted");
|
||||||
|
|
||||||
assert!(
|
assert_eq!(session.status, SessionStatus::Dead);
|
||||||
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
|
let detail = session.activity_detail.expect("selection error");
|
||||||
"{err:?}"
|
assert!(detail.contains("Claude Code or Codex"), "{detail}");
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
fake.log().is_empty(),
|
fake.log().is_empty(),
|
||||||
"unsupported command launched an adapter"
|
"unsupported command launched an adapter"
|
||||||
);
|
);
|
||||||
|
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -900,7 +853,6 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
assert!(pid_alive(pid));
|
assert!(pid_alive(pid));
|
||||||
|
|
||||||
@@ -943,8 +895,6 @@ async fn shutdown_reaps_every_adapter_child() {
|
|||||||
.create(structured(cwd.path(), b.env()))
|
.create(structured(cwd.path(), b.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create b");
|
.expect("create b");
|
||||||
wait_for_ready(&fx.manager, sa.id).await;
|
|
||||||
wait_for_ready(&fx.manager, sb.id).await;
|
|
||||||
let (pa, pb) = (a.pid(), b.pid());
|
let (pa, pb) = (a.pid(), b.pid());
|
||||||
assert!(pid_alive(pa) && pid_alive(pb));
|
assert!(pid_alive(pa) && pid_alive(pb));
|
||||||
|
|
||||||
@@ -979,13 +929,10 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
let blocker_session = fx
|
fx.manager
|
||||||
.manager
|
|
||||||
.create(structured(cwd.path(), blocker.env()))
|
.create(structured(cwd.path(), blocker.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create blocker");
|
.expect("create blocker");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
wait_for_ready(&fx.manager, blocker_session.id).await;
|
|
||||||
|
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||||
@@ -1037,7 +984,6 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
wait_for_exit(fake.pid()).await;
|
wait_for_exit(fake.pid()).await;
|
||||||
|
|
||||||
fx.manager.shutdown().await;
|
fx.manager.shutdown().await;
|
||||||
@@ -1071,31 +1017,23 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
|||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
||||||
|
|
||||||
// create() returns immediately; the handshake runs in the background
|
let create_cwd = cwd.path().to_path_buf();
|
||||||
// and holds a lifecycle permit.
|
let manager = fx.manager.clone();
|
||||||
let session = fx
|
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
|
||||||
.manager
|
|
||||||
.create(structured(cwd.path(), env))
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
|
|
||||||
// Wait until the adapter has received session/new (handshake in progress).
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fake.log().contains("recv session/new")
|
fake.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Shutdown must wait for the in-flight background handshake.
|
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
assert!(
|
assert!(
|
||||||
!shutdown.is_finished(),
|
!shutdown.is_finished(),
|
||||||
"shutdown returned before handshake finished"
|
"shutdown returned before create finished"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The handshake completes in the background (500ms delay).
|
let session = create.await.expect("create task").expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
@@ -1128,37 +1066,27 @@ async fn structured_handshakes_run_independently() {
|
|||||||
gate.to_string_lossy().into_owned(),
|
gate.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both create() calls return immediately with Starting; the first
|
let first_cwd = cwd.path().to_path_buf();
|
||||||
// adapter's handshake is gated while the second runs freely.
|
let manager = fx.manager.clone();
|
||||||
let first = fx
|
let first =
|
||||||
.manager
|
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
|
||||||
.create(structured(cwd.path(), blocked_env))
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
.await
|
blocked.log().contains("recv session/new")
|
||||||
.expect("first create");
|
})
|
||||||
let second = fx
|
.await;
|
||||||
.manager
|
|
||||||
.create(structured(cwd.path(), ready.env()))
|
|
||||||
.await
|
|
||||||
.expect("second create");
|
|
||||||
|
|
||||||
// The second handshake completes independently of the first.
|
let second = tokio::time::timeout(
|
||||||
let second_ready = wait_for_ready(&fx.manager, second.id).await;
|
Duration::from_secs(5),
|
||||||
assert_eq!(second_ready.activity, ActivityState::Idle);
|
fx.manager.create(structured(cwd.path(), ready.env())),
|
||||||
|
)
|
||||||
// The first handshake is still blocked.
|
.await
|
||||||
let first_snap = fx
|
.expect("second handshake was blocked by the first")
|
||||||
.manager
|
.expect("second create");
|
||||||
.snapshot()
|
assert_eq!(second.status, SessionStatus::Running);
|
||||||
.await
|
assert!(!first.is_finished(), "the gate must still block the first");
|
||||||
.into_iter()
|
|
||||||
.find(|s| s.id == first.id)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(first_snap.activity, ActivityState::Starting);
|
|
||||||
|
|
||||||
std::fs::write(&gate, b"release").unwrap();
|
std::fs::write(&gate, b"release").unwrap();
|
||||||
let first_ready = wait_for_ready(&fx.manager, first.id).await;
|
let first = first.await.expect("first task").expect("first create");
|
||||||
assert_eq!(first_ready.activity, ActivityState::Idle);
|
|
||||||
|
|
||||||
fx.manager.kill(first.id, false).await.expect("kill first");
|
fx.manager.kill(first.id, false).await.expect("kill first");
|
||||||
fx.manager
|
fx.manager
|
||||||
.kill(second.id, false)
|
.kill(second.id, false)
|
||||||
@@ -1186,18 +1114,16 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
gate_b.to_string_lossy().into_owned(),
|
gate_b.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both create() calls return immediately; the background handshake
|
let create_a = {
|
||||||
// tasks hold lifecycle permits and are blocked on their gates.
|
let manager = fx.manager.clone();
|
||||||
let session_a = fx
|
let cwd = cwd.path().to_path_buf();
|
||||||
.manager
|
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
|
||||||
.create(structured(cwd.path(), env_a))
|
};
|
||||||
.await
|
let create_b = {
|
||||||
.expect("create a");
|
let manager = fx.manager.clone();
|
||||||
let session_b = fx
|
let cwd = cwd.path().to_path_buf();
|
||||||
.manager
|
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
|
||||||
.create(structured(cwd.path(), env_b))
|
};
|
||||||
.await
|
|
||||||
.expect("create b");
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
@@ -1217,14 +1143,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
assert!(!shutdown.is_finished());
|
assert!(!shutdown.is_finished());
|
||||||
|
|
||||||
std::fs::write(&gate_a, b"release").unwrap();
|
std::fs::write(&gate_a, b"release").unwrap();
|
||||||
wait_for_ready(&fx.manager, session_a.id).await;
|
let session_a = create_a.await.expect("create a task").expect("create a");
|
||||||
assert!(
|
assert!(
|
||||||
!shutdown.is_finished(),
|
!shutdown.is_finished(),
|
||||||
"shutdown ignored the second create"
|
"shutdown ignored the second create"
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&gate_b, b"release").unwrap();
|
std::fs::write(&gate_b, b"release").unwrap();
|
||||||
wait_for_ready(&fx.manager, session_b.id).await;
|
let session_b = create_b.await.expect("create b task").expect("create b");
|
||||||
shutdown.await.expect("shutdown task");
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
wait_for_exit(a.pid()).await;
|
wait_for_exit(a.pid()).await;
|
||||||
@@ -1240,7 +1166,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let fake = Fake::new();
|
let fake = Fake::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
@@ -1266,30 +1192,40 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
|||||||
auto_approve: false,
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// create() returns immediately with Starting; the handshake is gated.
|
let create = {
|
||||||
let session = fx.manager.create(request).await.expect("create");
|
let manager = fx.manager.clone();
|
||||||
assert_eq!(session.activity, ActivityState::Starting);
|
tokio::spawn(async move { manager.create(request).await })
|
||||||
|
};
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fake.log().contains("recv session/new")
|
fake.log().contains("recv session/new")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let id = fake
|
||||||
|
.log()
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
|
||||||
|
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||||
|
.expect("session id in adapter log");
|
||||||
let pid = fake.pid();
|
let pid = fake.pid();
|
||||||
|
|
||||||
// Kill the session while its handshake is still in progress.
|
create.abort();
|
||||||
// force=true because the worktree has an unpushed branch commit.
|
let shutdown = {
|
||||||
fx.manager.kill(session.id, true).await.expect("kill");
|
let manager = fx.manager.clone();
|
||||||
|
tokio::spawn(async move { manager.shutdown().await })
|
||||||
// Release the gate so the background task can finish and settle.
|
};
|
||||||
std::fs::write(&gate, b"release").unwrap();
|
assert!(create.await.unwrap_err().is_cancelled());
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), shutdown)
|
||||||
// Shutdown must not hang — the lifecycle permit is settled.
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
|
|
||||||
.await
|
.await
|
||||||
.expect("shutdown hung on a settled permit");
|
.expect("cancelled admission kept shutdown waiting")
|
||||||
|
.expect("shutdown task");
|
||||||
wait_for_exit(pid).await;
|
wait_for_exit(pid).await;
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
|
||||||
|
&& !repo.path().join("cancelled-create").exists()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
assert!(fx.manager.snapshot().await.is_empty());
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
let id = session.id.to_string();
|
let id = id.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
std::fs::read_dir(fx.sidecar_dir())
|
std::fs::read_dir(fx.sidecar_dir())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1299,7 +1235,7 @@ async fn killing_a_starting_session_rolls_back_and_settles_admission() {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.starts_with(&id)),
|
.starts_with(&id)),
|
||||||
"killed Starting session left a sidecar artifact"
|
"cancelled create left a sidecar artifact"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1316,7 +1252,6 @@ async fn shutdown_drains_final_notifications_to_the_log() {
|
|||||||
.create(structured(cwd.path(), env))
|
.create(structured(cwd.path(), env))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
fx.manager
|
fx.manager
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
@@ -1347,7 +1282,6 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
|
|||||||
.create(structured(cwd.path(), fake.env()))
|
.create(structured(cwd.path(), fake.env()))
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
wait_for_ready(&fx.manager, session.id).await;
|
|
||||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
|
||||||
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
|||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
tmux: TmuxFixture,
|
_tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
|||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
sidecar,
|
sidecar,
|
||||||
tmux,
|
_tmux: tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,37 +171,3 @@ async fn a_blank_name_changes_nothing() {
|
|||||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session that dies naturally (liveness watcher marks it dead) must not
|
|
||||||
/// overwrite a haiku rename that landed before the death. Regression test for
|
|
||||||
/// the stale-clone sidecar write in `mark_session_dead`.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn session_death_preserves_the_haiku_name() {
|
|
||||||
let fx = fixture();
|
|
||||||
let id = spawn(&fx.manager, None).await;
|
|
||||||
|
|
||||||
fx.manager.claim_autoname(id).await.unwrap();
|
|
||||||
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
||||||
|
|
||||||
// Verify the sidecar has Haiku before the session dies.
|
|
||||||
let before = fx.sidecar.read(id).unwrap().unwrap();
|
|
||||||
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
|
||||||
|
|
||||||
// Kill the tmux session directly so the liveness watcher picks it up.
|
|
||||||
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
|
||||||
fx.manager
|
|
||||||
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
|
||||||
|
|
||||||
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
|
||||||
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let after = fx.sidecar.read(id).unwrap().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
display::name_source(&after),
|
|
||||||
NameSource::Haiku,
|
|
||||||
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
|
||||||
);
|
|
||||||
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
|||||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(r#"id="root""#),
|
html.contains(r#"id="app""#),
|
||||||
"served index is missing the SPA mount point; got:\n{html}"
|
"served index is missing the SPA mount point; got:\n{html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("maestro is running"),
|
!html.contains("maestro is running"),
|
||||||
"served index is the placeholder, not the built bundle"
|
"served index is the placeholder, not the built bundle"
|
||||||
);
|
);
|
||||||
// Stronger signal than `id="root"` (which appears in both source and built
|
// Stronger signal than `id="app"` (which appears in both source and built
|
||||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||||
// both an empty/placeholder embed and the staleness case.
|
// both an empty/placeholder embed and the staleness case.
|
||||||
|
|||||||
@@ -154,8 +154,7 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||||
let id = h
|
h.manager
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: SessionView::Structured,
|
view: SessionView::Structured,
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -167,9 +166,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id;
|
.id
|
||||||
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
|
||||||
id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||||
@@ -239,61 +236,7 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||||
let h = spawn("attachments").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let endpoint = url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/attachments"),
|
|
||||||
);
|
|
||||||
let bytes = vec![0, 255, 10, 13];
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", "sample.bin")])
|
|
||||||
.body(bytes.clone())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
let body: serde_json::Value = response.json().await.unwrap();
|
|
||||||
let path = body["path"].as_str().unwrap();
|
|
||||||
assert!(Path::new(path).is_absolute());
|
|
||||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
|
||||||
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", name)])
|
|
||||||
.body("text")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 400, "{name}");
|
|
||||||
}
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", "large.bin")])
|
|
||||||
.body(vec![0; 20 * 1024 * 1024 + 1])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 413);
|
|
||||||
let response = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
|
||||||
))
|
|
||||||
.query(&[("name", "file.txt")])
|
|
||||||
.body("text")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let fake = FakeAcp::new();
|
let fake = FakeAcp::new();
|
||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
@@ -310,7 +253,6 @@ async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
// A second prompt while a turn is active is accepted into the queue.
|
|
||||||
let second = client
|
let second = client
|
||||||
.post(url(
|
.post(url(
|
||||||
h.addr,
|
h.addr,
|
||||||
@@ -320,7 +262,9 @@ async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
|
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = second.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "turn_in_flight");
|
||||||
|
|
||||||
let cancel = client
|
let cancel = client
|
||||||
.post(url(
|
.post(url(
|
||||||
@@ -331,8 +275,16 @@ async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
// After cancel + settlement, the queued "second" prompt drains. Wait for
|
let next = client
|
||||||
// both the first and the drained prompt to reach ACP.
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "next"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
loop {
|
loop {
|
||||||
if fake.log().matches("recv session/prompt").count() == 2 {
|
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||||
@@ -342,7 +294,7 @@ async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("first and drained queued prompt both reach ACP");
|
.expect("both prompt commands reach ACP");
|
||||||
let log = fake.log();
|
let log = fake.log();
|
||||||
let first_prompt = log.find("recv session/prompt").unwrap();
|
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||||
let cancel = log.find("recv session/cancel").unwrap();
|
let cancel = log.find("recv session/cancel").unwrap();
|
||||||
@@ -994,9 +946,7 @@ async fn names_honors_count_and_caps_it() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||||
// null (no repo anywhere above) or — in sandboxed environments where /tmp
|
|
||||||
// itself sits inside a repo — a path strictly above the tempdir.
|
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let resp: serde_json::Value = reqwest::get(url(
|
let resp: serde_json::Value = reqwest::get(url(
|
||||||
@@ -1011,16 +961,7 @@ async fn gitroot_probes_path() {
|
|||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||||
// inside a checkout), the root legitimately points there. The test only
|
|
||||||
// asserts no root was found *inside* the temp dir.
|
|
||||||
match resp.get("root").and_then(|v| v.as_str()) {
|
|
||||||
None => {}
|
|
||||||
Some(root) => assert!(
|
|
||||||
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
|
|
||||||
"found a repo root inside the fixture at {root:?}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1499,12 +1440,6 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
|||||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||||
panic!("expected structured SessionAdded")
|
panic!("expected structured SessionAdded")
|
||||||
};
|
};
|
||||||
assert_eq!(session.activity, ActivityState::Starting);
|
|
||||||
// The background handshake completes and sends SessionUpdated → Idle.
|
|
||||||
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
|
|
||||||
else {
|
|
||||||
panic!("expected SessionUpdated after handshake")
|
|
||||||
};
|
|
||||||
assert_eq!(session.activity, ActivityState::Idle);
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ fn sample(id: Uuid) -> Session {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
|||||||
.map(|v| v.starts_with("text/html")),
|
.map(|v| v.starts_with("text/html")),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force overrides the refusal: session gone, tree removed.
|
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||||
client.kill_session_forced(s.id).await.expect("forced kill");
|
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"the forced kill ends the session"
|
||||||
);
|
);
|
||||||
assert!(!wt.is_dir(), "a forced kill removes the worktree");
|
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
||||||
assert!(
|
assert!(
|
||||||
!git_worktree_list(base.path())
|
git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the removed tree is unregistered from git"
|
"the kept tree stays registered in git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,15 +226,6 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
|
|||||||
.map(|(_, tool)| *tool)
|
.map(|(_, tool)| *tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a command line resolves to an agent Maestro can run as a
|
|
||||||
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
|
||||||
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
|
||||||
/// control on this, and the daemon rejects a `Structured` request for
|
|
||||||
/// anything else, so the answer has to come from one place.
|
|
||||||
pub fn supports_structured_view(command: &str) -> bool {
|
|
||||||
matches!(command_tool(command), Some("claude" | "codex"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||||
/// of the word that is the actual program (past every assignment/wrapper it
|
/// of the word that is the actual program (past every assignment/wrapper it
|
||||||
/// resolved through).
|
/// resolved through).
|
||||||
@@ -744,8 +735,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,14 +1064,4 @@ mod tests {
|
|||||||
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||||
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_view_support_follows_the_resolved_program() {
|
|
||||||
assert!(supports_structured_view("claude --resume"));
|
|
||||||
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
|
||||||
assert!(supports_structured_view("npx claude-code"));
|
|
||||||
assert!(!supports_structured_view("opencode"));
|
|
||||||
assert!(!supports_structured_view("bash -l"));
|
|
||||||
assert!(!supports_structured_view("claude-code-acp"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,16 +132,6 @@ pub struct Session {
|
|||||||
/// the two are minted by different processes with different lifetimes.
|
/// the two are minted by different processes with different lifetimes.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub acp_session_id: Option<String>,
|
pub acp_session_id: Option<String>,
|
||||||
/// When `true` the daemon auto-approves every ACP permission request for
|
|
||||||
/// this session instead of surfacing them to the client. Set at spawn time
|
|
||||||
/// from the preset or the `CreateSessionRequest`; persisted so it survives
|
|
||||||
/// a daemon restart.
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_approve: bool,
|
|
||||||
/// Prompts queued on the daemon while a turn was in flight or the adapter
|
|
||||||
/// was still booting. Persisted so the queue survives a daemon restart.
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub queued_prompts: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session's process shape (#133): a plain terminal, or a structured agent
|
/// A session's process shape (#133): a plain terminal, or a structured agent
|
||||||
@@ -185,10 +175,6 @@ pub enum ActivityState {
|
|||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
/// An agent reported a failure, or a structured session/turn failed.
|
/// An agent reported a failure, or a structured session/turn failed.
|
||||||
Error,
|
Error,
|
||||||
/// The ACP adapter is booting; the session is registered but cannot accept
|
|
||||||
/// prompts yet. Older clients that do not know this variant degrade it to
|
|
||||||
/// `Idle` via the lenient deserializer — acceptable.
|
|
||||||
Starting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for ActivityState {
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
@@ -201,7 +187,6 @@ impl<'de> Deserialize<'de> for ActivityState {
|
|||||||
"Active" => Self::Active,
|
"Active" => Self::Active,
|
||||||
"AwaitingInput" => Self::AwaitingInput,
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
"Error" => Self::Error,
|
"Error" => Self::Error,
|
||||||
"Starting" => Self::Starting,
|
|
||||||
// Known or not, any other spelling degrades to the neutral default
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
_ => Self::Idle,
|
_ => Self::Idle,
|
||||||
})
|
})
|
||||||
@@ -240,10 +225,9 @@ where
|
|||||||
"Idle" => Ok(ActivityState::Idle),
|
"Idle" => Ok(ActivityState::Idle),
|
||||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||||
"Error" => Ok(ActivityState::Error),
|
"Error" => Ok(ActivityState::Error),
|
||||||
"Starting" => Ok(ActivityState::Starting),
|
|
||||||
other => Err(D::Error::unknown_variant(
|
other => Err(D::Error::unknown_variant(
|
||||||
other,
|
other,
|
||||||
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,10 +866,6 @@ pub struct ReducedState {
|
|||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub config_options: Option<Value>,
|
pub config_options: Option<Value>,
|
||||||
/// Prompts queued on the daemon while a turn is in flight. Drained as
|
|
||||||
/// a single concatenated prompt when the current turn completes.
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub queued_prompts: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server messages sent over a structured-session WebSocket.
|
/// Server messages sent over a structured-session WebSocket.
|
||||||
@@ -1068,8 +1048,6 @@ mod tests {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,11 +86,6 @@ pub fn structured_prompt(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/prompt")
|
format!("{SESSIONS}/{id}/structured/prompt")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
|
|
||||||
pub fn structured_attachment(id: Uuid) -> String {
|
|
||||||
format!("{SESSIONS}/{id}/structured/attachments")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||||
pub fn structured_cancel(id: Uuid) -> String {
|
pub fn structured_cancel(id: Uuid) -> String {
|
||||||
format!("{SESSIONS}/{id}/structured/cancel")
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
@@ -118,7 +113,6 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED: &str = "/api/structured/:id";
|
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
|
||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||||
PROTOCOL_VERSION,
|
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -50,8 +49,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +60,6 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
/// Every ACP prompt sent to a structured session, in order.
|
|
||||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -97,7 +92,6 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -184,16 +178,6 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every ACP prompt this daemon received, oldest first.
|
|
||||||
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
|
||||||
self.state
|
|
||||||
.inner
|
|
||||||
.lock()
|
|
||||||
.expect("poisoned")
|
|
||||||
.structured_prompts
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -282,26 +266,6 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn structured_prompt(
|
|
||||||
State(s): State<AppState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(req): Json<StructuredPromptRequest>,
|
|
||||||
) -> Response {
|
|
||||||
if let Some(r) = forced(&s) {
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
let mut guard = s.inner.lock().expect("poisoned");
|
|
||||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
|
||||||
return (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
guard.structured_prompts.push((id, req));
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
//! `dispatch` / the overlay routing.
|
//! `dispatch` / the overlay routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -55,36 +54,6 @@ enum OverlayDecision {
|
|||||||
Pass,
|
Pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
|
||||||
let encoded = base64_encode(text.as_bytes());
|
|
||||||
let mut output = std::io::stdout();
|
|
||||||
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64_encode(bytes: &[u8]) -> String {
|
|
||||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
||||||
for chunk in bytes.chunks(3) {
|
|
||||||
let bits = (u32::from(chunk[0]) << 16)
|
|
||||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
|
||||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
|
||||||
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
|
||||||
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
|
||||||
output.push(if chunk.len() > 1 {
|
|
||||||
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
|
||||||
} else {
|
|
||||||
'='
|
|
||||||
});
|
|
||||||
output.push(if chunk.len() > 2 {
|
|
||||||
TABLE[(bits & 0x3f) as usize] as char
|
|
||||||
} else {
|
|
||||||
'='
|
|
||||||
});
|
|
||||||
}
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
home_dir: String,
|
home_dir: String,
|
||||||
@@ -371,10 +340,6 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.handle_structured_mouse(&key) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(effects) = self.handle_structured_key(&key) {
|
if let Some(effects) = self.handle_structured_key(&key) {
|
||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -458,61 +423,6 @@ impl App {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transcript blocks are pointer targets: click selects the whole block,
|
|
||||||
/// gives it the selection surface, and copies its complete text.
|
|
||||||
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
|
||||||
let InputKey::Mouse {
|
|
||||||
kind,
|
|
||||||
region: Region::Pane,
|
|
||||||
row,
|
|
||||||
..
|
|
||||||
} = key
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let Some(sid) = self.state.active_session() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(handle) = self.structured.get(&sid) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(pane) = self.region.pane else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let offset = usize::from(row.saturating_sub(pane.y));
|
|
||||||
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
|
||||||
if offset >= usize::from(transcript_height) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let mut state = handle.state.lock().expect("structured state mutex");
|
|
||||||
let rendered_line = state.scroll.saturating_add(offset);
|
|
||||||
if !state.select_rendered_line(rendered_line) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let Some(id) = state.selected.as_deref() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
if *kind == MouseKind::Hover {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let text = crate::structured_pane::copy_text(transcript);
|
|
||||||
drop(state);
|
|
||||||
if text.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
match copy_to_terminal_clipboard(&text) {
|
|
||||||
Ok(()) => self.state.toast("copied transcript block"),
|
|
||||||
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -1995,12 +1905,6 @@ async fn fetch_branches(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clipboard_payload_uses_standard_base64() {
|
|
||||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
|
||||||
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
|
||||||
}
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::draft::DraftField;
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
@@ -2216,8 +2120,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -2401,8 +2303,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -2432,8 +2332,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3596,8 +3494,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: focused,
|
last_focused_at: focused,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
};
|
};
|
||||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
@@ -3878,8 +3774,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3917,8 +3811,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,8 +225,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -811,8 +811,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
};
|
};
|
||||||
@@ -255,14 +255,9 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
/// ACP-backed structured transcript. Supported only when the resolved
|
/// ACP-backed structured transcript. Off by default and supported only
|
||||||
/// command is Claude Code or Codex, and on by default whenever it is —
|
/// when the resolved command is Claude Code or Codex.
|
||||||
/// until the operator works the toggle themselves.
|
|
||||||
pub structured_view: bool,
|
pub structured_view: bool,
|
||||||
/// Set once the operator toggles structured view by hand. From then on a
|
|
||||||
/// command change never re-enables what they turned off, the same guard
|
|
||||||
/// `name_touched` gives the name field.
|
|
||||||
pub structured_view_touched: bool,
|
|
||||||
/// Enclosing repository of the chosen target — from the project scan for
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
pub base_repo: Option<PathBuf>,
|
||||||
@@ -303,7 +298,7 @@ impl Default for DraftPane {
|
|||||||
// while its data loads, and stay usable when the daemon reports none.
|
// while its data loads, and stay usable when the daemon reports none.
|
||||||
let mut presets = builtin_presets();
|
let mut presets = builtin_presets();
|
||||||
presets.push(custom_preset());
|
presets.push(custom_preset());
|
||||||
let mut draft = Self {
|
Self {
|
||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
@@ -324,7 +319,6 @@ impl Default for DraftPane {
|
|||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
structured_view: false,
|
structured_view: false,
|
||||||
structured_view_touched: false,
|
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -343,11 +337,7 @@ impl Default for DraftPane {
|
|||||||
// straight away, and Tab from here reaches the command tiles.
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
focused_field: DraftField::DirPicker,
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
home_dir: String::new(),
|
||||||
};
|
}
|
||||||
// The built-in tile the form opens on decides whether structured view
|
|
||||||
// starts ticked, exactly as a later preset list would.
|
|
||||||
draft.sync_structured_view();
|
|
||||||
draft
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +356,7 @@ impl DraftPane {
|
|||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
self.apply_seed_command();
|
||||||
self.sync_structured_view();
|
self.clear_unsupported_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
@@ -683,24 +673,18 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn supports_structured_view(&self) -> bool {
|
fn supports_structured_view(&self) -> bool {
|
||||||
supports_structured_view(self.command())
|
matches!(command_tool(self.command()), Some("claude" | "codex"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the toggle after the command changed: an unsupported
|
fn clear_unsupported_structured_view(&mut self) {
|
||||||
/// command forces it off, a supported one turns it back on unless the
|
|
||||||
/// operator has already made the choice themselves.
|
|
||||||
fn sync_structured_view(&mut self) {
|
|
||||||
if !self.supports_structured_view() {
|
if !self.supports_structured_view() {
|
||||||
self.structured_view = false;
|
self.structured_view = false;
|
||||||
} else if !self.structured_view_touched {
|
|
||||||
self.structured_view = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_structured_view(&mut self) {
|
pub fn toggle_structured_view(&mut self) {
|
||||||
if self.supports_structured_view() {
|
if self.supports_structured_view() {
|
||||||
self.structured_view = !self.structured_view;
|
self.structured_view = !self.structured_view;
|
||||||
self.structured_view_touched = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,7 +832,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||||
self.sync_structured_view();
|
self.clear_unsupported_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1151,7 +1135,7 @@ impl DraftPane {
|
|||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => {
|
DraftField::CustomCommand => {
|
||||||
self.custom_command.handle_key(other);
|
self.custom_command.handle_key(other);
|
||||||
self.sync_structured_view();
|
self.clear_unsupported_structured_view();
|
||||||
}
|
}
|
||||||
// Typing filters the list; the highlight resets to the top
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
@@ -1913,7 +1897,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
fn structured_view_is_default_off_and_visible_for_supported_agents() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
advanced_collapsed: false,
|
advanced_collapsed: false,
|
||||||
..DraftPane::default()
|
..DraftPane::default()
|
||||||
@@ -1938,11 +1922,13 @@ mod tests {
|
|||||||
auto_approve: false,
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(draft.structured_view, "on by default for a supported agent");
|
assert!(!draft.structured_view);
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
draft.cycle_preset(1);
|
draft.cycle_preset(1);
|
||||||
assert_eq!(draft.command(), "codex");
|
assert_eq!(draft.command(), "codex");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2032,16 +2018,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
draft.sync_structured_view();
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||||
supported,
|
supported,
|
||||||
"toggle visibility for {command:?}"
|
"toggle visibility for {command:?}"
|
||||||
);
|
);
|
||||||
|
draft.toggle_structured_view();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.structured_view, supported,
|
draft.structured_view, supported,
|
||||||
"default selection for {command:?}"
|
"toggle takes effect for {command:?}"
|
||||||
);
|
);
|
||||||
let view = draft.build_request().unwrap().view;
|
let view = draft.build_request().unwrap().view;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2053,12 +2039,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"request view for {command:?}"
|
"request view for {command:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"the toggle turns the default off for {command:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2096,6 +2076,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
draft.toggle_structured_view();
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
@@ -2114,45 +2095,6 @@ mod tests {
|
|||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default only applies until the operator has an opinion: once they
|
|
||||||
/// untick the box, moving between supported commands leaves it unticked
|
|
||||||
/// instead of turning it back on under them.
|
|
||||||
#[test]
|
|
||||||
fn an_unticked_structured_view_survives_a_command_change() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
advanced_collapsed: false,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.set_presets(vec![
|
|
||||||
SpawnPreset {
|
|
||||||
id: "claude".into(),
|
|
||||||
label: "Claude".into(),
|
|
||||||
command: "claude".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "codex".into(),
|
|
||||||
label: "Codex".into(),
|
|
||||||
command: "codex".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
|
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(!draft.structured_view);
|
|
||||||
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "codex");
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"the operator's choice outranks the default"
|
|
||||||
);
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Editing the custom-command text away from a supported agent clears an armed
|
/// Editing the custom-command text away from a supported agent clears an armed
|
||||||
/// selection immediately, matching the preset-switch case above — an
|
/// selection immediately, matching the preset-switch case above — an
|
||||||
/// invalid `Structured` request can never reach `build_request`.
|
/// invalid `Structured` request can never reach `build_request`.
|
||||||
@@ -2167,7 +2109,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||||
draft.sync_structured_view();
|
draft.toggle_structured_view();
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
typed(&mut draft, " -p");
|
typed(&mut draft, " -p");
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ pub enum MouseKind {
|
|||||||
Click,
|
Click,
|
||||||
DblClick,
|
DblClick,
|
||||||
Drag,
|
Drag,
|
||||||
Hover,
|
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,6 @@ impl InputKey {
|
|||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
MouseKind::DblClick => "dblclick",
|
MouseKind::DblClick => "dblclick",
|
||||||
MouseKind::Drag => "drag",
|
MouseKind::Drag => "drag",
|
||||||
MouseKind::Hover => "hover",
|
|
||||||
MouseKind::ScrollUp => "scrollup",
|
MouseKind::ScrollUp => "scrollup",
|
||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
@@ -180,7 +178,6 @@ impl InputKey {
|
|||||||
"click" => MouseKind::Click,
|
"click" => MouseKind::Click,
|
||||||
"dblclick" => MouseKind::DblClick,
|
"dblclick" => MouseKind::DblClick,
|
||||||
"drag" => MouseKind::Drag,
|
"drag" => MouseKind::Drag,
|
||||||
"hover" => MouseKind::Hover,
|
|
||||||
"scrollup" => MouseKind::ScrollUp,
|
"scrollup" => MouseKind::ScrollUp,
|
||||||
"scrolldown" => MouseKind::ScrollDown,
|
"scrolldown" => MouseKind::ScrollDown,
|
||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
@@ -300,7 +297,6 @@ where
|
|||||||
let mk = match kind {
|
let mk = match kind {
|
||||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||||
MouseEventKind::Moved => MouseKind::Hover,
|
|
||||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -685,9 +681,6 @@ mod tests {
|
|||||||
let s = "mouse:dblclick(sidebar-row)";
|
let s = "mouse:dblclick(sidebar-row)";
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s);
|
assert_eq!(k.canonical(), s);
|
||||||
let s = "mouse:hover(pane)";
|
|
||||||
let k = InputKey::parse(s).unwrap();
|
|
||||||
assert_eq!(k.canonical(), s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl Surface {
|
|||||||
pub fn activity(&self, state: ActivityState) -> Color {
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
match state {
|
match state {
|
||||||
ActivityState::Active => self.active,
|
ActivityState::Active => self.active,
|
||||||
ActivityState::Idle | ActivityState::Starting => self.idle,
|
ActivityState::Idle => self.idle,
|
||||||
ActivityState::AwaitingInput => self.awaiting,
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
ActivityState::Error => self.error,
|
ActivityState::Error => self.error,
|
||||||
}
|
}
|
||||||
@@ -928,10 +928,9 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const STATES: [ActivityState; 5] = [
|
const STATES: [ActivityState; 4] = [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -502,8 +502,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,7 +979,6 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1012,7 +1009,6 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1274,8 +1270,6 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
auto_approve: false,
|
|
||||||
queued_prompts: Vec::new(),
|
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
@@ -308,12 +308,11 @@ fn fallback_after_removal(
|
|||||||
|
|
||||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (index, row) in state.rows.iter().enumerate() {
|
for row in &state.rows {
|
||||||
if row.kind == TranscriptRowKind::Plan {
|
if row.kind == TranscriptRowKind::Plan {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let row_width = width.saturating_sub(1).max(1);
|
let row_width = width.saturating_sub(1).max(1);
|
||||||
let start = out.len();
|
|
||||||
match row.kind {
|
match row.kind {
|
||||||
TranscriptRowKind::User => push_wrapped(
|
TranscriptRowKind::User => push_wrapped(
|
||||||
&mut out,
|
&mut out,
|
||||||
@@ -357,53 +356,10 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
|||||||
),
|
),
|
||||||
TranscriptRowKind::Plan => {}
|
TranscriptRowKind::Plan => {}
|
||||||
}
|
}
|
||||||
if out.len() > start
|
|
||||||
&& state.rows[index + 1..]
|
|
||||||
.iter()
|
|
||||||
.any(|next| next.kind != TranscriptRowKind::Plan)
|
|
||||||
{
|
|
||||||
// A blank surface line is the vertical padding that keeps adjacent
|
|
||||||
// transcript blocks readable at a glance.
|
|
||||||
out.push(VisualLine {
|
|
||||||
text: String::new(),
|
|
||||||
row_id: None,
|
|
||||||
rail: false,
|
|
||||||
tone: Tone::Normal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text supplied to the terminal clipboard when a transcript block is
|
|
||||||
/// clicked. It preserves the full tool detail rather than its compact preview.
|
|
||||||
pub fn copy_text(row: &TranscriptRow) -> String {
|
|
||||||
match row.kind {
|
|
||||||
TranscriptRowKind::User
|
|
||||||
| TranscriptRowKind::AgentText
|
|
||||||
| TranscriptRowKind::AgentThought => content_text(row),
|
|
||||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
|
||||||
let (name, argument) = tool_title(row);
|
|
||||||
let mut lines = vec![if argument.is_empty() {
|
|
||||||
name
|
|
||||||
} else {
|
|
||||||
format!("{name} {argument}")
|
|
||||||
}];
|
|
||||||
lines.extend(tool_content_lines(row, None));
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
TranscriptRowKind::Approval => row
|
|
||||||
.data
|
|
||||||
.get("toolCall")
|
|
||||||
.and_then(|tool| tool.get("title"))
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.unwrap_or("permission requested")
|
|
||||||
.into(),
|
|
||||||
TranscriptRowKind::Notice => "[notice]".into(),
|
|
||||||
TranscriptRowKind::Plan => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_wrapped(
|
fn push_wrapped(
|
||||||
out: &mut Vec<VisualLine>,
|
out: &mut Vec<VisualLine>,
|
||||||
row: &TranscriptRow,
|
row: &TranscriptRow,
|
||||||
@@ -1044,39 +1000,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transcript_blocks_have_a_padding_line_between_them() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.rows = vec![
|
|
||||||
row(
|
|
||||||
"one",
|
|
||||||
TranscriptRowKind::User,
|
|
||||||
json!({"content":{"type":"text","text":"first"}}),
|
|
||||||
),
|
|
||||||
row(
|
|
||||||
"two",
|
|
||||||
TranscriptRowKind::AgentText,
|
|
||||||
json!({"content":{"type":"text","text":"second"}}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let lines = build_lines(&state, 80);
|
|
||||||
assert_eq!(lines.len(), 3);
|
|
||||||
assert_eq!(lines[1].text, "");
|
|
||||||
assert!(lines[1].row_id.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn copying_a_tool_keeps_its_full_detail() {
|
|
||||||
let tool = row(
|
|
||||||
"t",
|
|
||||||
TranscriptRowKind::ToolCall,
|
|
||||||
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_is_compact_until_selected_row_expands() {
|
fn tool_is_compact_until_selected_row_expands() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
@@ -1497,7 +1420,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 20;
|
state.scroll = 10;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1516,7 +1439,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 20;
|
state.scroll = 10;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1536,7 +1459,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 20;
|
state.scroll = 10;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1555,7 +1478,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 20;
|
state.scroll = 10;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1574,7 +1497,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 20;
|
state.scroll = 10;
|
||||||
|
|
||||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||||
|
|||||||
@@ -199,18 +199,6 @@ impl StructuredState {
|
|||||||
self.follow_selection = true;
|
self.follow_selection = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the transcript block under a rendered transcript line. Pointer
|
|
||||||
/// input uses the same row identity map as scroll reconciliation, so a
|
|
||||||
/// wrapped block behaves as one target.
|
|
||||||
pub fn select_rendered_line(&mut self, line: usize) -> bool {
|
|
||||||
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
self.selected = Some(id);
|
|
||||||
self.follow_selection = false;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_selected(&mut self) {
|
pub fn toggle_selected(&mut self) {
|
||||||
let Some(id) = self.selected.clone() else {
|
let Some(id) = self.selected.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -669,25 +657,6 @@ mod tests {
|
|||||||
assert_eq!(state.composer.value(), "hi");
|
assert_eq!(state.composer.value(), "hi");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selecting_a_rendered_line_selects_its_whole_block() {
|
|
||||||
let mut state = StructuredState {
|
|
||||||
line_rows: vec![
|
|
||||||
Some("one".into()),
|
|
||||||
Some("one".into()),
|
|
||||||
None,
|
|
||||||
Some("two".into()),
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(state.select_rendered_line(1));
|
|
||||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
|
||||||
assert!(!state.follow_selection);
|
|
||||||
assert!(!state.select_rendered_line(2));
|
|
||||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
+1
-2
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
|||||||
state word and no second badge: the rail exists so the state is still legible
|
state word and no second badge: the rail exists so the state is still legible
|
||||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||||
|
|
||||||
In the **web client** the rail is a 7px bar pinned to the row's own left
|
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||||
edge, square corners, spanning the full card height edge to edge — it
|
edge, square corners, spanning the full card height edge to edge — it
|
||||||
shifts outward with nesting depth along with the row's own padding, since
|
shifts outward with nesting depth along with the row's own padding, since
|
||||||
it is positioned relative to the row. It carries motion only for states that
|
it is positioned relative to the row. It carries motion only for states that
|
||||||
@@ -160,7 +160,6 @@ The state word is never spelled out — the colour carries it.
|
|||||||
| ---------------- | --------- | ---------------- |
|
| ---------------- | --------- | ---------------- |
|
||||||
| `Active` | `--live` | `active` |
|
| `Active` | `--live` | `active` |
|
||||||
| `Idle` | `--muted` | `idle` |
|
| `Idle` | `--muted` | `idle` |
|
||||||
| `Starting` | `--muted` | `idle` |
|
|
||||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||||
| `Error` | `--danger`| `error` |
|
| `Error` | `--danger`| `error` |
|
||||||
|
|
||||||
|
|||||||
+13
-65
@@ -35,22 +35,6 @@ session's worktree holds unsaved work — applies to a structured session
|
|||||||
exactly as it does to a terminal one; only what gets terminated differs (an
|
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||||
ACP handle instead of a tmux pane).
|
ACP handle instead of a tmux pane).
|
||||||
|
|
||||||
## Choosing the view
|
|
||||||
|
|
||||||
`view` is fixed at spawn and never changes. Every client picks it the same
|
|
||||||
way, from the resolved command alone
|
|
||||||
(`maestro_protocol::display::supports_structured_view`): a command that
|
|
||||||
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
|
||||||
else spawns **Terminal**. The operator can override the default — the web and
|
|
||||||
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
|
||||||
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
|
||||||
even for a command the CLI does not recognise and lets the daemon refuse it).
|
|
||||||
Once the operator works the toggle, a later command change never re-ticks it
|
|
||||||
under them; it can still be forced off when the new command has no adapter.
|
|
||||||
|
|
||||||
The daemon is the last word: a `Structured` request for a command with no
|
|
||||||
adapter is refused there, whichever client sent it.
|
|
||||||
|
|
||||||
## The adapter
|
## The adapter
|
||||||
|
|
||||||
The resolved structured command selects a provider adapter. Its binary is
|
The resolved structured command selects a provider adapter. Its binary is
|
||||||
@@ -189,17 +173,10 @@ choices, not daemon ones.
|
|||||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups each contiguous run of two or more tool rows into a
|
- The web client groups two or more tool rows after the same `User` row into
|
||||||
collapsed activity block. An `AgentText` or `AgentThought` row between
|
one collapsed turn activity block. Opening it reveals independently
|
||||||
tools splits them into separate groups, so the timeline reads
|
expandable tool calls, including any raw command, input, output, and diff
|
||||||
text → group → text → group rather than lumping every tool in a turn into
|
content the adapter supplied.
|
||||||
one box. Text and thought rows stay outside groups as standalone transcript
|
|
||||||
items — the operator sees all reasoning without expanding. Resolved
|
|
||||||
approval rows between tools within a run are absorbed into the group. The
|
|
||||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
|
||||||
argument come from the last tool row's `splitToolTitle` — the operator
|
|
||||||
sees which command is current at a glance. Opening the group reveals tool
|
|
||||||
rows in chronological order.
|
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
@@ -325,26 +302,6 @@ pending decision, entirely on the daemon side:
|
|||||||
|
|
||||||
## Prompt and cancel: bounded liveness
|
## Prompt and cancel: bounded liveness
|
||||||
|
|
||||||
The web composer accepts up to ten files through its attachment picker, file
|
|
||||||
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
|
|
||||||
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
|
|
||||||
be removed before sending; failed sends retain the draft and attachments.
|
|
||||||
Pastes also stay inline when all ten attachment slots are occupied or the text
|
|
||||||
exceeds the 20 MiB file limit.
|
|
||||||
|
|
||||||
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
|
|
||||||
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
|
|
||||||
Only live structured sessions accept uploads. Filenames are limited to 180
|
|
||||||
bytes and cannot contain directory separators or control characters. Files get unique names and private
|
|
||||||
file permissions in `<id>.attachments` beside the session sidecar. They persist
|
|
||||||
across daemon restarts; removing a composer chip does not delete uploaded data.
|
|
||||||
There is currently no automatic attachment cleanup.
|
|
||||||
|
|
||||||
The prompt includes each filename and host path so the adapter can read it
|
|
||||||
through its own file tools, including images when the provider supports them.
|
|
||||||
These references are persisted in the ordinary user transcript row. Attachment
|
|
||||||
bytes are not embedded in ACP messages or transcript history.
|
|
||||||
|
|
||||||
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||||
against each other through one per-session `operation` lock
|
against each other through one per-session `operation` lock
|
||||||
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||||
@@ -353,26 +310,17 @@ thing a callback can be attributed to, so at most one turn may ever be
|
|||||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||||
allowed to claim the daemon's idea of "the active turn."
|
allowed to claim the daemon's idea of "the active turn."
|
||||||
|
|
||||||
- Prompts are accepted at any time: if the session is idle the daemon dispatches
|
- A prompt is appended as a durable `User` row before it claims the turn and
|
||||||
immediately (`204`); if a turn is active the prompt is appended to a
|
|
||||||
server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
|
|
||||||
When the active turn completes, all queued prompts are concatenated with
|
|
||||||
`\n\n` and dispatched as the next turn. The queue is persisted in the session
|
|
||||||
sidecar and survives daemon restarts — clients never queue locally.
|
|
||||||
`ReducedState.queued_prompts` contains the current queue so every connected
|
|
||||||
client can render the pending messages.
|
|
||||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched.
|
operator's input. If that append fails, the prompt is not dispatched. While a
|
||||||
|
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||||
|
queuing — one active turn per session, never a hidden queue of prompts.
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||||
the next turn. If the session dies instead (settlement timeout), the queue
|
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
||||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
prompt claiming "the active turn" while the old one is still outstanding
|
||||||
its response), the daemon cannot simply forget it: ACP has not confirmed the
|
would make the next permission callback ambiguous between the two.
|
||||||
cancel yet, and a new prompt claiming "the active turn" while the old one is
|
|
||||||
still outstanding would make the next permission callback ambiguous between
|
|
||||||
the two.
|
|
||||||
- So a cancelled-but-still-dispatched turn must **settle within
|
- So a cancelled-but-still-dispatched turn must **settle within
|
||||||
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
||||||
resolves) or that grace period runs out. If it settles in time, the next
|
resolves) or that grace period runs out. If it settles in time, the next
|
||||||
|
|||||||
+25
-10
@@ -75,17 +75,27 @@ The point of refusing rather than cleaning up is that the session stays as the
|
|||||||
operator's handle on their own unfinished work. A session that is killed while
|
operator's handle on their own unfinished work. A session that is killed while
|
||||||
its worktree still matters leaves a directory nobody is looking at.
|
its worktree still matters leaves a directory nobody is looking at.
|
||||||
|
|
||||||
|
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||||
|
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||||
|
That is only safe because force never removes what blocked.
|
||||||
|
|
||||||
### `--force`
|
### `--force`
|
||||||
|
|
||||||
`--force` overrides the refusal and **removes the worktree**, even when it holds
|
`--force` overrides the refusal and nothing else. It ends the session and
|
||||||
unsaved work. The operator sees the breakdown in the confirmation prompt and
|
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
||||||
decides; once confirmed, the tree is gone.
|
would have blocked. A tree whose guard is clear never needed the override, so a
|
||||||
|
forced kill removes it as usual — force changes what happens to blocked work,
|
||||||
|
not ordinary cleanup.
|
||||||
|
|
||||||
In the web client the kill button sends the unforced `DELETE` straight away —
|
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||||
there is no pre-confirmation, because the daemon is the one that decides. Only
|
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||||
the refusal raises a prompt, and that prompt names what is in the way and offers
|
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||||
"Force kill", which repeats the request with `force=true`.
|
"Force kill", which repeats the request with `force=true`.
|
||||||
|
|
||||||
|
Ending a session and destroying its work are two separate acts. Maestro only
|
||||||
|
performs the first one. Removing a tree it left behind is the operator's own
|
||||||
|
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||||
|
|
||||||
### Exiting on its own
|
### Exiting on its own
|
||||||
|
|
||||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||||
@@ -103,12 +113,17 @@ once. A removed tree drops off the record, so dismissing the session afterwards
|
|||||||
does not refuse: there is no guard left to trip.
|
does not refuse: there is no guard left to trip.
|
||||||
|
|
||||||
A tree that was left alone stays attached to the dead record, where dismissing it
|
A tree that was left alone stays attached to the dead record, where dismissing it
|
||||||
later goes through `--force`, which removes it. The same holds when the probe
|
later goes through `--force`, which leaves it on disk too. The same holds when
|
||||||
itself fails: nothing is destroyed.
|
the probe itself fails: nothing is destroyed.
|
||||||
|
|
||||||
## Trees left behind by natural exit
|
## Nothing is removed on the operator's behalf
|
||||||
|
|
||||||
A forced kill always removes the worktree. A natural exit with unsaved work
|
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
||||||
leaves the tree on disk — there is no operator confirmation in that path. Those
|
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
||||||
trees are ordinary git worktrees; `git worktree list` finds them and
|
ordinary git worktree on disk; `git worktree list` finds it and
|
||||||
`git worktree remove` removes them.
|
`git worktree remove` removes it.
|
||||||
|
|
||||||
|
That is deliberate. A tracked list of trees pending deletion is a second place
|
||||||
|
the truth can live, and it goes stale the moment somebody removes a directory by
|
||||||
|
hand. Git already knows which worktrees exist, and removing one is not a
|
||||||
|
decision Maestro is better placed to make.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user