Compare commits
306
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd | ||
|
|
b22539bd5b | ||
|
|
8f0d73d89c | ||
|
|
c3e2faa7ee | ||
|
|
6ccd79baa0 | ||
|
|
4932cbf3b3 | ||
|
|
1ea463f6cf | ||
|
|
7c27a897ac | ||
|
|
a9717d01c8 | ||
|
|
35a6ff9d0f | ||
|
|
474000935a | ||
|
|
5f8cc2a140 | ||
|
|
432967fe64 | ||
|
|
2512b8be42 | ||
|
|
e3556bd847 | ||
|
|
ce0e4e6820 | ||
|
|
fa3d6c82b2 | ||
|
|
ebe25a1a55 | ||
|
|
923911f631 | ||
|
|
8cbe8510ac | ||
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 | ||
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 | ||
|
|
9533672e09 | ||
|
|
b11784e8e3 | ||
|
|
42ffc1a352 | ||
|
|
a50f098c8a | ||
|
|
32d4058c96 | ||
|
|
74a423567f | ||
|
|
b225fbdd69 | ||
|
|
244e8d2927 | ||
|
|
4a1a10c49a | ||
|
|
d607740fea | ||
|
|
0b34d1608f | ||
|
|
075dea509a | ||
|
|
c067f48a24 | ||
|
|
51f96cfb98 | ||
|
|
8f5d96a95b | ||
|
|
3e98b3ab24 | ||
|
|
24ed1ac002 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
64741a027e | ||
|
|
cdd4213ff5 | ||
|
|
f8973b3802 | ||
|
|
1e2c0f0a38 | ||
|
|
6fa7dc63e4 | ||
|
|
5c0a4c4640 | ||
|
|
8773671afd | ||
|
|
d9815d86c6 | ||
|
|
13009d0f0e | ||
|
|
bbae09bc48 | ||
|
|
c7c294996e | ||
|
|
3c638b08de | ||
|
|
be23a2b539 | ||
|
|
b98f5cb3e5 | ||
|
|
0f5576c77a | ||
|
|
0292f373f7 | ||
|
|
eea337ce36 | ||
|
|
fd3ea64949 | ||
|
|
046c110460 | ||
|
|
6eb65f1057 | ||
|
|
3a68c1dd2f | ||
|
|
fde1954eac | ||
|
|
25435c34b2 | ||
|
|
4a956e9c38 | ||
|
|
2a49814572 | ||
|
|
d1aa56bab3 | ||
|
|
cf322746a7 | ||
|
|
b25e733539 | ||
|
|
5a861976d7 | ||
|
|
bfe4965081 | ||
|
|
9d7ed8c071 | ||
|
|
88a40c1ea7 | ||
|
|
7049b5f1c0 | ||
|
|
c277ad630c | ||
|
|
fc9c62d46b | ||
|
|
3fe560d67e | ||
|
|
b2b33535ae | ||
|
|
4ea6ddcaee | ||
|
|
e9142bb027 | ||
|
|
d11098a478 | ||
|
|
61776b4e0a | ||
|
|
eabd21bccf | ||
|
|
fd54d4f833 | ||
|
|
78b5abb0cf | ||
|
|
6d8b49b75f | ||
|
|
6c20a99d58 | ||
|
|
b1718559df | ||
|
|
540a3025b1 | ||
|
|
3cb4bebd1e | ||
|
|
c0da16658a | ||
|
|
eb4655a9d2 | ||
|
|
0e20baf7c2 | ||
|
|
c76059ec66 | ||
|
|
af8acbfbf2 | ||
|
|
f02168ffd5 | ||
|
|
1ad7845f51 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c | ||
|
|
706942ddd7 | ||
|
|
52877abe29 |
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -15,3 +15,6 @@ worktrees/
|
||||
|
||||
# `nix run .#dev` scratch state (tmux socket)
|
||||
/.dev/
|
||||
|
||||
# Per-machine Claude Code tool permissions
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
||||
the two matchers must agree on, and the fixture table they are tested against.
|
||||
Read it before changing either bar or either matcher.
|
||||
|
||||
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
||||
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
||||
transcript row kinds, the two projections and their cursor/replay semantics,
|
||||
the handshake and declined capabilities, and the approval nonce rules. Read
|
||||
it before changing anything that renders a structured session.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
Generated
+750
-57
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
@@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
libc = "0.2"
|
||||
maestro-acp = { path = "crates/maestro-acp" }
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
# `default-features = false` so each consumer opts into `haiku-autoname`
|
||||
# through its own feature of the same name (#131); a workspace entry that
|
||||
@@ -38,9 +40,11 @@ tempfile = "3"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-width = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
ratatui = "0.28"
|
||||
vt100 = "0.15"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build/
|
||||
/captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -0,0 +1,82 @@
|
||||
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)
|
||||
|
||||
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
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -0,0 +1,19 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,121 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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)}"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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(" ")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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 }
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
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>)
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
@@ -0,0 +1,136 @@
|
||||
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)
|
||||
@@ -0,0 +1,91 @@
|
||||
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)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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() }
|
||||
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Maestro</string>
|
||||
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
package pt.naps.maestro
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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())))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,38 @@
|
||||
[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"
|
||||
|
||||
[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" }
|
||||
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.
@@ -0,0 +1,7 @@
|
||||
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
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
#!/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
@@ -0,0 +1,89 @@
|
||||
@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
|
||||
@@ -0,0 +1,19 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Maestro"
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "maestro-acp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "ACP client role: one provider adapter child per structured session"
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A deterministic stand-in for `claude-code-acp` (#134).
|
||||
|
||||
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
|
||||
answers `initialize` and `session/new`, logs every method it receives, and
|
||||
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
|
||||
the adapter's name, so the real adapter (which needs Claude auth and is
|
||||
slow) never runs in the deterministic suite.
|
||||
|
||||
Environment knobs, all optional:
|
||||
|
||||
FAKE_ACP_LOG append one line per event to this file:
|
||||
start pid=<pid> cwd=<cwd>
|
||||
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
|
||||
caps <json of clientCapabilities>
|
||||
recv <method>
|
||||
signal SIGTERM
|
||||
FAKE_ACP_ECHO_ENV name of one env var to log at start
|
||||
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
||||
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
||||
`session/new`
|
||||
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
|
||||
`session/new`
|
||||
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
||||
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
||||
so the SIGTERM path is exercised)
|
||||
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
||||
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
||||
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
||||
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
||||
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
||||
notifications ("replayed <i>") before answering,
|
||||
the way a real agent streams history back
|
||||
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
||||
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
|
||||
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
||||
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
||||
JSON-RPC error
|
||||
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
||||
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||
callback until a later prompt
|
||||
|
||||
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
||||
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
||||
speak on demand (#135).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
LOG = os.environ.get("FAKE_ACP_LOG")
|
||||
|
||||
|
||||
def log(line):
|
||||
if not LOG:
|
||||
return
|
||||
with open(LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def on_sigterm(_signum, _frame):
|
||||
log("signal SIGTERM")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, on_sigterm)
|
||||
|
||||
session_id = f"fake-{os.getpid()}"
|
||||
ticks = 0
|
||||
prompt_failures = 0
|
||||
pending_prompt_id = None
|
||||
pending_permission_id = None
|
||||
delayed_permission = False
|
||||
current_model = "opus"
|
||||
current_mode = "default"
|
||||
current_effort = "high"
|
||||
|
||||
|
||||
def settings_response(include_session=False):
|
||||
result = {}
|
||||
if include_session:
|
||||
result["sessionId"] = session_id
|
||||
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
||||
result.update({
|
||||
"models": {
|
||||
"currentModelId": current_model,
|
||||
"availableModels": [
|
||||
{"modelId": "opus", "name": "Opus"},
|
||||
{"modelId": "sonnet", "name": "Sonnet"},
|
||||
],
|
||||
},
|
||||
"modes": {
|
||||
"currentModeId": current_mode,
|
||||
"availableModes": [
|
||||
{"id": "default", "name": "Default"},
|
||||
{"id": "plan", "name": "Plan"},
|
||||
],
|
||||
},
|
||||
"configOptions": [{
|
||||
"id": "effort", "name": "Effort", "category": "thought_level",
|
||||
"type": "select", "currentValue": current_effort,
|
||||
"options": [
|
||||
{"value": "low", "name": "Low"},
|
||||
{"value": "high", "name": "High"},
|
||||
],
|
||||
}],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def chunk(text):
|
||||
return {"jsonrpc": "2.0", "method": "session/update", "params": {
|
||||
"sessionId": session_id,
|
||||
"update": {"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": text}},
|
||||
}}
|
||||
|
||||
|
||||
def on_sigusr1(_signum, _frame):
|
||||
global ticks
|
||||
ticks += 1
|
||||
send(chunk(f"tick {ticks}"))
|
||||
log(f"emit tick {ticks}")
|
||||
|
||||
|
||||
signal.signal(signal.SIGUSR1, on_sigusr1)
|
||||
|
||||
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
|
||||
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
|
||||
if echo:
|
||||
log(f"env {echo}={os.environ.get(echo, '')}")
|
||||
|
||||
for raw in sys.stdin:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
msg = json.loads(raw)
|
||||
method = msg.get("method")
|
||||
if method is None:
|
||||
# A response to something we sent; nothing to do.
|
||||
if msg.get("id") == pending_permission_id:
|
||||
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
||||
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||
"result": {"stopReason": "end_turn"}})
|
||||
pending_prompt_id = None
|
||||
pending_permission_id = None
|
||||
continue
|
||||
log(f"recv {method}")
|
||||
rid = msg.get("id")
|
||||
params = msg.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
|
||||
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
|
||||
send({"jsonrpc": "2.0", "id": rid,
|
||||
"error": {"code": -32000, "message": "fake init failure"}})
|
||||
continue
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||
"protocolVersion": params.get("protocolVersion", 1),
|
||||
"agentCapabilities": {
|
||||
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
|
||||
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
|
||||
"authMethods": [],
|
||||
}})
|
||||
elif method == "session/new":
|
||||
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
|
||||
while gate and not os.path.exists(gate):
|
||||
time.sleep(0.01)
|
||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
||||
send(chunk("hello from fake"))
|
||||
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
||||
sys.exit(0)
|
||||
elif method == "session/load":
|
||||
session_id = params.get("sessionId")
|
||||
log(f"recv session/load sessionId={session_id}")
|
||||
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
|
||||
send({"jsonrpc": "2.0", "id": rid,
|
||||
"error": {"code": -32000, "message": "fake load failure"}})
|
||||
continue
|
||||
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
||||
send(chunk(f"replayed {i + 1}"))
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
||||
send(chunk("hello after load"))
|
||||
elif method == "session/set_model":
|
||||
current_model = params.get("modelId")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||
elif method == "session/set_mode":
|
||||
current_mode = params.get("modeId")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||
elif method == "session/set_config_option":
|
||||
current_effort = params.get("value")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||
"configOptions": settings_response()["configOptions"]}})
|
||||
elif method == "session/cancel":
|
||||
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||
send(chunk("final on cancel"))
|
||||
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
||||
and pending_prompt_id is not None):
|
||||
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||
"result": {"stopReason": "cancelled"}})
|
||||
pending_prompt_id = None
|
||||
pending_permission_id = None
|
||||
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
||||
sys.exit(0)
|
||||
elif method == "session/prompt":
|
||||
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
||||
if pending_prompt_id is None:
|
||||
pending_prompt_id = rid
|
||||
delayed_permission = True
|
||||
continue
|
||||
if delayed_permission:
|
||||
delayed_permission = False
|
||||
pending_permission_id = "permission-1"
|
||||
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||
"method": "session/request_permission", "params": {
|
||||
"sessionId": session_id,
|
||||
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||
"options": [
|
||||
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||
]
|
||||
}})
|
||||
continue
|
||||
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
||||
pending_prompt_id = rid
|
||||
pending_permission_id = "permission-1"
|
||||
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||
"method": "session/request_permission", "params": {
|
||||
"sessionId": session_id,
|
||||
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||
"options": [
|
||||
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
||||
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||
]
|
||||
}})
|
||||
continue
|
||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
||||
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
||||
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
||||
prompt_failures += 1
|
||||
send({"jsonrpc": "2.0", "id": rid,
|
||||
"error": {"code": -32000, "message": "fake prompt failure"}})
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "id": rid,
|
||||
"result": {"stopReason": "end_turn"}})
|
||||
elif rid is not None:
|
||||
send({"jsonrpc": "2.0", "id": rid,
|
||||
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
||||
|
||||
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
|
||||
while True:
|
||||
signal.pause()
|
||||
@@ -0,0 +1,514 @@
|
||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||
//! ignored smoke tests against each maintained adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fake_agent() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
|
||||
}
|
||||
|
||||
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
|
||||
/// log file the agent appends to. `PATH` for the spawn is just that dir.
|
||||
struct Fake {
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn log_path(&self) -> PathBuf {
|
||||
self.dir.path().join("agent.log")
|
||||
}
|
||||
|
||||
fn log(&self) -> String {
|
||||
std::fs::read_to_string(self.log_path()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The fake's `bin/` first, so it shadows any real adapter, then the
|
||||
/// process `PATH` so the script's `python3` still resolves.
|
||||
fn env(&self) -> HashMap<String, String> {
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
self.dir.path().join("bin").display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
HashMap::from([
|
||||
("PATH".to_string(), path),
|
||||
(
|
||||
"FAKE_ACP_LOG".to_string(),
|
||||
self.log_path().to_string_lossy().into_owned(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn config(&self, cwd: &Path) -> SpawnConfig {
|
||||
let mut config = SpawnConfig::new(cwd, self.env());
|
||||
config.handshake_timeout = Duration::from_secs(10);
|
||||
config
|
||||
}
|
||||
|
||||
async fn wait_for_log(&self, needle: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if self.log().contains(needle) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("log never contained {needle:?}:\n{}", self.log());
|
||||
}
|
||||
|
||||
fn pid(&self) -> u32 {
|
||||
self.log()
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("start pid="))
|
||||
.and_then(|rest| rest.split(' ').next())
|
||||
.and_then(|pid| pid.parse().ok())
|
||||
.expect("pid in log")
|
||||
}
|
||||
|
||||
async fn wait_for_exit(&self) {
|
||||
let pid = self.pid();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while pid_alive(pid) && Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(!pid_alive(pid), "child {pid} still alive");
|
||||
}
|
||||
}
|
||||
|
||||
fn pid_alive(pid: u32) -> bool {
|
||||
Path::new(&format!("/proc/{pid}")).exists()
|
||||
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
||||
.map(|s| !s.contains(") Z "))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config
|
||||
.env
|
||||
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||
config
|
||||
.env
|
||||
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
|
||||
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
assert!(session.session_id().starts_with("fake-"));
|
||||
assert!(pid_alive(session.pid()));
|
||||
|
||||
let log = fake.log();
|
||||
let cwd_canon = cwd.path().canonicalize().unwrap();
|
||||
assert!(
|
||||
log.contains(&format!("cwd={}", cwd_canon.display())),
|
||||
"child cwd missing from log:\n{log}"
|
||||
);
|
||||
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
|
||||
assert!(log.contains("recv initialize"), "{log}");
|
||||
assert!(log.contains("recv session/new"), "{log}");
|
||||
let caps = log
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("caps "))
|
||||
.expect("caps line");
|
||||
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
|
||||
assert_eq!(caps["fs"]["readTextFile"], false);
|
||||
assert_eq!(caps["fs"]["writeTextFile"], false);
|
||||
assert_eq!(caps["terminal"], false);
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_adapter_names_the_install_command() {
|
||||
let empty = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let env = HashMap::from([(
|
||||
"PATH".to_string(),
|
||||
empty.path().to_string_lossy().into_owned(),
|
||||
)]);
|
||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||
.await
|
||||
.expect_err("no adapter on PATH");
|
||||
assert!(matches!(
|
||||
err,
|
||||
AcpError::AdapterMissing {
|
||||
adapter: AcpAdapter::ClaudeCode
|
||||
}
|
||||
));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
||||
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_adapter_has_its_own_binary_and_install_guidance() {
|
||||
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
|
||||
assert!(AcpAdapter::Codex
|
||||
.install_command()
|
||||
.contains("@agentclientprotocol/codex-acp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_selects_the_codex_adapter() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn Codex adapter");
|
||||
assert!(fake.log().contains("recv session/new"));
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_updates_reach_the_taken_receiver() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()))
|
||||
.await
|
||||
.expect("spawn");
|
||||
|
||||
let mut updates = session.take_updates().expect("first take");
|
||||
assert!(session.take_updates().is_none(), "handed out once");
|
||||
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||
.await
|
||||
.expect("update within 5s")
|
||||
.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());
|
||||
match first.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
|
||||
other => panic!("unexpected content {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected update {other:?}"),
|
||||
}
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advertised_settings_are_normalized_and_mutable() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
let mut updates = session.take_updates().expect("updates");
|
||||
|
||||
let initial = loop {
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||
.await
|
||||
.expect("settings update")
|
||||
.expect("channel open");
|
||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||
continue;
|
||||
};
|
||||
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
|
||||
break options.config_options;
|
||||
}
|
||||
};
|
||||
assert_eq!(initial.len(), 3);
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "effort"));
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
||||
let model = initial
|
||||
.iter()
|
||||
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
|
||||
.expect("legacy model option");
|
||||
let model = serde_json::to_value(model).unwrap();
|
||||
assert_eq!(model["options"][0]["name"], "Opus");
|
||||
assert!(model["options"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|option| option["value"] == "fable"));
|
||||
|
||||
let changed = session
|
||||
.set_config(
|
||||
"__maestro_legacy_model".into(),
|
||||
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
||||
)
|
||||
.expect("queue model change");
|
||||
changed
|
||||
.await
|
||||
.expect("response delivered")
|
||||
.expect("model changed");
|
||||
fake.wait_for_log("recv session/set_model").await;
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_sends_cancel_then_sigterm() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn");
|
||||
let pid = session.pid();
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(200), Duration::from_secs(2))
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
fake.wait_for_log("signal SIGTERM").await;
|
||||
|
||||
let log = fake.log();
|
||||
let cancel = log.find("recv session/cancel").expect("cancel logged");
|
||||
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
|
||||
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
|
||||
assert!(!pid_alive(pid), "child {pid} still alive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config
|
||||
.env
|
||||
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
|
||||
session
|
||||
.terminate(Duration::from_secs(2), Duration::from_secs(2))
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
let log = fake.log();
|
||||
assert!(log.contains("recv session/cancel"), "{log}");
|
||||
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
|
||||
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||
assert!(err.to_string().contains("fake init failure"), "{err}");
|
||||
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
|
||||
// follows may find it already gone. Either way it must not survive.
|
||||
let pid: u32 = fake
|
||||
.log()
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("start pid="))
|
||||
.and_then(|rest| rest.split(' ').next())
|
||||
.and_then(|p| p.parse().ok())
|
||||
.expect("pid in log");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while pid_alive(pid) && Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_exit_is_observed_after_a_successful_handshake() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config
|
||||
.env
|
||||
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
|
||||
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
|
||||
.await
|
||||
.expect("exit within 5s")
|
||||
.expect("status");
|
||||
assert!(status.success(), "{status}");
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_exit_before_handshake_reports_exited_early() {
|
||||
// A "adapter" that dies before answering anything.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let script = bin.join(ADAPTER_BIN);
|
||||
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
|
||||
let mut config = SpawnConfig::new(cwd.path(), env);
|
||||
config.handshake_timeout = Duration::from_secs(10);
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||
match &err {
|
||||
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||
assert!(status.contains('3'), "{status}");
|
||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||
}
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_terminates_and_reaps_the_child() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
|
||||
drop(session);
|
||||
|
||||
fake.wait_for_log("signal SIGTERM").await;
|
||||
fake.wait_for_exit().await;
|
||||
}
|
||||
|
||||
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||
panic!("expected Notification, got {update:?}");
|
||||
};
|
||||
match &n.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||
ContentBlock::Text(t) => t.text.clone(),
|
||||
other => panic!("unexpected content {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected update {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
||||
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
assert_eq!(session.session_id(), "fake-old");
|
||||
let log = fake.log();
|
||||
assert!(
|
||||
log.contains("recv session/load sessionId=fake-old"),
|
||||
"{log}"
|
||||
);
|
||||
assert!(!log.contains("recv session/new"), "{log}");
|
||||
|
||||
// The three replayed chunks never reach the receiver: the event log
|
||||
// already holds them. The first notification after the response does.
|
||||
let mut updates = session.take_updates().expect("updates");
|
||||
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||
.await
|
||||
.expect("update within 5s")
|
||||
.expect("channel open");
|
||||
assert_eq!(chunk_text(&first), "hello after load");
|
||||
// Nothing queued behind it either.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(updates.try_recv().is_err(), "replay leaked through");
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_session_requires_the_load_capability() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config)
|
||||
.await
|
||||
.expect_err("no loadSession");
|
||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||
assert!(err.to_string().contains("loadSession"), "{err}");
|
||||
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_session_error_fails_the_spawn() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||
assert!(err.to_string().contains("fake load failure"), "{err}");
|
||||
}
|
||||
|
||||
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
|
||||
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_adapter_handshake() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
|
||||
.await
|
||||
.expect("real claude-code-acp handshake");
|
||||
assert!(!session.session_id().is_empty());
|
||||
assert!(pid_alive(session.pid()));
|
||||
session
|
||||
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
|
||||
/// End to end against the maintained Codex adapter. It needs a locally
|
||||
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_codex_adapter_handshake() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session =
|
||||
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("real codex-acp handshake");
|
||||
assert!(!session.session_id().is_empty());
|
||||
assert!(pid_alive(session.pid()));
|
||||
session
|
||||
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
pub enum AttachError {
|
||||
#[error("ws: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("daemon returned {status} ({code}): {message}")]
|
||||
Rejected {
|
||||
status: String,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("termios: {0}")]
|
||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
||||
}
|
||||
|
||||
impl AttachError {
|
||||
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||
if let Some(body) = response.body().as_deref() {
|
||||
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||
return Self::Rejected {
|
||||
status: response.status().to_string(),
|
||||
code: error_body.error.code,
|
||||
message: error_body.error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::WebSocket(error)
|
||||
}
|
||||
|
||||
/// True when the failure was an inability to reach the WS endpoint at the
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// rejection from a daemon that did answer.
|
||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
||||
/// byte that exits cleanly without killing the session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(AttachError::from_websocket)?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
let raw = RawTty::enable()?;
|
||||
@@ -233,4 +256,25 @@ mod tests {
|
||||
Err(AttachError::InvalidDetachKey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_surfaces_structured_handshake_errors() {
|
||||
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||
"not_terminal",
|
||||
"session is not a terminal session",
|
||||
))
|
||||
.unwrap();
|
||||
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||
.status(409)
|
||||
.body(Some(body))
|
||||
.unwrap();
|
||||
|
||||
let error =
|
||||
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
/// Send text to a running session. Terminal sessions receive raw
|
||||
/// keystrokes; structured sessions receive an ACP prompt.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
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.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
@@ -125,7 +133,7 @@ pub struct KillArgs {
|
||||
/// Session ID to kill.
|
||||
pub session_id: Uuid,
|
||||
/// End the session even when its worktree holds unsaved work (#81). The
|
||||
/// worktree is left on disk — never removed.
|
||||
/// worktree is removed along with the session.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
||||
/// agent failing is reported and does not stop the others.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn run_hooks_install() -> Result<(), CliError> {
|
||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||
let changed = maestro_hooks::install_for_home(&home)?;
|
||||
if changed {
|
||||
println!(
|
||||
"installed claude-code activity hook: {} → {}",
|
||||
maestro_hooks::script_path(&home).display(),
|
||||
maestro_hooks::settings_path(&home).display()
|
||||
);
|
||||
} else {
|
||||
println!("claude-code activity hook already up to date");
|
||||
let script = maestro_hooks::script_path(&home);
|
||||
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||
let agent = target.label();
|
||||
let settings = target.settings_path(&home);
|
||||
match result {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"installed {agent} activity hook: {} → {}",
|
||||
script.display(),
|
||||
settings.display()
|
||||
);
|
||||
if target == maestro_hooks::Target::Codex {
|
||||
// Codex will not run a hook it has not been told to
|
||||
// trust, and that approval is the operator's to give.
|
||||
println!(" codex will ask you to trust this hook the next time it starts");
|
||||
}
|
||||
}
|
||||
Ok(false) => println!("{agent} activity hook already up to date"),
|
||||
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, WorktreeSpec,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -51,15 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: spawn_view(&args),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve: false,
|
||||
};
|
||||
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
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
@@ -112,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
||||
/// `maestro send`. Answers with the session that was typed into, so the
|
||||
/// caller can name it once the daemon has accepted the text.
|
||||
///
|
||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||
/// there — ACP prompts are always complete messages).
|
||||
///
|
||||
/// 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)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
@@ -137,19 +158,28 @@ pub async fn run_send(
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
// We always need the session list: even a bare UUID needs the view field
|
||||
// to decide which endpoint to hit.
|
||||
let sessions = client.list_sessions().await?;
|
||||
let id = resolve_session(&sessions, &args.session)?;
|
||||
let view = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.view)
|
||||
.unwrap_or(SessionView::Terminal);
|
||||
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
match view {
|
||||
SessionView::Structured => {
|
||||
client.send_structured_prompt(id, &text).await?;
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -178,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -189,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let view = match s.view {
|
||||
SessionView::Terminal => "terminal",
|
||||
SessionView::Structured => "structured",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
ActivityState::Starting => "starting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
@@ -206,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, view, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
@@ -229,13 +264,70 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||
use uuid::Uuid;
|
||||
|
||||
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 {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(id),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: command.into(),
|
||||
@@ -252,6 +344,9 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +378,22 @@ mod tests {
|
||||
assert!(s.contains("idle"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_exposes_terminal_and_structured_views() {
|
||||
let terminal = session("/tmp", "bash", 1);
|
||||
let mut structured = session("/tmp", "claude", 2);
|
||||
structured.view = SessionView::Structured;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||
|
||||
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||
use maestro_testing::{session, MockDaemon};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
@@ -91,11 +92,13 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
let created = mock
|
||||
.client()
|
||||
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/home/mock".into(),
|
||||
command: "bash".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -199,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
||||
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]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
|
||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +519,7 @@ auto_install = false
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -551,6 +556,7 @@ auto_install = false
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -561,6 +567,7 @@ auto_install = false
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -578,11 +585,13 @@ auto_install = false
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
@@ -597,6 +606,7 @@ auto_install = false
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
include_dir = { workspace = true, optional = true }
|
||||
libc.workspace = true
|
||||
maestro-acp.workspace = true
|
||||
mime_guess = { workspace = true, optional = true }
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks = { workspace = true, default-features = false }
|
||||
@@ -42,6 +43,7 @@ tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
libc.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a structured session's sidebar state from its ACP projection.
|
||||
pub fn decide_structured(
|
||||
turn_active: bool,
|
||||
pending_approval: bool,
|
||||
turn_failed: bool,
|
||||
) -> ActivityState {
|
||||
if turn_failed {
|
||||
ActivityState::Error
|
||||
} else if pending_approval {
|
||||
ActivityState::AwaitingInput
|
||||
} else if turn_active {
|
||||
ActivityState::Active
|
||||
} else {
|
||||
ActivityState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a session's pane must be continuously focused (a client's pane,
|
||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||
/// operator has looked, so the rail stops asking for attention.
|
||||
@@ -136,6 +153,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||
assert_eq!(
|
||||
decide_structured(true, true, false),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_in_force_until_exactly_its_expiry() {
|
||||
let ttl = Duration::from_secs(300);
|
||||
|
||||
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#106)");
|
||||
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||
let agent = target.label();
|
||||
match outcome {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!(agent, "installed activity hook (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!(agent, "updated activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
"hooks auto-install failed; run `maestro hooks install` manually"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!("updated claude-code activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
@@ -100,7 +106,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
projects.start_background_refresh();
|
||||
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
manager.clone(),
|
||||
config.hostname.clone(),
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
@@ -112,9 +118,22 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let bound = listener.local_addr()?;
|
||||
tracing::info!(addr = %bound, "API listening");
|
||||
|
||||
let shutdown = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("ctrl-c received — shutting down");
|
||||
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
|
||||
// shutdown, and both must reach the adapter children below (#134). The
|
||||
// unit's `KillMode=process` signals only the daemon, so nothing else
|
||||
// would end them.
|
||||
let shutdown_manager = manager.clone();
|
||||
let shutdown = async move {
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("install SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
|
||||
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
|
||||
}
|
||||
// Axum waits for upgraded WebSockets to drain. Structured children
|
||||
// must be settled before that wait, while the signal path still owns
|
||||
// the shutdown order (#179).
|
||||
shutdown_manager.shutdown().await;
|
||||
};
|
||||
|
||||
axum::serve(listener, router)
|
||||
|
||||
@@ -80,13 +80,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_repo_dir_is_none() {
|
||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||
// The fixture has no `.git`, so `find_root` must not return a path
|
||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||
// (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 sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
match find_root(&sub) {
|
||||
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]
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
||||
//! same installer `maestro hooks install` uses writes it. That covers
|
||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
||||
//! a failure is warned about and never blocks startup. The same global hook
|
||||
//! serves every Claude Code session on the machine, so nothing here targets
|
||||
//! repositories or worktrees.
|
||||
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||
//! file already carries exactly what this build ships (and `[hooks]
|
||||
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||
//! well as first starts: an install from a maestro that had fewer events —
|
||||
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||
//! failure is warned about and never blocks startup, and one target failing
|
||||
//! does not stop the others. The same global hook serves every session on the
|
||||
//! machine, so nothing here targets repositories or worktrees.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
||||
/// happened; an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
use maestro_hooks::Target;
|
||||
|
||||
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||
/// any whose install is already current. Returns what happened per target —
|
||||
/// an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
|
||||
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
|
||||
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
|
||||
Target::ALL
|
||||
.iter()
|
||||
.map(|&target| (target, auto_install_target(target, home_dir)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_current(&settings, &script) {
|
||||
let home = Path::new(home_dir);
|
||||
let settings = target.settings_path(home);
|
||||
let script = maestro_hooks::script_path(home);
|
||||
if maestro_hooks::is_current(target, &settings, &script) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
let stale = maestro_hooks::is_installed(&settings);
|
||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||
match maestro_hooks::install_target(target, home) {
|
||||
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
||||
Ok(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
tracing::warn!(
|
||||
target = target.label(),
|
||||
error = %e,
|
||||
"hooks auto-install failed (non-fatal)"
|
||||
);
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The outcome recorded for one target.
|
||||
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
|
||||
outcomes
|
||||
.iter()
|
||||
.find(|(t, _)| *t == target)
|
||||
.map(|(_, o)| *o)
|
||||
.expect("every target is reported")
|
||||
}
|
||||
|
||||
/// Everything happens under a temp `$HOME`; the developer's own
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
||||
/// written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
assert!(maestro_hooks::is_installed(&settings));
|
||||
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||
for target in Target::ALL {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, target),
|
||||
AutoInstallOutcome::Installed,
|
||||
"{}",
|
||||
target.label()
|
||||
);
|
||||
assert!(maestro_hooks::is_installed(
|
||||
&target.settings_path(dir.path())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent maestro knows about is hooked, not just Claude Code: a
|
||||
/// codex session reports activity and its first prompt the same way.
|
||||
#[test]
|
||||
fn codex_gets_its_own_hook_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auto_install(dir.path().to_str().unwrap());
|
||||
let hooks = dir.path().join(".codex/hooks.json");
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
|
||||
let events = root["hooks"].as_object().unwrap();
|
||||
assert!(events.contains_key("PermissionRequest"));
|
||||
assert!(
|
||||
!events.contains_key("Notification"),
|
||||
"codex has no Notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -69,14 +120,22 @@ mod tests {
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A current install is left exactly as it is.
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let before = std::fs::read_to_string(&settings).unwrap();
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(&settings).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
let before: Vec<String> = Target::ALL
|
||||
.iter()
|
||||
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||
.collect();
|
||||
let outcomes = auto_install(home);
|
||||
for (i, target) in Target::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, *target),
|
||||
AutoInstallOutcome::AlreadyPresent
|
||||
);
|
||||
assert_eq!(
|
||||
before[i],
|
||||
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An install from an older maestro — present, but missing an event this
|
||||
@@ -87,7 +146,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let settings = Target::ClaudeCode.settings_path(dir.path());
|
||||
let script = maestro_hooks::script_path(dir.path());
|
||||
|
||||
let mut root: serde_json::Value =
|
||||
@@ -96,12 +155,23 @@ mod tests {
|
||||
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
||||
assert!(maestro_hooks::is_current(&settings, &script));
|
||||
let outcomes = auto_install(home);
|
||||
assert_eq!(
|
||||
outcome(&outcomes, Target::ClaudeCode),
|
||||
AutoInstallOutcome::Upgraded
|
||||
);
|
||||
assert!(maestro_hooks::is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings,
|
||||
&script
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
let outcomes = auto_install("");
|
||||
for target in Target::ALL {
|
||||
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod hooks;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projection;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod release;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
@@ -21,12 +22,14 @@ use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||
use crate::sidecar::EventCursor;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalApiState {
|
||||
@@ -64,7 +67,20 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
.route(routes::patterns::ATTACH, any(attach_ws));
|
||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||
.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_CONFIG, post(structured_config))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_APPROVAL,
|
||||
post(structured_approval),
|
||||
);
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
{
|
||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||
@@ -164,12 +180,14 @@ fn dir_error_status(code: &str) -> StatusCode {
|
||||
fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
"not_terminal" => StatusCode::CONFLICT,
|
||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -297,11 +315,10 @@ async fn rename_session(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||
/// The report holds above the daemon's inference for a TTL, then inference
|
||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
||||
/// session's state. Structured sessions acknowledge and ignore these reports
|
||||
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
||||
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
||||
async fn report_activity(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
@@ -368,6 +385,198 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(status, Json(ApiError::new(code, message))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayQuery {
|
||||
since: Option<u64>,
|
||||
before: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
|
||||
|
||||
async fn structured_replay(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<ReplayQuery>,
|
||||
) -> Response {
|
||||
if query.since.is_some() && query.before.is_some() {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_cursor",
|
||||
"use either since or before, not both",
|
||||
);
|
||||
}
|
||||
let cursor = match query.before {
|
||||
Some(before) => EventCursor::Before(Some(before)),
|
||||
None => EventCursor::Since(query.since.unwrap_or(0)),
|
||||
};
|
||||
match s
|
||||
.manager
|
||||
.read_acp_projection_replay(
|
||||
id,
|
||||
cursor,
|
||||
query
|
||||
.limit
|
||||
.unwrap_or(100)
|
||||
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(events)) => Json(events).into_response(),
|
||||
// A terminal session, or a structured session whose adapter is no
|
||||
// longer live, cannot provide a coherent replay continuation.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
||||
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
||||
Ok(QueueAcpPrompt::Full) => error_response(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"queue_full",
|
||||
"the prompt queue has reached its capacity",
|
||||
),
|
||||
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Ok(QueueAcpPrompt::Failed(message)) => {
|
||||
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
||||
}
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
match s.manager.cancel_acp_turn(id).await {
|
||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(false) => 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_config(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<StructuredConfigRequest>,
|
||||
) -> Response {
|
||||
match s
|
||||
.manager
|
||||
.set_acp_config(id, body.config_id, body.value)
|
||||
.await
|
||||
{
|
||||
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(SetAcpConfig::InvalidValue) => error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_config_value",
|
||||
"configuration values must be strings or booleans",
|
||||
),
|
||||
Ok(SetAcpConfig::Unavailable) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Ok(SetAcpConfig::Failed(message)) => {
|
||||
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
|
||||
}
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_approval(
|
||||
State(s): State<LocalApiState>,
|
||||
Path((id, nonce)): Path<(Uuid, String)>,
|
||||
Json(decision): Json<StructuredApprovalDecision>,
|
||||
) -> Response {
|
||||
let decision = match decision {
|
||||
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
|
||||
StructuredApprovalDecision::AllowForSession => {
|
||||
crate::session::ApprovalDecision::AllowForSession
|
||||
}
|
||||
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
|
||||
};
|
||||
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
|
||||
Ok(Some(())) => StatusCode::NO_CONTENT.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
@@ -405,12 +614,23 @@ async fn attach_ws(
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||
Ok(subscription) => subscription,
|
||||
Err(SessionError::NotFound(_)) => {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let info = error.to_error_info();
|
||||
return Err(error_response(
|
||||
session_error_status(&info.code),
|
||||
&info.code,
|
||||
&info.message,
|
||||
));
|
||||
}
|
||||
};
|
||||
let manager = s.manager.clone();
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
@@ -418,6 +638,75 @@ async fn attach_ws(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn structured_ws(
|
||||
Path(id): Path<Uuid>,
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((snapshot, state, rx)) = s
|
||||
.manager
|
||||
.subscribe_acp_projection(id, EventCursor::Since(0))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
})?
|
||||
else {
|
||||
return Err(error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
));
|
||||
};
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
let _ = pump_structured(socket, snapshot, state, rx).await;
|
||||
}))
|
||||
}
|
||||
|
||||
async fn pump_structured(
|
||||
socket: axum::extract::ws::WebSocket,
|
||||
snapshot: maestro_protocol::TranscriptSnapshot,
|
||||
state: maestro_protocol::ReducedState,
|
||||
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
|
||||
) -> Result<(), axum::Error> {
|
||||
use axum::extract::ws::Message;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
for event in [
|
||||
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||
StructuredEvent::ReducedState { state },
|
||||
] {
|
||||
if sink
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&event).expect("structured event encode"),
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
loop {
|
||||
tokio::select! {
|
||||
update = rx.recv() => match update {
|
||||
Ok(update) => {
|
||||
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
|
||||
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
},
|
||||
frame = stream.next() => match frame {
|
||||
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(_)) => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2525
-155
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,23 @@
|
||||
//! original `cwd`, command, env, metadata, etc. Files are deleted on
|
||||
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
|
||||
//! advertised but with empty metadata (handled by the caller, not here).
|
||||
//!
|
||||
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
||||
//! sidecar: every `session/update` notification and daemon approval row is one
|
||||
//! JSON object, appended before it is broadcast. The log is the source of
|
||||
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
||||
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
||||
//! that does not parse — including the partial line a hard kill leaves at
|
||||
//! the end — keeps its seq and is skipped on read. `delete` removes the log
|
||||
//! with the sidecar; `load_all` never reads one as a sidecar.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_protocol::Session;
|
||||
use maestro_acp::schema::SessionNotification;
|
||||
use maestro_protocol::{Session, TranscriptRow};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -38,6 +51,120 @@ impl SidecarStore {
|
||||
self.dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
/// Where the per-session event log lives (#135).
|
||||
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
|
||||
self.dir.join(format!("{id}.events.jsonl"))
|
||||
}
|
||||
|
||||
/// Durable evidence that graceful daemon shutdown ended this structured
|
||||
/// session and the next daemon may resume it.
|
||||
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
|
||||
self.dir.join(format!("{id}.recover"))
|
||||
}
|
||||
|
||||
/// Mark `id` as eligible for one structured-session recovery attempt.
|
||||
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
std::fs::write(self.recovery_path_for(id), [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume the graceful-shutdown recovery marker for `id`.
|
||||
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
|
||||
match std::fs::remove_file(self.recovery_path_for(id)) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the event log for `id` for appending, creating it (and the
|
||||
/// directory) when missing. One handle per live session: the caller
|
||||
/// keeps it for the session's lifetime rather than reopening per
|
||||
/// notification.
|
||||
///
|
||||
/// A log whose last line has no newline — what a hard kill mid-write
|
||||
/// leaves behind — is terminated first, so the next append starts a
|
||||
/// fresh line instead of gluing onto the fragment. The fragment keeps
|
||||
/// the seq of its line and reads as malformed.
|
||||
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(self.events_path_for(id))?;
|
||||
let mut lines = 0u64;
|
||||
let mut last = None;
|
||||
let mut reader = BufReader::new(&mut file);
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
|
||||
last = Some(buf[n - 1]);
|
||||
}
|
||||
if last.is_some_and(|b| b != b'\n') {
|
||||
file.write_all(b"\n")?;
|
||||
lines += 1;
|
||||
}
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
Ok(EventLog {
|
||||
file: Box::new(file),
|
||||
last_seq: lines,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read events from the log for `id`, at most `limit`, in ascending seq
|
||||
/// order. A missing log is an empty log. Lines that fail to parse are
|
||||
/// skipped but still consume a seq, so cursors stay stable.
|
||||
pub fn read_events(
|
||||
&self,
|
||||
id: Uuid,
|
||||
cursor: EventCursor,
|
||||
limit: usize,
|
||||
) -> Result<Vec<LoggedEvent>, SidecarError> {
|
||||
let file = match File::open(self.events_path_for(id)) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
|
||||
let mut seq = 0u64;
|
||||
for line in BufReader::new(file).split(b'\n') {
|
||||
let line = line?;
|
||||
seq += 1;
|
||||
let keep = match cursor {
|
||||
EventCursor::Since(since) => seq > since,
|
||||
EventCursor::Before(Some(before)) => seq < before,
|
||||
EventCursor::Before(None) => true,
|
||||
};
|
||||
if !keep {
|
||||
if matches!(cursor, EventCursor::Before(Some(_))) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Ok(event) = PersistedEvent::decode(&line) else {
|
||||
continue;
|
||||
};
|
||||
out.push_back(LoggedEvent { seq, event });
|
||||
match cursor {
|
||||
EventCursor::Since(_) if out.len() >= limit => break,
|
||||
EventCursor::Before(_) if out.len() > limit => {
|
||||
out.pop_front();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out.into())
|
||||
}
|
||||
|
||||
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
|
||||
/// if it does not exist.
|
||||
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
|
||||
@@ -59,13 +186,13 @@ impl SidecarStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the sidecar for `id`. No-op if it does not exist.
|
||||
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
|
||||
/// whichever does not exist.
|
||||
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||
match std::fs::remove_file(self.path_for(id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
remove_if_present(&self.events_path_for(id))?;
|
||||
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
|
||||
remove_if_present(&self.recovery_path_for(id))?;
|
||||
remove_if_present(&self.path_for(id))
|
||||
}
|
||||
|
||||
/// Read every sidecar JSON in the directory. Files that fail to parse are
|
||||
@@ -81,6 +208,8 @@ impl SidecarStore {
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
// `.events.jsonl` logs (#135) sit in the same directory and are
|
||||
// never sidecars.
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
@@ -95,3 +224,425 @@ impl SidecarStore {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which slice of a session's event log to read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EventCursor {
|
||||
/// Forward: events with `seq` strictly greater than the cursor, the
|
||||
/// first `limit` of them. `Since(0)` is the whole log from the start.
|
||||
/// What a client catching up after a reconnect asks for.
|
||||
Since(u64),
|
||||
/// Backward: events with `seq` strictly less than the cursor, the last
|
||||
/// `limit` of them, still returned in ascending order. `None` means the
|
||||
/// tail of the log, for a first paint. The next page passes the smallest
|
||||
/// seq it received.
|
||||
Before(Option<u64>),
|
||||
}
|
||||
|
||||
/// One line of a session's event log.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoggedEvent {
|
||||
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
||||
pub seq: u64,
|
||||
pub event: PersistedEvent,
|
||||
}
|
||||
|
||||
/// A single entry in the structured event history.
|
||||
///
|
||||
/// ACP notifications retain their original JSON representation, so existing
|
||||
/// event logs remain readable. Daemon-generated rows use an envelope so they
|
||||
/// can share the same cursor sequence without pretending to be ACP traffic.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
|
||||
pub enum PersistedEvent {
|
||||
User {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Approval {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Turn {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
#[serde(skip)]
|
||||
Notification(Box<SessionNotification>),
|
||||
}
|
||||
|
||||
impl PersistedEvent {
|
||||
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice::<SessionNotification>(line)
|
||||
.map(|notification| Self::Notification(Box::new(notification)))
|
||||
.or_else(|_| serde_json::from_slice(line))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionNotification> for PersistedEvent {
|
||||
fn from(notification: SessionNotification) -> Self {
|
||||
Self::Notification(Box::new(notification))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SessionNotification> for PersistedEvent {
|
||||
fn from(notification: &SessionNotification) -> Self {
|
||||
Self::Notification(Box::new(notification.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PersistedEvent> for PersistedEvent {
|
||||
fn from(event: &PersistedEvent) -> Self {
|
||||
event.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// What an [`EventLog`] appends onto: something seekable by length and
|
||||
/// truncatable back to one, so a failed append can be rolled off the end
|
||||
/// again. A real [`File`] in production; an in-memory fault double in tests
|
||||
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
|
||||
/// the behavior under test is the production behavior, not a stand-in for
|
||||
/// it.
|
||||
pub(crate) trait LogSink: Write + Send {
|
||||
fn len(&mut self) -> std::io::Result<u64>;
|
||||
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
|
||||
}
|
||||
|
||||
impl LogSink for File {
|
||||
fn len(&mut self) -> std::io::Result<u64> {
|
||||
Ok(self.metadata()?.len())
|
||||
}
|
||||
|
||||
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||
self.set_len(len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a failed [`EventLog::append`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppendError {
|
||||
/// The write or flush failed, but the log was rolled back to exactly the
|
||||
/// byte it was at before the call: `last_seq` and the file agree, and
|
||||
/// this same [`EventLog`] is safe to keep appending to. The event itself
|
||||
/// was never persisted or broadcast.
|
||||
#[error("event append failed, rolled back cleanly: {0}")]
|
||||
Failed(#[source] SidecarError),
|
||||
/// The append failed *and* the rollback did not restore the log to its
|
||||
/// prior length, so the file's line count and `last_seq` may now
|
||||
/// disagree. The caller must not append to this [`EventLog`] again —
|
||||
/// treat the session as dead rather than promise consistency it cannot
|
||||
/// keep.
|
||||
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
|
||||
Fatal {
|
||||
cause: SidecarError,
|
||||
rollback: SidecarError,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<SidecarError> for AppendError {
|
||||
fn from(error: SidecarError) -> Self {
|
||||
AppendError::Failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// An open, append-only handle on one session's event log. Obtained from
|
||||
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
||||
pub struct EventLog {
|
||||
file: Box<dyn LogSink>,
|
||||
last_seq: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventLog {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EventLog")
|
||||
.field("last_seq", &self.last_seq)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
|
||||
/// inject a deterministic append failure (#184) — a partial write, a
|
||||
/// flush that fails after the bytes are already down, or a rollback that
|
||||
/// cannot itself complete.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
|
||||
Self {
|
||||
file: Box::new(file),
|
||||
last_seq,
|
||||
}
|
||||
}
|
||||
|
||||
/// The seq of the last line, `0` for an empty log.
|
||||
pub fn last_seq(&self) -> u64 {
|
||||
self.last_seq
|
||||
}
|
||||
|
||||
/// Append one notification as a line and return its seq. The line is
|
||||
/// handed to the kernel in a single write before this returns, so a
|
||||
/// caller that broadcasts afterwards never announces an event the log
|
||||
/// would not replay — short of the machine losing power.
|
||||
///
|
||||
/// A `write_all` that errors partway can leave an unterminated prefix on
|
||||
/// disk; a `flush` that errors after a *complete* `write_all` leaves
|
||||
/// ambiguity about whether that full, well-formed line should count —
|
||||
/// counting it would silently shift every later seq by one, since
|
||||
/// `last_seq` was never bumped for it. Both cases are rolled back to the
|
||||
/// exact pre-append length before returning, so `last_seq` and the file
|
||||
/// never disagree and the same [`EventLog`] can be appended to again
|
||||
/// immediately. If the rollback itself fails, the log's consistency can
|
||||
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
|
||||
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
|
||||
let event = event.into();
|
||||
let mut line = match &event {
|
||||
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
|
||||
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
|
||||
_ => {
|
||||
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
|
||||
}
|
||||
};
|
||||
line.push(b'\n');
|
||||
|
||||
let pre_len = self
|
||||
.file
|
||||
.len()
|
||||
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
|
||||
|
||||
if let Err(cause) = self.file.write_all(&line) {
|
||||
return Err(self.rollback_after(pre_len, cause));
|
||||
}
|
||||
if let Err(cause) = self.file.flush() {
|
||||
return Err(self.rollback_after(pre_len, cause));
|
||||
}
|
||||
|
||||
self.last_seq += 1;
|
||||
Ok(self.last_seq)
|
||||
}
|
||||
|
||||
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
|
||||
match self.file.truncate(pre_len) {
|
||||
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
|
||||
Err(rollback) => AppendError::Fatal {
|
||||
cause: SidecarError::Io(cause),
|
||||
rollback: SidecarError::Io(rollback),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
|
||||
/// the test that planted it, so the test can inspect what actually ended up
|
||||
/// on the "disk" after a fault without reaching into `EventLog`'s private
|
||||
/// `file` field.
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
#[cfg(test)]
|
||||
impl SharedBuf {
|
||||
pub(crate) fn snapshot(&self) -> Vec<u8> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
|
||||
/// short write, a flush that fails after a full write, or a rollback that
|
||||
/// cannot itself complete — then behaves like a healthy sink from then on.
|
||||
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
|
||||
/// handle swapped in from a healthy disk, which production never does
|
||||
/// (#184).
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FaultyLogSink {
|
||||
pub(crate) buf: SharedBuf,
|
||||
/// Accept this many bytes on the next `write` call, then fail the
|
||||
/// *following* call — a short write followed by an error, exactly what
|
||||
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
|
||||
/// must not report an error on a call that made partial progress, so
|
||||
/// the fault is delivered one call later, via `pending_write_error`.
|
||||
pub(crate) fail_write_after: Option<usize>,
|
||||
pub(crate) pending_write_error: bool,
|
||||
pub(crate) fail_flush_once: bool,
|
||||
pub(crate) fail_truncate: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Write for FaultyLogSink {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
if self.pending_write_error {
|
||||
self.pending_write_error = false;
|
||||
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||
}
|
||||
if let Some(allowed) = self.fail_write_after.take() {
|
||||
let allowed = allowed.min(data.len());
|
||||
if allowed == 0 {
|
||||
// No progress was made, so it's fine to report the error
|
||||
// from this same call.
|
||||
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||
}
|
||||
self.buf
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend_from_slice(&data[..allowed]);
|
||||
self.pending_write_error = true;
|
||||
return Ok(allowed);
|
||||
}
|
||||
self.buf.0.lock().unwrap().extend_from_slice(data);
|
||||
Ok(data.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if self.fail_flush_once {
|
||||
self.fail_flush_once = false;
|
||||
return Err(std::io::Error::other(
|
||||
"fault: flush failed after a full write",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LogSink for FaultyLogSink {
|
||||
fn len(&mut self) -> std::io::Result<u64> {
|
||||
Ok(self.buf.0.lock().unwrap().len() as u64)
|
||||
}
|
||||
|
||||
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||
if self.fail_truncate {
|
||||
return Err(std::io::Error::other("fault: rollback truncate failed"));
|
||||
}
|
||||
self.buf.0.lock().unwrap().truncate(len as usize);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_event(id: &str) -> PersistedEvent {
|
||||
PersistedEvent::Approval {
|
||||
row: TranscriptRow {
|
||||
id: id.to_owned(),
|
||||
kind: maestro_protocol::TranscriptRowKind::Approval,
|
||||
data: serde_json::json!({"tool": "bash"}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays `bytes` as a real on-disk log via the production read path,
|
||||
/// so "live and durable agree" is checked with the same code a
|
||||
/// reconnecting client would hit, not a hand-rolled line splitter.
|
||||
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
std::fs::create_dir_all(store.dir()).unwrap();
|
||||
std::fs::write(store.events_path_for(id), bytes).unwrap();
|
||||
store.read_events(id, EventCursor::Since(0), 100).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
|
||||
let buf = SharedBuf::default();
|
||||
let writer = FaultyLogSink {
|
||||
buf: buf.clone(),
|
||||
fail_write_after: Some(5),
|
||||
..Default::default()
|
||||
};
|
||||
let mut log = EventLog::for_test(writer, 0);
|
||||
|
||||
let err = log.append(sample_event("r1")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppendError::Failed(_)),
|
||||
"a repairable rollback must not be reported fatal"
|
||||
);
|
||||
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
|
||||
assert!(
|
||||
buf.snapshot().is_empty(),
|
||||
"the partial prefix a failed write_all leaves behind must be truncated away"
|
||||
);
|
||||
|
||||
let seq = log
|
||||
.append(sample_event("r1"))
|
||||
.expect("the same instance must be able to append again after a clean rollback");
|
||||
assert_eq!(seq, 1);
|
||||
assert_eq!(log.last_seq(), 1);
|
||||
|
||||
let bytes = buf.snapshot();
|
||||
assert_eq!(
|
||||
bytes.iter().filter(|&&b| b == b'\n').count(),
|
||||
1,
|
||||
"exactly one well-formed line, not a partial prefix glued to the recovered append"
|
||||
);
|
||||
|
||||
let replayed = replay(&bytes);
|
||||
assert_eq!(replayed.len(), 1);
|
||||
assert_eq!(replayed[0].seq, log.last_seq());
|
||||
let PersistedEvent::Approval { row } = &replayed[0].event else {
|
||||
panic!("approval event");
|
||||
};
|
||||
assert_eq!(row.id, "r1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_rolls_back_when_flush_fails_after_a_full_write() {
|
||||
let buf = SharedBuf::default();
|
||||
let writer = FaultyLogSink {
|
||||
buf: buf.clone(),
|
||||
fail_flush_once: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut log = EventLog::for_test(writer, 0);
|
||||
|
||||
let err = log.append(sample_event("r1")).unwrap_err();
|
||||
assert!(matches!(err, AppendError::Failed(_)));
|
||||
assert_eq!(log.last_seq(), 0);
|
||||
assert!(
|
||||
buf.snapshot().is_empty(),
|
||||
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
|
||||
silently shift every later seq by one"
|
||||
);
|
||||
|
||||
let seq = log
|
||||
.append(sample_event("r1"))
|
||||
.expect("the same instance must recover once flush stops failing");
|
||||
assert_eq!(seq, 1);
|
||||
|
||||
let bytes = buf.snapshot();
|
||||
let replayed = replay(&bytes);
|
||||
assert_eq!(replayed.len(), 1);
|
||||
assert_eq!(replayed[0].seq, log.last_seq());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
|
||||
let buf = SharedBuf::default();
|
||||
let writer = FaultyLogSink {
|
||||
buf,
|
||||
fail_write_after: Some(3),
|
||||
fail_truncate: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut log = EventLog::for_test(writer, 0);
|
||||
|
||||
let err = log.append(sample_event("r1")).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppendError::Fatal { .. }),
|
||||
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
|
||||
a clean recoverable failure"
|
||||
);
|
||||
assert_eq!(
|
||||
log.last_seq(),
|
||||
0,
|
||||
"a fatal append must not advance the cursor either"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
let (branch, path) = if create_branch {
|
||||
// Worktrees are commonly abandoned without deleting their branch,
|
||||
// so a random handle can collide with one of its own past runs.
|
||||
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||
// branch name itself — the path is derived from it anyway.
|
||||
let mut candidate = branch.to_string();
|
||||
let mut path = self.target_path(base_repo, &candidate);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
|
||||
n += 1;
|
||||
candidate = format!("{branch}-{n}");
|
||||
path = self.target_path(base_repo, &candidate);
|
||||
}
|
||||
(candidate, path)
|
||||
} else {
|
||||
// Existing branch: the name must stay exact, so only the
|
||||
// directory — anything occupying it, ours or the operator's — is
|
||||
// sidestepped with a numeric suffix (#77). The daemon never
|
||||
// adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
(branch.to_string(), path)
|
||||
};
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||
if create_branch {
|
||||
cmd.arg("-b").arg(branch).arg(&path);
|
||||
cmd.arg("-b").arg(&branch).arg(&path);
|
||||
} else {
|
||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||
cmd.arg(&path).arg(branch);
|
||||
cmd.arg(&path).arg(&branch);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
check(out)?;
|
||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
|
||||
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["show-ref", "--verify", "--quiet"])
|
||||
.arg(format!("refs/heads/{branch}"))
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,11 +42,13 @@ async fn fixture(ttl: Duration) -> Fixture {
|
||||
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -75,11 +75,13 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
let s = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd_path,
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
sidecar: SidecarStore,
|
||||
_tmux: TmuxFixture,
|
||||
tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
||||
Fixture {
|
||||
manager,
|
||||
sidecar,
|
||||
_tmux: tmux,
|
||||
tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,13 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
};
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -169,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use tempfile::TempDir;
|
||||
|
||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -28,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains(r#"id="app""#),
|
||||
html.contains(r#"id="root""#),
|
||||
"served index is missing the SPA mount point; got:\n{html}"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("maestro is running"),
|
||||
"served index is the placeholder, not the built bundle"
|
||||
);
|
||||
// Stronger signal than `id="app"` (which appears in both source and built
|
||||
// Stronger signal than `id="root"` (which appears in both source and built
|
||||
// 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
|
||||
// both an empty/placeholder embed and the staleness case.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
|
||||
@@ -4,12 +4,17 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
||||
WorktreeSpec,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -86,6 +91,640 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
|
||||
format!("ws://{addr}{path}")
|
||||
}
|
||||
|
||||
struct FakeAcp {
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl FakeAcp {
|
||||
fn new() -> Self {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let adapter = bin.join("claude-code-acp");
|
||||
std::fs::copy(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
|
||||
&adapter,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn env(&self) -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
(
|
||||
"PATH".into(),
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.dir.path().join("bin").display(),
|
||||
std::env::var("PATH").unwrap()
|
||||
),
|
||||
),
|
||||
(
|
||||
"FAKE_ACP_LOG".into(),
|
||||
self.dir.path().join("agent.log").display().to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn pid(&self) -> u32 {
|
||||
std::fs::read_to_string(self.dir.path().join("agent.log"))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.strip_prefix("start pid=")?
|
||||
.split(' ')
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
.next_back()
|
||||
.expect("fake ACP started")
|
||||
}
|
||||
|
||||
fn log(&self) -> String {
|
||||
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||
structured_session_with_env(h, fake.env()).await
|
||||
}
|
||||
|
||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||
let id = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: "/tmp".into(),
|
||||
command: "claude".into(),
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
if sessions
|
||||
.iter()
|
||||
.find(|session| session.id == id)
|
||||
.is_some_and(|session| session.activity == expected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
|
||||
"transcript_snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
|
||||
"reduced_state"
|
||||
);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "hello"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
||||
.await
|
||||
.expect("projection update arrives on the subscribed stream")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_text()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
|
||||
"projection_update"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
||||
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 fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let client = reqwest::Client::new();
|
||||
let first = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "first"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
// A second prompt while a turn is active is accepted into the queue.
|
||||
let second = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "second"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
|
||||
|
||||
let cancel = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/cancel"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
// After cancel + settlement, the queued "second" prompt drains. Wait for
|
||||
// both the first and the drained prompt to reach ACP.
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("first and drained queued prompt both reach ACP");
|
||||
let log = fake.log();
|
||||
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||
let cancel = log.find("recv session/cancel").unwrap();
|
||||
let second_prompt = log.rfind("recv session/prompt").unwrap();
|
||||
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_approval_is_secret_single_use_and_race_safe() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let client = reqwest::Client::new();
|
||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
ws.next().await.unwrap().unwrap();
|
||||
ws.next().await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt")
|
||||
))
|
||||
.json(&serde_json::json!({"prompt":"needs permission"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let event: serde_json::Value =
|
||||
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||
.unwrap();
|
||||
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
|
||||
assert_eq!(event["transcript"]["row"]["kind"], "approval");
|
||||
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
|
||||
break nonce.to_owned();
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
|
||||
assert!(
|
||||
!fake.log().contains(&nonce),
|
||||
"daemon nonce leaked to ACP agent"
|
||||
);
|
||||
|
||||
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
|
||||
let (a, b) = tokio::join!(
|
||||
client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!("allow_once"))
|
||||
.send(),
|
||||
client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!("deny"))
|
||||
.send(),
|
||||
);
|
||||
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert_eq!(
|
||||
client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!("deny"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if fake.log().contains("permission response") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let log = fake.log();
|
||||
assert!(
|
||||
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
|
||||
"{log}"
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let event: serde_json::Value =
|
||||
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||
.unwrap();
|
||||
if event["state"]["pending_approval"].is_null() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("resolved approval clears the reduced state");
|
||||
wait_for_session_activity(&h, id, ActivityState::Idle).await;
|
||||
assert_eq!(
|
||||
client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!(
|
||||
"/api/sessions/{}/structured/approvals/{nonce}",
|
||||
Uuid::new_v4()
|
||||
)
|
||||
))
|
||||
.json(&serde_json::json!("deny"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
reqwest::StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_failure_clears_the_turn() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let client = reqwest::Client::new();
|
||||
let path = format!("/api/sessions/{id}/structured/prompt");
|
||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
ws.next().await.unwrap().unwrap();
|
||||
ws.next().await.unwrap().unwrap();
|
||||
let first = client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!({"prompt":"fails"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
let mut saw_active_turn = false;
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while let Some(Ok(message)) = ws.next().await {
|
||||
let event: serde_json::Value =
|
||||
serde_json::from_str(&message.into_text().unwrap()).unwrap();
|
||||
let turn = &event["state"]["turn"];
|
||||
if turn["responding"] == true {
|
||||
saw_active_turn = true;
|
||||
}
|
||||
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("structured websocket closed before the failed turn cleared");
|
||||
})
|
||||
.await
|
||||
.expect("failed turn clears promptly");
|
||||
wait_for_session_activity(&h, id, ActivityState::Error).await;
|
||||
let second = client
|
||||
.post(url(h.addr, &path))
|
||||
.json(&serde_json::json!({"prompt":"available"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(second.bytes().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let client = reqwest::Client::new();
|
||||
let prompt = |id| {
|
||||
client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "hello"}))
|
||||
};
|
||||
|
||||
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
|
||||
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
let error: ApiError = unknown.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_found");
|
||||
|
||||
let terminal = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Terminal,
|
||||
cwd: "/tmp".into(),
|
||||
command: "sh".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let non_structured = prompt(terminal.id).send().await.unwrap();
|
||||
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = non_structured.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_structured");
|
||||
|
||||
let fake = FakeAcp::new();
|
||||
let dead = structured_session(&h, &fake).await;
|
||||
h.manager.shutdown().await;
|
||||
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
||||
let unavailable = prompt(dead).send().await.unwrap();
|
||||
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = unavailable.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_structured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_honors_cursors_and_limit() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let events: Vec<serde_json::Value> = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
if !events.is_empty() {
|
||||
return events;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["cursor"], 1);
|
||||
assert!(events[0].get("notification").is_none());
|
||||
|
||||
let invalid = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_pages_projected_events_in_cursor_order() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let client = reqwest::Client::new();
|
||||
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
|
||||
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let events: Vec<serde_json::Value> = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
if events.len() == 2 {
|
||||
return events;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event["cursor"].as_u64().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let boundary: Vec<serde_json::Value> = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(boundary.len(), 1);
|
||||
assert_eq!(boundary[0]["cursor"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let client = reqwest::Client::new();
|
||||
let unknown = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
let terminal = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Terminal,
|
||||
cwd: "/tmp".into(),
|
||||
command: "sh".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let response = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/replay", terminal.id),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
|
||||
let fake = FakeAcp::new();
|
||||
let dead = structured_session(&h, &fake).await;
|
||||
h.manager.shutdown().await;
|
||||
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
||||
let response = client
|
||||
.get(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{dead}/structured/replay"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = response.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_structured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_ws_rejects_a_dead_session() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
h.manager.shutdown().await;
|
||||
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
|
||||
|
||||
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||
.await
|
||||
.expect_err("dead structured session must not upgrade");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("409") || message.to_lowercase().contains("conflict"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn info_returns_daemon_identity() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -174,11 +813,13 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
let created: Session = reqwest::Client::new()
|
||||
.post(url(h.addr, "/api/sessions"))
|
||||
@@ -353,7 +994,9 @@ async fn names_honors_count_and_caps_it() {
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
||||
// 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 tmp = tempfile::tempdir().unwrap();
|
||||
let resp: serde_json::Value = reqwest::get(url(
|
||||
@@ -368,7 +1011,16 @@ async fn gitroot_probes_path() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
||||
// 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]
|
||||
@@ -390,11 +1042,13 @@ async fn patch_session_renames_it() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -432,11 +1086,13 @@ async fn post_focus_stamps_last_focused_at() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -465,11 +1121,13 @@ async fn delete_owned_session_kills_it() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -486,11 +1144,13 @@ async fn delete_owned_session_kills_it() {
|
||||
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -595,6 +1255,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -604,6 +1265,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -647,6 +1309,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -656,6 +1319,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -680,11 +1344,13 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -715,6 +1381,39 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||
.json(&maestro_protocol::SendInputRequest {
|
||||
text: "ignored".into(),
|
||||
submit: false,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = response.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
|
||||
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||
.await
|
||||
.expect_err("structured session must not upgrade to a PTY socket");
|
||||
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||
panic!("expected an HTTP handshake rejection, got {error}");
|
||||
};
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body = response.body().as_deref().expect("ApiError response body");
|
||||
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_for_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -748,11 +1447,13 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
let created = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -780,6 +1481,79 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
||||
let h = spawn("konishi").await;
|
||||
let fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
|
||||
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
||||
.await
|
||||
.expect("events ws");
|
||||
assert!(matches!(
|
||||
next_event(&mut ws).await,
|
||||
maestro_protocol::CatalogEvent::Snapshot { .. }
|
||||
));
|
||||
|
||||
let id = structured_session_with_env(&h, env).await;
|
||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||
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);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let prompt = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "hello"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
let report = client
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({"state": "Error"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
wait_for_session_activity(&h, id, ActivityState::Active).await;
|
||||
|
||||
let mut activities = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
|
||||
next_event(&mut ws).await
|
||||
{
|
||||
activities.push(session.activity);
|
||||
if session.activity == ActivityState::Idle {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("structured turn returns to idle");
|
||||
assert!(
|
||||
activities.contains(&ActivityState::Active),
|
||||
"{activities:?}"
|
||||
);
|
||||
assert!(
|
||||
!activities.contains(&ActivityState::Error),
|
||||
"{activities:?}"
|
||||
);
|
||||
|
||||
h.manager.kill(id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
||||
/// two clients connected at different times both get their own Snapshot.
|
||||
#[tokio::test]
|
||||
@@ -792,11 +1566,13 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
||||
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -51,11 +51,13 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
};
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -64,11 +64,13 @@ impl Fx {
|
||||
|
||||
fn request(&self, command: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: self.tmp.path().to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,13 @@ async fn create_then_kill_round_trip() {
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -63,11 +65,13 @@ async fn create_appends_to_spawn_history() {
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -92,11 +96,13 @@ async fn create_without_history_logs_nothing() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -114,11 +120,13 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -153,6 +161,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// The shell prints the marker before doing anything else and
|
||||
// then sleeps so the session stays alive. Before NAP-253 this
|
||||
@@ -161,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -200,6 +210,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Print a line, then switch to the alternate screen the way a
|
||||
// full-screen program does on startup. Only the second marker is
|
||||
@@ -209,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -258,6 +270,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||
@@ -266,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -311,11 +325,13 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -373,11 +389,13 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -423,11 +441,13 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -488,6 +508,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -497,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -580,6 +602,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -589,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
branch: "feat/keep".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -617,6 +641,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -626,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -680,6 +706,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -689,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
branch: "feat/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -729,6 +757,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -738,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -774,6 +804,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -783,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
branch: "feat/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -814,6 +846,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -823,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
branch: "feat/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -855,6 +889,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -864,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -916,6 +952,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -925,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
branch: "feat/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -976,11 +1014,13 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1010,6 +1050,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
let first = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -1019,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/one".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create first");
|
||||
@@ -1027,6 +1069,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
let second = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: inner_worktree.clone(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -1036,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/two".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create second");
|
||||
@@ -1065,11 +1109,13 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1103,11 +1149,13 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1128,11 +1176,13 @@ async fn set_focused_true_stamps_last_focused_at() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1176,11 +1226,13 @@ async fn attaching_stamps_last_focused_at() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
|
||||
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
@@ -32,6 +32,10 @@ fn sample(id: Uuid) -> Session {
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +70,30 @@ fn delete_removes_file_and_is_idempotent() {
|
||||
let s = sample(id);
|
||||
|
||||
store.write(&s).unwrap();
|
||||
store.mark_for_recovery(id).unwrap();
|
||||
assert!(store.path_for(id).exists());
|
||||
assert!(store.recovery_path_for(id).exists());
|
||||
|
||||
store.delete(id).unwrap();
|
||||
assert!(!store.path_for(id).exists());
|
||||
assert!(!store.recovery_path_for(id).exists());
|
||||
|
||||
// second delete is a no-op
|
||||
store.delete(id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_marker_is_consumed_once() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
assert!(!store.take_recovery_marker(id).unwrap());
|
||||
store.mark_for_recovery(id).unwrap();
|
||||
assert!(store.take_recovery_marker(id).unwrap());
|
||||
assert!(!store.take_recovery_marker(id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -103,6 +122,37 @@ fn load_all_returns_empty_when_directory_missing() {
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_missing_view_key_reads_as_terminal() {
|
||||
// A pre-#133 sidecar on disk has no `view` field at all.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
let mut value = serde_json::to_value(sample(id)).unwrap();
|
||||
value.as_object_mut().unwrap().remove("view");
|
||||
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
|
||||
let loaded = store.read(id).expect("read").expect("present");
|
||||
assert_eq!(loaded.view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_structured_view_round_trips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let mut s = sample(id);
|
||||
s.view = SessionView::Structured;
|
||||
|
||||
store.write(&s).expect("write");
|
||||
let loaded = store.read(id).expect("read");
|
||||
assert_eq!(
|
||||
loaded.as_ref().map(|s| s.view),
|
||||
Some(SessionView::Structured)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -113,3 +163,237 @@ fn write_creates_parent_directory() {
|
||||
assert!(nested.exists());
|
||||
assert!(store.path_for(s.id).exists());
|
||||
}
|
||||
|
||||
// ---- per-session event log (#135) ----
|
||||
|
||||
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
|
||||
use maestro_daemon::sidecar::EventCursor;
|
||||
|
||||
fn note(text: &str) -> SessionNotification {
|
||||
SessionNotification::new(
|
||||
"acp-1",
|
||||
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
|
||||
ContentBlock::Text(TextContent::new(text)),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn text_of(n: &SessionNotification) -> &str {
|
||||
match &n.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||
ContentBlock::Text(t) => &t.text,
|
||||
other => panic!("unexpected content {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected update {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||
panic!("expected ACP notification")
|
||||
};
|
||||
text_of(notification)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_assigns_line_position_as_seq_and_reads_back() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
let mut log = store.open_events(id).expect("open");
|
||||
assert_eq!(log.last_seq(), 0);
|
||||
assert_eq!(log.append(note("a")).unwrap(), 1);
|
||||
assert_eq!(log.append(note("b")).unwrap(), 2);
|
||||
assert_eq!(log.append(note("c")).unwrap(), 3);
|
||||
assert_eq!(log.last_seq(), 3);
|
||||
|
||||
let path = store.events_path_for(id);
|
||||
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
|
||||
for line in raw.lines() {
|
||||
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
|
||||
assert_eq!(v["sessionId"], "acp-1");
|
||||
}
|
||||
|
||||
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
||||
assert_eq!(
|
||||
all.iter().map(event_text).collect::<Vec<_>>(),
|
||||
vec!["a", "b", "c"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_reads_forward_and_before_reads_backward_with_limit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
for i in 1..=6 {
|
||||
log.append(note(&format!("n{i}"))).unwrap();
|
||||
}
|
||||
|
||||
// `since` is exclusive: what a client that already holds seq 2 needs.
|
||||
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
|
||||
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
|
||||
// Past the end: nothing, not an error.
|
||||
assert!(store
|
||||
.read_events(id, EventCursor::Since(6), 3)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
// `before` is exclusive and pages backward: the `limit` events closest
|
||||
// to the cursor, still in ascending seq order.
|
||||
let back = store
|
||||
.read_events(id, EventCursor::Before(Some(5)), 2)
|
||||
.unwrap();
|
||||
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
|
||||
// No cursor: the tail of the log, for a first paint.
|
||||
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
|
||||
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
|
||||
// Paging past the start runs out cleanly.
|
||||
let start = store
|
||||
.read_events(id, EventCursor::Before(Some(2)), 10)
|
||||
.unwrap();
|
||||
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
|
||||
assert!(store
|
||||
.read_events(id, EventCursor::Before(Some(1)), 10)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_log_reads_as_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
assert!(store
|
||||
.read_events(id, EventCursor::Since(0), 10)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(store
|
||||
.read_events(id, EventCursor::Before(None), 10)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lines_are_skipped_but_keep_their_seq() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
log.append(note("first")).unwrap();
|
||||
drop(log);
|
||||
|
||||
// Garbage in the middle and a line that is JSON but not a notification.
|
||||
let path = store.events_path_for(id);
|
||||
let mut raw = std::fs::read_to_string(&path).unwrap();
|
||||
raw.push_str("{not json\n");
|
||||
raw.push_str("{\"unrelated\": true}\n");
|
||||
std::fs::write(&path, raw).unwrap();
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
assert_eq!(log.last_seq(), 3);
|
||||
assert_eq!(log.append(note("fourth")).unwrap(), 4);
|
||||
|
||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
||||
assert_eq!(event_text(&all[1]), "fourth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
||||
// A hard kill mid-write leaves a partial line with no newline.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
log.append(note("whole")).unwrap();
|
||||
drop(log);
|
||||
|
||||
let path = store.events_path_for(id);
|
||||
let mut raw = std::fs::read(&path).unwrap();
|
||||
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
|
||||
std::fs::write(&path, &raw).unwrap();
|
||||
|
||||
// Reads: the partial line is skipped, nothing else is lost.
|
||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
|
||||
|
||||
// Reopening terminates the partial line so the next append does not
|
||||
// glue onto it; the partial keeps a seq of its own.
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
assert_eq!(log.last_seq(), 2);
|
||||
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
||||
assert_eq!(event_text(&all[1]), "after");
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(raw.lines().count(), 3, "{raw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
store.write(&sample(id)).unwrap();
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
log.append(note("x")).unwrap();
|
||||
drop(log);
|
||||
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
|
||||
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
|
||||
assert!(store.events_path_for(id).exists());
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
|
||||
|
||||
store.delete(id).unwrap();
|
||||
assert!(!store.path_for(id).exists());
|
||||
assert!(!store.events_path_for(id).exists());
|
||||
assert!(!legacy_approvals.exists());
|
||||
// Idempotent with the log already gone.
|
||||
store.delete(id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let legacy = serde_json::to_vec(¬e("before")).unwrap();
|
||||
std::fs::create_dir_all(dir.path()).unwrap();
|
||||
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
|
||||
|
||||
let mut log = store.open_events(id).unwrap();
|
||||
assert_eq!(
|
||||
log.append(PersistedEvent::Approval {
|
||||
row: TranscriptRow {
|
||||
id: "approval:secret".into(),
|
||||
kind: TranscriptRowKind::Approval,
|
||||
data: serde_json::json!({"tool": "bash"}),
|
||||
},
|
||||
})
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||
|
||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||
assert_eq!(
|
||||
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
|
||||
assert_eq!(event_text(&all[0]), "before");
|
||||
assert_eq!(event_text(&all[2]), "after");
|
||||
|
||||
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
|
||||
assert_eq!(since[0].seq, 2);
|
||||
let before = store
|
||||
.read_events(id, EventCursor::Before(Some(3)), 1)
|
||||
.unwrap();
|
||||
assert_eq!(before[0].seq, 2);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user