Compare commits
285
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bdfbe42 | ||
|
|
27b6e647c1 | ||
|
|
0631e69ef0 | ||
|
|
f98c637cd9 | ||
|
|
554182d956 | ||
|
|
391efc72d2 | ||
|
|
cf582be306 | ||
|
|
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 | ||
|
|
3cb4bebd1e | ||
|
|
0e20baf7c2 | ||
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
@@ -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
+4
@@ -1467,6 +1467,7 @@ dependencies = [
|
||||
"maestro-cli",
|
||||
"maestro-daemon",
|
||||
"maestro-tui",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -1477,6 +1478,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1558,6 +1560,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"maestro-tui",
|
||||
@@ -1643,6 +1646,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"tracing",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
+2
-1
@@ -10,7 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
agent-client-protocol = "2.1"
|
||||
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"] }
|
||||
@@ -44,6 +44,7 @@ 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,85 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pt.naps.maestro"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "pt.naps.maestro"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
getByName("debug") {
|
||||
storeFile = file("debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.turbine)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
Binary file not shown.
Vendored
+6
@@ -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,27 @@
|
||||
<?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:windowSoftInputMode="adjustResize"
|
||||
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,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<link rel="stylesheet" href="vendor/xterm.css" />
|
||||
<style>
|
||||
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
|
||||
.xterm { padding: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script src="vendor/xterm.js"></script>
|
||||
<script src="vendor/addon-fit.js"></script>
|
||||
<script>
|
||||
// Owns the `WS /api/attach/:id` connection directly, the same shape as
|
||||
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
|
||||
// frames both ways, `ClientControl` JSON in text frames. The native
|
||||
// Android side (TerminalWebView.kt) never touches the socket; it only
|
||||
// calls the maestro* bridge functions below for the extra key bar.
|
||||
var params = new URLSearchParams(location.search);
|
||||
var wsUrl = params.get("ws");
|
||||
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
convertEol: false,
|
||||
scrollback: 2000,
|
||||
theme: {
|
||||
background: "#121419",
|
||||
foreground: "#e6e8ec",
|
||||
cursor: "#5fe3a1",
|
||||
cursorAccent: "#121419",
|
||||
selectionBackground: "#2b6b4f",
|
||||
},
|
||||
});
|
||||
var fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
var container = document.getElementById("terminal");
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
var ws = null;
|
||||
var awaitingReplay = true;
|
||||
var armed = { ctrl: false, alt: false };
|
||||
|
||||
function sendControl(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function sendInput(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = function () {
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
ws.onmessage = function (ev) {
|
||||
if (typeof ev.data === "string") return; // control frame, nothing to render
|
||||
var replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), function () {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = function (ev) {
|
||||
if (ev.code === 1000) return;
|
||||
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
||||
};
|
||||
}
|
||||
|
||||
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
|
||||
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
|
||||
function applyModsToChar(ch, ctrl, alt) {
|
||||
var out = ch;
|
||||
if (ctrl) {
|
||||
var code = ch.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
|
||||
}
|
||||
if (alt) out = "\x1b" + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
// CSI cursor sequence with the modifier parameter xterm expects:
|
||||
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
|
||||
function cursorSeq(final, ctrl, alt) {
|
||||
if (!ctrl && !alt) return "\x1b[" + final;
|
||||
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
return "\x1b[1;" + mod + final;
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||||
var hadCtrl = armed.ctrl;
|
||||
var hadAlt = armed.alt;
|
||||
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
|
||||
armed.ctrl = false;
|
||||
armed.alt = false;
|
||||
if (window.AndroidBridge) {
|
||||
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
|
||||
if (hadAlt) AndroidBridge.onModConsumed("alt");
|
||||
}
|
||||
} else {
|
||||
sendInput(data);
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(function (size) {
|
||||
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
|
||||
});
|
||||
|
||||
container.addEventListener("click", function () {
|
||||
term.focus();
|
||||
});
|
||||
|
||||
var ro = new ResizeObserver(function () {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (e) {
|
||||
// container not measurable yet (e.g. mid-teardown) — next observation retries
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Bridge called from TerminalWebViewController via evaluateJavascript —
|
||||
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
|
||||
window.maestroKey = function (key) {
|
||||
var ctrl = armed.ctrl;
|
||||
var alt = armed.alt;
|
||||
var seq;
|
||||
switch (key) {
|
||||
case "Tab": seq = "\t"; break;
|
||||
case "Esc": seq = "\x1b"; break;
|
||||
case "Up": seq = cursorSeq("A", ctrl, alt); break;
|
||||
case "Down": seq = cursorSeq("B", ctrl, alt); break;
|
||||
case "Right": seq = cursorSeq("C", ctrl, alt); break;
|
||||
case "Left": seq = cursorSeq("D", ctrl, alt); break;
|
||||
default: return;
|
||||
}
|
||||
sendInput(seq);
|
||||
armed.ctrl = false;
|
||||
armed.alt = false;
|
||||
};
|
||||
|
||||
window.maestroToggleMod = function (name) {
|
||||
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
|
||||
};
|
||||
|
||||
window.maestroFocus = function () {
|
||||
term.focus();
|
||||
};
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.data.Session
|
||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||
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 baseUrl by prefs.baseUrl.collectAsState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
||||
|
||||
val session = openSession
|
||||
if (session != null) {
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = session.id,
|
||||
sessionTitle = sessionName(session),
|
||||
onBack = { openSession = null },
|
||||
)
|
||||
} else {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = { s ->
|
||||
openSession = s
|
||||
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,117 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import pt.naps.maestro.data.CreateSessionRequest
|
||||
import pt.naps.maestro.data.SessionView
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
import pt.naps.maestro.data.WorktreeSpec
|
||||
|
||||
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
||||
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
||||
|
||||
/** Commands that just launch another program, looked through to find the
|
||||
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
||||
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
||||
|
||||
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
||||
|
||||
/** Program a command line would run, stripped of leading `FOO=bar`
|
||||
* assignments, known wrappers, its own arguments, and any path prefix. A
|
||||
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
||||
* client uses — good enough for the preset commands and the common
|
||||
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
||||
internal fun resolveProgram(command: String): String {
|
||||
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
var i = 0
|
||||
while (i < words.size) {
|
||||
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
||||
if (i >= words.size) return ""
|
||||
val word = words[i].substringAfterLast('/').lowercase()
|
||||
if (word !in WRAPPER_PROGRAMS) return word
|
||||
i++
|
||||
while (i < words.size && words[i].startsWith("-")) i++
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
enum class WorktreeBranchMode { New, Existing }
|
||||
|
||||
/** The spawn form's current values, gathered at submit time. Mirrors
|
||||
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
||||
data class SpawnFormValues(
|
||||
val command: String,
|
||||
val cwd: String,
|
||||
val name: String,
|
||||
val structuredView: Boolean,
|
||||
val autoApprove: Boolean,
|
||||
val worktreeEnabled: Boolean,
|
||||
val worktreeBranch: String,
|
||||
val worktreeBranchMode: WorktreeBranchMode,
|
||||
)
|
||||
|
||||
sealed interface SpawnBuildResult {
|
||||
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
||||
data class Error(val message: String) : SpawnBuildResult
|
||||
}
|
||||
|
||||
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
||||
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
||||
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
||||
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
||||
|
||||
val name = values.name.trim()
|
||||
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
||||
|
||||
var worktree: WorktreeSpec? = null
|
||||
if (values.worktreeEnabled) {
|
||||
val branch = values.worktreeBranch.trim()
|
||||
if (baseRepo.isNullOrBlank()) {
|
||||
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
||||
}
|
||||
if (branch.isEmpty()) {
|
||||
return SpawnBuildResult.Error(
|
||||
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
||||
else "Worktree requires a branch name.",
|
||||
)
|
||||
}
|
||||
worktree = WorktreeSpec(
|
||||
base_repo = baseRepo,
|
||||
branch = branch,
|
||||
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
||||
)
|
||||
}
|
||||
|
||||
return SpawnBuildResult.Ok(
|
||||
CreateSessionRequest(
|
||||
cwd = values.cwd,
|
||||
command = values.command,
|
||||
metadata = metadata,
|
||||
worktree = worktree,
|
||||
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
||||
SessionView.Structured
|
||||
} else {
|
||||
SessionView.Terminal
|
||||
},
|
||||
auto_approve = values.autoApprove,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
||||
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
||||
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
||||
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
||||
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
||||
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
||||
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
||||
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.data.BranchInfo
|
||||
import pt.naps.maestro.data.Project
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
|
||||
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
||||
* a preset chip row, a searchable project list, a raw-path autocomplete
|
||||
* fallback, a branch/worktree picker, a session name, and the
|
||||
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
||||
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
||||
* tests. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SpawnSheet(
|
||||
viewModel: SpawnViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onSpawned: (Session) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
SpawnSheetContent(
|
||||
state = state,
|
||||
onSelectPreset = viewModel::selectPreset,
|
||||
onCustomCommandChange = viewModel::setCustomCommand,
|
||||
onStructuredViewChange = viewModel::setStructuredView,
|
||||
onProjectQueryChange = viewModel::setProjectQuery,
|
||||
onSelectProject = viewModel::selectProject,
|
||||
onCwdChange = viewModel::setCwd,
|
||||
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
||||
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
||||
onBranchModeChange = viewModel::setBranchMode,
|
||||
onNewBranchChange = viewModel::setNewBranch,
|
||||
onExistingBranchChange = viewModel::setExistingBranch,
|
||||
onNameChange = viewModel::setName,
|
||||
onReroll = viewModel::rerollName,
|
||||
onDismissError = viewModel::dismissError,
|
||||
onSubmit = { viewModel.submit(onSpawned) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpawnSheetContent(
|
||||
state: SpawnUiState,
|
||||
onSelectPreset: (String) -> Unit,
|
||||
onCustomCommandChange: (String) -> Unit,
|
||||
onStructuredViewChange: (Boolean) -> Unit,
|
||||
onProjectQueryChange: (String) -> Unit,
|
||||
onSelectProject: (Project) -> Unit,
|
||||
onCwdChange: (String) -> Unit,
|
||||
onPickDirSuggestion: (String) -> Unit,
|
||||
onWorktreeEnabledChange: (Boolean) -> Unit,
|
||||
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
||||
onNewBranchChange: (String) -> Unit,
|
||||
onExistingBranchChange: (String) -> Unit,
|
||||
onNameChange: (String) -> Unit,
|
||||
onReroll: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(state.error) {
|
||||
val message = state.error ?: return@LaunchedEffect
|
||||
snackbarHostState.showSnackbar(message)
|
||||
onDismissError()
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
PresetRow(
|
||||
presets = state.presets,
|
||||
selectedPresetId = state.selectedPresetId,
|
||||
onSelectPreset = onSelectPreset,
|
||||
)
|
||||
if (state.isCustomSelected) {
|
||||
OutlinedTextField(
|
||||
value = state.customCommand,
|
||||
onValueChange = onCustomCommandChange,
|
||||
label = { Text("Command") },
|
||||
placeholder = { Text("bash -l") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
ProjectPicker(
|
||||
query = state.projectQuery,
|
||||
onQueryChange = onProjectQueryChange,
|
||||
projects = state.visibleProjects,
|
||||
loading = state.projectsLoading,
|
||||
selectedPath = state.selectedProjectPath,
|
||||
homeDir = state.homeDir,
|
||||
onSelectProject = onSelectProject,
|
||||
)
|
||||
|
||||
DirAutocompleteField(
|
||||
cwd = state.cwd,
|
||||
suggestions = state.dirSuggestions,
|
||||
onCwdChange = onCwdChange,
|
||||
onPickSuggestion = onPickDirSuggestion,
|
||||
)
|
||||
|
||||
BranchSection(
|
||||
enabled = state.worktreeEnabled,
|
||||
onEnabledChange = onWorktreeEnabledChange,
|
||||
baseRepo = state.baseRepo,
|
||||
mode = state.branchMode,
|
||||
onModeChange = onBranchModeChange,
|
||||
newBranch = state.newBranch,
|
||||
onNewBranchChange = onNewBranchChange,
|
||||
branches = state.branches,
|
||||
branchesLoading = state.branchesLoading,
|
||||
existingBranch = state.effectiveExistingBranch,
|
||||
onExistingBranchChange = onExistingBranchChange,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Session name") },
|
||||
placeholder = { Text("named after your first prompt") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = onReroll) { Text("Reroll") }
|
||||
}
|
||||
|
||||
if (supportsStructuredView(state.command)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
||||
Text("Structured view")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSubmit,
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.submitting) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Spawn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||
Snackbar(snackbarData = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row is its own small touch target: tap anywhere in it, not just the
|
||||
* leading control, to select it. */
|
||||
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
||||
this.clickable(enabled = enabled, onClick = onClick)
|
||||
|
||||
@Composable
|
||||
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
for (preset in presets) {
|
||||
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
||||
FilterChip(
|
||||
selected = preset.id == selectedPresetId,
|
||||
onClick = { onSelectPreset(preset.id) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectPicker(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
projects: List<Project>,
|
||||
loading: Boolean,
|
||||
selectedPath: String?,
|
||||
homeDir: String,
|
||||
onSelectProject: (Project) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Project", style = MaterialTheme.typography.labelLarge)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Filter projects…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
when {
|
||||
loading -> Text(
|
||||
"Loading projects…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
projects.isEmpty() -> Text(
|
||||
"No projects match.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
||||
items(projects, key = { it.path }) { project ->
|
||||
ProjectRow(
|
||||
project = project,
|
||||
active = project.path == selectedPath,
|
||||
homeDir = homeDir,
|
||||
onClick = { onSelectProject(project) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
||||
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(background)
|
||||
.clickableRow(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
abbreviateHome(parentDir(project.path), homeDir),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abbreviateHome(path: String, homeDir: String): String =
|
||||
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
||||
|
||||
@Composable
|
||||
private fun DirAutocompleteField(
|
||||
cwd: String,
|
||||
suggestions: List<String>,
|
||||
onCwdChange: (String) -> Unit,
|
||||
onPickSuggestion: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = cwd,
|
||||
onValueChange = onCwdChange,
|
||||
label = { Text("Or type a directory") },
|
||||
placeholder = { Text("/home/user/project") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
||||
items(suggestions) { entry ->
|
||||
Text(
|
||||
text = "$entry/",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickableRow { onPickSuggestion(entry) }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchSection(
|
||||
enabled: Boolean,
|
||||
onEnabledChange: (Boolean) -> Unit,
|
||||
baseRepo: String?,
|
||||
mode: WorktreeBranchMode,
|
||||
onModeChange: (WorktreeBranchMode) -> Unit,
|
||||
newBranch: String,
|
||||
onNewBranchChange: (String) -> Unit,
|
||||
branches: List<BranchInfo>,
|
||||
branchesLoading: Boolean,
|
||||
existingBranch: String,
|
||||
onExistingBranchChange: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
||||
Text("Create git worktree")
|
||||
}
|
||||
if (!enabled) return@Column
|
||||
|
||||
if (baseRepo == null) {
|
||||
Text(
|
||||
"Choose a directory inside a git repository first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
||||
Text("New branch")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
||||
Text("Existing branch")
|
||||
}
|
||||
|
||||
if (mode == WorktreeBranchMode.New) {
|
||||
OutlinedTextField(
|
||||
value = newBranch,
|
||||
onValueChange = onNewBranchChange,
|
||||
label = { Text("New branch name") },
|
||||
placeholder = { Text("feat/foo") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
branchesLoading -> Text(
|
||||
"Loading branches…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
branches.isEmpty() -> Text(
|
||||
"No local branches found.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||
items(branches, key = { it.name }) { branch ->
|
||||
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = branch.name == existingBranch,
|
||||
onClick = { onExistingBranchChange(branch.name) },
|
||||
enabled = held == null,
|
||||
)
|
||||
Column {
|
||||
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
||||
if (held != null) {
|
||||
Text(
|
||||
"held by $held",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.api.MaestroApiException
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.data.BranchInfo
|
||||
import pt.naps.maestro.data.Project
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
|
||||
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
||||
* `/api/gitroot` fire (#269). */
|
||||
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
||||
|
||||
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
||||
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
||||
* `parentDir` in `web/src/paths.ts`. */
|
||||
internal fun parentDir(path: String): String {
|
||||
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
||||
val slash = trimmed.lastIndexOf('/')
|
||||
return when {
|
||||
slash < 0 -> ""
|
||||
slash == 0 -> "/"
|
||||
else -> trimmed.substring(0, slash)
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits a path into its directory and trailing (possibly partial) segment.
|
||||
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
||||
internal fun splitPath(value: String): Pair<String, String> {
|
||||
val slash = value.lastIndexOf('/')
|
||||
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
||||
}
|
||||
|
||||
data class SpawnUiState(
|
||||
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
||||
val selectedPresetId: String = "",
|
||||
val customCommand: String = "",
|
||||
val commandTouched: Boolean = false,
|
||||
val structuredView: Boolean = false,
|
||||
val structuredViewTouched: Boolean = false,
|
||||
val homeDir: String = "",
|
||||
val projects: List<Project> = emptyList(),
|
||||
val projectsLoading: Boolean = true,
|
||||
val projectQuery: String = "",
|
||||
val selectedProjectPath: String? = null,
|
||||
val cwd: String = "",
|
||||
val dirSuggestions: List<String> = emptyList(),
|
||||
val baseRepo: String? = null,
|
||||
val worktreeEnabled: Boolean = false,
|
||||
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
||||
val newBranch: String = "",
|
||||
val existingBranch: String = "",
|
||||
val branches: List<BranchInfo> = emptyList(),
|
||||
val branchesLoading: Boolean = false,
|
||||
val name: String = "",
|
||||
val submitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
||||
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
||||
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
||||
|
||||
val visibleProjects: List<Project>
|
||||
get() {
|
||||
val query = projectQuery.trim().lowercase()
|
||||
val filtered = if (query.isEmpty()) {
|
||||
projects
|
||||
} else {
|
||||
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
||||
}
|
||||
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
||||
}
|
||||
|
||||
val effectiveExistingBranch: String
|
||||
get() = existingBranch.ifEmpty {
|
||||
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
||||
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
||||
* for a short-lived form, and this keeps the class trivially testable with a
|
||||
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
||||
*/
|
||||
class SpawnViewModel(
|
||||
private val client: MaestroClient,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
private val _state = MutableStateFlow(SpawnUiState())
|
||||
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
||||
|
||||
private var dirJob: Job? = null
|
||||
private var branchesJob: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
||||
_state.update { s ->
|
||||
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
||||
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
||||
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
||||
} else {
|
||||
s.selectedPresetId
|
||||
}
|
||||
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
||||
.withSyncedStructuredView()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
||||
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPreset(id: String) {
|
||||
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
||||
}
|
||||
|
||||
fun setCustomCommand(text: String) {
|
||||
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
||||
}
|
||||
|
||||
fun setStructuredView(enabled: Boolean) {
|
||||
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
||||
}
|
||||
|
||||
/** Re-derives the structured-view checkbox from the command this state
|
||||
* carries, unless the operator has already ticked/unticked it by hand. */
|
||||
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
||||
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
||||
|
||||
fun setProjectQuery(query: String) {
|
||||
_state.update { it.copy(projectQuery = query) }
|
||||
}
|
||||
|
||||
fun selectProject(project: Project) {
|
||||
dirJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedProjectPath = project.path,
|
||||
cwd = project.path,
|
||||
dirSuggestions = emptyList(),
|
||||
baseRepo = project.base_repo ?: project.path,
|
||||
branches = emptyList(),
|
||||
existingBranch = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator typed a path by hand: clears any project pick, and
|
||||
* schedules a debounced refresh of both the directory-suggestion
|
||||
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
||||
fun setCwd(raw: String) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
cwd = raw,
|
||||
selectedProjectPath = null,
|
||||
baseRepo = null,
|
||||
branches = emptyList(),
|
||||
existingBranch = "",
|
||||
)
|
||||
}
|
||||
dirJob?.cancel()
|
||||
dirJob = scope.launch {
|
||||
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
||||
refreshDirSuggestions(raw)
|
||||
refreshBaseRepo(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
||||
* drill-in gesture the web `DirAutocomplete` uses. */
|
||||
fun pickDirSuggestion(entry: String) {
|
||||
val (dir, _) = splitPath(_state.value.cwd)
|
||||
setCwd("$dir$entry/")
|
||||
}
|
||||
|
||||
private suspend fun refreshDirSuggestions(raw: String) {
|
||||
val (dir, prefix) = splitPath(raw)
|
||||
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
||||
_state.update { it.copy(dirSuggestions = filtered) }
|
||||
}
|
||||
|
||||
private suspend fun refreshBaseRepo(raw: String) {
|
||||
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||
_state.update { it.copy(baseRepo = root) }
|
||||
}
|
||||
|
||||
fun setWorktreeEnabled(enabled: Boolean) {
|
||||
_state.update { it.copy(worktreeEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setBranchMode(mode: WorktreeBranchMode) {
|
||||
_state.update { it.copy(branchMode = mode) }
|
||||
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
||||
}
|
||||
|
||||
fun setNewBranch(text: String) {
|
||||
_state.update { it.copy(newBranch = text) }
|
||||
}
|
||||
|
||||
fun setExistingBranch(text: String) {
|
||||
_state.update { it.copy(existingBranch = text) }
|
||||
}
|
||||
|
||||
private fun loadBranchesIfNeeded() {
|
||||
val cwd = _state.value.cwd
|
||||
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
||||
branchesJob?.cancel()
|
||||
_state.update { it.copy(branchesLoading = true) }
|
||||
branchesJob = scope.launch {
|
||||
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
||||
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
||||
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setName(text: String) {
|
||||
_state.update { it.copy(name = text) }
|
||||
}
|
||||
|
||||
/** `GET /api/names?count=1`: replaces the session-name field with a
|
||||
* freshly generated handle. */
|
||||
fun rerollName() {
|
||||
scope.launch {
|
||||
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
||||
if (handle != null) _state.update { it.copy(name = handle) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
||||
* session. Any failure — validation or API — lands in
|
||||
* [SpawnUiState.error] instead. */
|
||||
fun submit(onSuccess: (Session) -> Unit) {
|
||||
val s = _state.value
|
||||
val values = SpawnFormValues(
|
||||
command = s.command,
|
||||
cwd = s.cwd,
|
||||
name = s.name,
|
||||
structuredView = s.structuredView,
|
||||
autoApprove = s.activePreset?.auto_approve ?: false,
|
||||
worktreeEnabled = s.worktreeEnabled,
|
||||
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
||||
worktreeBranchMode = s.branchMode,
|
||||
)
|
||||
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
||||
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
||||
is SpawnBuildResult.Ok -> {
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
scope.launch {
|
||||
try {
|
||||
val session = client.createSession(built.request)
|
||||
_state.update { it.copy(submitting = false) }
|
||||
onSuccess(session)
|
||||
} catch (e: MaestroApiException) {
|
||||
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_state.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
|
||||
* in the terminal page's own JS instead of sending a byte sequence. */
|
||||
sealed class TerminalKeyAction(val label: String) {
|
||||
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
|
||||
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
|
||||
}
|
||||
|
||||
/**
|
||||
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
|
||||
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
|
||||
* — armed here, then folded into the very next key (tapped on this bar or
|
||||
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
|
||||
*/
|
||||
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
|
||||
TerminalKeyAction.Send("Esc", "Esc"),
|
||||
TerminalKeyAction.Send("Tab", "Tab"),
|
||||
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
|
||||
TerminalKeyAction.Modifier("alt", "Alt"),
|
||||
TerminalKeyAction.Send("↑", "Up"),
|
||||
TerminalKeyAction.Send("↓", "Down"),
|
||||
TerminalKeyAction.Send("←", "Left"),
|
||||
TerminalKeyAction.Send("→", "Right"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TerminalKeyBar(
|
||||
armed: Set<String>,
|
||||
onSend: (String) -> Unit,
|
||||
onToggleModifier: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
for (action in TERMINAL_KEY_ACTIONS) {
|
||||
when (action) {
|
||||
is TerminalKeyAction.Modifier -> {
|
||||
val isArmed = action.name in armed
|
||||
TextButton(onClick = { onToggleModifier(action.name) }) {
|
||||
Text(
|
||||
text = action.display,
|
||||
color = if (isArmed) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is TerminalKeyAction.Send -> {
|
||||
TextButton(onClick = { onSend(action.key) }) {
|
||||
Text(text = action.display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import java.net.URLEncoder
|
||||
import pt.naps.maestro.api.Routes
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
|
||||
* loads for one session. The embedded xterm.js page reads its `ws` query
|
||||
* param and opens that as its own `WS /api/attach/:id` connection directly
|
||||
* — this object only computes the URL, it never touches the socket.
|
||||
*/
|
||||
object TerminalPageUrl {
|
||||
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
|
||||
|
||||
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
|
||||
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
|
||||
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
|
||||
return "$ASSET_URL?ws=$encoded"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.Modifier
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
||||
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
||||
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TerminalScreen(
|
||||
daemonBaseUrl: String,
|
||||
sessionId: Uuid,
|
||||
sessionTitle: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
||||
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
||||
var armed by remember { mutableStateOf(emptySet<String>()) }
|
||||
val keyboardOpen = WindowInsets.isImeVisible
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(sessionTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
TerminalWebView(
|
||||
pageUrl = pageUrl,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize(),
|
||||
onReady = { controller = it },
|
||||
onModifierConsumed = { name -> armed = armed - name },
|
||||
)
|
||||
if (keyboardOpen) {
|
||||
TerminalKeyBar(
|
||||
armed = armed,
|
||||
onSend = { key ->
|
||||
controller?.sendKey(key)
|
||||
armed = emptySet()
|
||||
},
|
||||
onToggleModifier = { name ->
|
||||
armed = if (name in armed) armed - name else armed + name
|
||||
controller?.toggleModifier(name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
||||
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
||||
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
||||
|
||||
/**
|
||||
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
||||
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
||||
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
||||
* this controller only calls the `maestro*` bridge functions it exposes.
|
||||
*/
|
||||
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
||||
fun sendKey(key: String) {
|
||||
val arg = JSONObject.quote(key)
|
||||
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
||||
}
|
||||
|
||||
fun toggleModifier(name: String) {
|
||||
val arg = JSONObject.quote(name)
|
||||
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
||||
}
|
||||
|
||||
fun focus() {
|
||||
webView.requestFocus()
|
||||
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
||||
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
||||
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
||||
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
||||
* the soft keyboard, so the native key bar can clear its toggle to match.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun TerminalWebView(
|
||||
pageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onReady: (TerminalWebViewController) -> Unit = {},
|
||||
onModifierConsumed: (String) -> Unit = {},
|
||||
) {
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun onModConsumed(name: String) {
|
||||
mainHandler.post { onModifierConsumed(name) }
|
||||
}
|
||||
},
|
||||
"AndroidBridge",
|
||||
)
|
||||
onReady(TerminalWebViewController(this))
|
||||
loadUrl(pageUrl)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
||||
},
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
}
|
||||
@@ -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,107 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.data.SessionView
|
||||
|
||||
private val base = SpawnFormValues(
|
||||
command = "claude",
|
||||
cwd = "/home/user/project",
|
||||
name = "amber-fox",
|
||||
structuredView = false,
|
||||
autoApprove = false,
|
||||
worktreeEnabled = false,
|
||||
worktreeBranch = "",
|
||||
worktreeBranchMode = WorktreeBranchMode.New,
|
||||
)
|
||||
|
||||
class SpawnModelTest {
|
||||
@Test
|
||||
fun `builds a plain request with the picked cwd and command`() {
|
||||
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
||||
assertEquals("/home/user/project", result.request.cwd)
|
||||
assertEquals("claude", result.request.command)
|
||||
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
||||
assertEquals(SessionView.Terminal, result.request.view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omits metadata name when the name is blank`() {
|
||||
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
||||
assertTrue(result.request.metadata.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requests structured view for resolved claude and codex commands`() {
|
||||
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
||||
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
||||
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not request structured view for unsupported commands`() {
|
||||
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
||||
assertEquals(SessionView.Terminal, result.request.view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attaches a trimmed branch when worktree is enabled`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
||||
"/home/user/project",
|
||||
) as SpawnBuildResult.Ok
|
||||
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
||||
assertEquals("feat/foo", result.request.worktree?.branch)
|
||||
assertTrue(result.request.worktree?.create_branch == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checks out an existing branch when the mode is existing`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||
"/home/user/project",
|
||||
) as SpawnBuildResult.Ok
|
||||
assertEquals(false, result.request.worktree?.create_branch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an existing-mode worktree with no branch picked`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||
"/repo",
|
||||
)
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a missing working directory`() {
|
||||
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a missing command`() {
|
||||
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a worktree without an inferred base repo`() {
|
||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a worktree without a branch name`() {
|
||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveProgram strips env assignments and paths`() {
|
||||
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
||||
assertEquals("claude", resolveProgram("claude"))
|
||||
assertEquals("zsh", resolveProgram("zsh -l"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import app.cash.turbine.test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.data.Project
|
||||
|
||||
/** Routes each enqueued response by request path rather than arrival order —
|
||||
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
||||
* so a plain FIFO queue can hand either endpoint the other's response. */
|
||||
private class RoutedDispatcher : Dispatcher() {
|
||||
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
||||
|
||||
fun enqueue(path: String, response: MockResponse) {
|
||||
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
||||
}
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty().substringBefore('?')
|
||||
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
|
||||
class SpawnViewModelTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var dispatcher: RoutedDispatcher
|
||||
private lateinit var client: MaestroClient
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
dispatcher = RoutedDispatcher()
|
||||
server = MockWebServer()
|
||||
server.dispatcher = dispatcher
|
||||
server.start()
|
||||
client = MaestroClient(server.url("/").toString())
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun jsonResponse(body: String) =
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
||||
* every test needs before it can observe anything past init. */
|
||||
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
||||
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||
}
|
||||
|
||||
private fun newViewModel() = SpawnViewModel(client, scope)
|
||||
|
||||
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
||||
var result: SpawnUiState? = null
|
||||
state.test {
|
||||
var item = awaitItem()
|
||||
while (!predicate(item)) item = awaitItem()
|
||||
result = item
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
return result ?: error("state predicate never satisfied")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
||||
enqueueInit(
|
||||
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
||||
{"id":"claude","label":"Claude","command":"claude"},
|
||||
{"id":"custom","label":"Custom…","command":""}
|
||||
],"instance":"i"}""",
|
||||
)
|
||||
|
||||
val vm = newViewModel()
|
||||
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
assertEquals("claude", settled.command)
|
||||
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
||||
assertEquals("/home/x", settled.homeDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
||||
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||
|
||||
val vm = newViewModel()
|
||||
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
||||
|
||||
assertEquals(FALLBACK_PRESETS, settled.presets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
||||
|
||||
vm.selectPreset("zsh")
|
||||
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
||||
assertEquals("zsh", afterZsh.command)
|
||||
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
||||
|
||||
vm.selectPreset("claude")
|
||||
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
||||
assertTrue(afterClaude.structuredView)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
vm.setStructuredView(false)
|
||||
vm.selectPreset("codex")
|
||||
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
||||
assertTrue("operator's untick should stick", !after.structuredView)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
val after = vm.awaitState { it.cwd == "/repo" }
|
||||
assertEquals("/repo", after.baseRepo)
|
||||
assertEquals("/repo", after.selectedProjectPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
||||
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
||||
|
||||
vm.setCwd("/home/user/pro")
|
||||
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
||||
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
||||
val withRepo = vm.awaitState { it.baseRepo != null }
|
||||
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reroll fills the name field from the daemon`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
||||
vm.rerollName()
|
||||
val after = vm.awaitState { it.name == "amber-fox" }
|
||||
assertEquals("amber-fox", after.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
var succeeded = false
|
||||
vm.submit { succeeded = true }
|
||||
val after = vm.awaitState { it.error != null }
|
||||
assertNotNull(after.error)
|
||||
assertTrue(!succeeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit posts the built request and reports the new session`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
vm.awaitState { it.cwd == "/repo" }
|
||||
|
||||
dispatcher.enqueue(
|
||||
"/api/sessions",
|
||||
jsonResponse(
|
||||
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
||||
),
|
||||
)
|
||||
var result: pt.naps.maestro.data.Session? = null
|
||||
vm.submit { result = it }
|
||||
val after = vm.awaitState { !it.submitting && it.error == null }
|
||||
assertNull(after.error)
|
||||
assertEquals("s1", result?.id)
|
||||
|
||||
var request = server.takeRequest()
|
||||
while (request.method != "POST") request = server.takeRequest()
|
||||
assertEquals("/api/sessions", request.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
vm.awaitState { it.cwd == "/repo" }
|
||||
|
||||
dispatcher.enqueue(
|
||||
"/api/sessions",
|
||||
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
||||
)
|
||||
vm.submit {}
|
||||
val after = vm.awaitState { it.error != null }
|
||||
assertTrue(after.error!!.contains("cwd does not exist"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TerminalKeyBarActionsTest {
|
||||
@Test
|
||||
fun `contains tab esc and all four arrows`() {
|
||||
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
|
||||
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl and alt are modifiers rather than sends`() {
|
||||
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
|
||||
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arrow keys carry the maestroKey names the terminal page expects`() {
|
||||
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
|
||||
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TerminalPageUrlTest {
|
||||
@Test
|
||||
fun `builds asset url with the encoded attach ws url`() {
|
||||
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
|
||||
assertEquals(
|
||||
"file:///android_asset/terminal/index.html" +
|
||||
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swaps an https base to wss`() {
|
||||
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
|
||||
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips a trailing slash from the daemon base url`() {
|
||||
val url = TerminalPageUrl.build("http://host:7070/", "s1")
|
||||
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@
|
||||
[versions]
|
||||
agp = "8.5.2"
|
||||
kotlin = "2.0.21"
|
||||
coreKtx = "1.13.1"
|
||||
lifecycleRuntimeKtx = "2.8.6"
|
||||
activityCompose = "1.9.2"
|
||||
composeBom = "2024.09.03"
|
||||
navigationCompose = "2.8.2"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerializationJson = "1.7.3"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
turbine = "1.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
BIN
Binary file not shown.
@@ -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")
|
||||
@@ -5,11 +5,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "ACP client role: one claude-code-acp child per structured session"
|
||||
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
|
||||
|
||||
+605
-58
@@ -1,5 +1,5 @@
|
||||
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
|
||||
//! structured session.
|
||||
//! ACP client role for the daemon: one provider adapter child per structured
|
||||
//! session.
|
||||
//!
|
||||
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
|
||||
//! adapter as a child process with stdio pipes, in the session's `cwd` with
|
||||
@@ -9,10 +9,10 @@
|
||||
//!
|
||||
//! **The handshake declares almost nothing.** `initialize` reports
|
||||
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
|
||||
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
|
||||
//! daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method that
|
||||
//! gets a real implementation, in #139; here it is a stub that declines.
|
||||
//! adapter uses the provider's own file and command tools instead of asking
|
||||
//! the daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method with
|
||||
//! a real implementation.
|
||||
//!
|
||||
//! Every `session/update` notification the adapter sends lands on an
|
||||
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
|
||||
@@ -42,29 +42,80 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation,
|
||||
InitializeRequest, LoadSessionRequest, NewSessionRequest, RequestPermissionOutcome,
|
||||
RequestPermissionRequest, RequestPermissionResponse, SessionId, SessionNotification,
|
||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
||||
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
||||
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
||||
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
||||
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||
};
|
||||
use agent_client_protocol::schema::ProtocolVersion;
|
||||
use agent_client_protocol::{ByteStreams, Client};
|
||||
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
pub use agent_client_protocol::schema::v1 as schema;
|
||||
|
||||
/// The adapter binary, resolved on `PATH`.
|
||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||
/// Item on the updates channel. Notifications and turn-done markers share
|
||||
/// the same FIFO so the event pump processes every notification for a turn
|
||||
/// before seeing its done signal.
|
||||
#[derive(Debug)]
|
||||
pub enum AcpUpdate {
|
||||
/// A raw `session/update` notification from the adapter.
|
||||
Notification(Box<SessionNotification>),
|
||||
/// The prompt response for `turn` arrived. All notifications for this
|
||||
/// turn precede this marker on the wire, and therefore in this channel.
|
||||
/// The event pump must fire `done` after persisting every preceding
|
||||
/// notification.
|
||||
TurnDone {
|
||||
turn: u64,
|
||||
result: Result<(), String>,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// How to install the adapter when it is missing. Named in the error so an
|
||||
/// operator staring at a failed session knows what to run.
|
||||
/// ACP adapter selected from the structured session's resolved command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcpAdapter {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl AcpAdapter {
|
||||
pub const fn binary(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "claude-code-acp",
|
||||
Self::Codex => "codex-acp",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn install_command(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
|
||||
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AcpAdapter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ClaudeCode => "Claude Code",
|
||||
Self::Codex => "Codex",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility aliases for Claude Code callers.
|
||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||
pub const INSTALL_COMMAND: &str =
|
||||
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
||||
|
||||
/// How long `initialize` + `session/new` may take before the spawn is
|
||||
/// declared failed. The real adapter boots a node process and the Claude
|
||||
/// Code SDK behind it; a slow machine takes seconds, never a minute.
|
||||
/// declared failed. The real adapters boot a node process and their provider
|
||||
/// client behind it; a slow machine takes seconds, never a minute.
|
||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
|
||||
@@ -77,6 +128,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Bytes of the child's stderr kept for the failure message.
|
||||
const STDERR_TAIL_BYTES: usize = 4096;
|
||||
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
|
||||
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedNewSessionRequest {
|
||||
cwd: PathBuf,
|
||||
mcp_servers: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedNewSessionResponse {
|
||||
session_id: SessionId,
|
||||
#[serde(default)]
|
||||
modes: Option<SessionModeState>,
|
||||
#[serde(default)]
|
||||
config_options: Option<Vec<SessionConfigOption>>,
|
||||
#[serde(default)]
|
||||
models: Option<LegacyModelState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedLoadSessionRequest {
|
||||
session_id: SessionId,
|
||||
cwd: PathBuf,
|
||||
mcp_servers: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedLoadSessionResponse {
|
||||
#[serde(default)]
|
||||
modes: Option<SessionModeState>,
|
||||
#[serde(default)]
|
||||
config_options: Option<Vec<SessionConfigOption>>,
|
||||
#[serde(default)]
|
||||
models: Option<LegacyModelState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacyModelState {
|
||||
current_model_id: String,
|
||||
available_models: Vec<LegacyModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacyModel {
|
||||
model_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/set_model", response = LegacySetModelResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacySetModelRequest {
|
||||
session_id: SessionId,
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
struct LegacySetModelResponse {}
|
||||
|
||||
/// What [`spawn`] needs to start one adapter.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -93,6 +213,8 @@ pub struct SpawnConfig {
|
||||
/// instead of opening a fresh one. The adapter must advertise
|
||||
/// `loadSession`; the handshake fails otherwise.
|
||||
pub load_session: Option<String>,
|
||||
/// The provider-specific adapter to launch.
|
||||
pub adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
impl SpawnConfig {
|
||||
@@ -102,6 +224,7 @@ impl SpawnConfig {
|
||||
env,
|
||||
handshake_timeout: HANDSHAKE_TIMEOUT,
|
||||
load_session: None,
|
||||
adapter: AcpAdapter::ClaudeCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,37 +233,59 @@ impl SpawnConfig {
|
||||
self.load_session = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
|
||||
self.adapter = adapter;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocked ACP permission request delivered to the daemon.
|
||||
#[derive(Debug)]
|
||||
pub struct PermissionRequest {
|
||||
/// The prompt turn that was active when ACP delivered this callback.
|
||||
pub turn: u64,
|
||||
pub request: RequestPermissionRequest,
|
||||
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AcpError {
|
||||
/// The adapter is not on `PATH`. The message names the install command
|
||||
/// so the failed session is actionable rather than silently dead.
|
||||
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
|
||||
AdapterMissing,
|
||||
#[error("failed to start {ADAPTER_BIN}: {0}")]
|
||||
Spawn(#[source] std::io::Error),
|
||||
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
|
||||
AdapterMissing { adapter: AcpAdapter },
|
||||
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
|
||||
Spawn {
|
||||
adapter: AcpAdapter,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// The adapter answered the handshake with an error.
|
||||
#[error("ACP handshake failed: {0}")]
|
||||
Handshake(String),
|
||||
/// The adapter went away before the handshake finished. Carries the
|
||||
/// tail of its stderr, which is where a missing Claude login shows up.
|
||||
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
|
||||
ExitedEarly { status: String, stderr: String },
|
||||
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
|
||||
ExitedEarly {
|
||||
adapter: AcpAdapter,
|
||||
status: String,
|
||||
stderr: String,
|
||||
},
|
||||
#[error("ACP handshake timed out after {0:?}")]
|
||||
HandshakeTimeout(Duration),
|
||||
}
|
||||
|
||||
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
|
||||
/// session env's `PATH` when it sets one, the daemon's own otherwise.
|
||||
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||
let path: OsString = match env.get("PATH") {
|
||||
Some(p) => OsString::from(p),
|
||||
None => std::env::var_os("PATH")?,
|
||||
};
|
||||
std::env::split_paths(&path)
|
||||
.filter(|dir| !dir.as_os_str().is_empty())
|
||||
.map(|dir| dir.join(ADAPTER_BIN))
|
||||
.map(|dir| dir.join(adapter.binary()))
|
||||
.find(|candidate| is_executable(candidate))
|
||||
}
|
||||
|
||||
@@ -153,11 +298,53 @@ fn is_executable(path: &Path) -> bool {
|
||||
|
||||
enum Command {
|
||||
Cancel,
|
||||
Prompt {
|
||||
text: String,
|
||||
turn: u64,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
SetConfig {
|
||||
config_id: String,
|
||||
value: SessionConfigOptionValue,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
struct ConnectionConfig {
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
enum ChildCommand {
|
||||
Signal(libc::c_int),
|
||||
Reap(oneshot::Sender<bool>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PermissionTurnState {
|
||||
active: Option<u64>,
|
||||
}
|
||||
|
||||
impl PermissionTurnState {
|
||||
fn begin(&mut self, turn: u64) {
|
||||
self.active = Some(turn);
|
||||
}
|
||||
|
||||
fn cancel_active(&mut self) {
|
||||
self.active = None;
|
||||
}
|
||||
|
||||
fn finish(&mut self, turn: u64) {
|
||||
if self.active == Some(turn) {
|
||||
self.active = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn callback_turn(&self) -> Option<u64> {
|
||||
self.active
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the handshake did not produce a session id.
|
||||
@@ -185,7 +372,8 @@ pub struct AcpSession {
|
||||
pid: u32,
|
||||
commands: mpsc::UnboundedSender<Command>,
|
||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||
exit: watch::Receiver<Option<ExitStatus>>,
|
||||
}
|
||||
|
||||
@@ -206,23 +394,76 @@ impl AcpSession {
|
||||
self.exit.borrow().is_some()
|
||||
}
|
||||
|
||||
/// Reap the child if it has already exited.
|
||||
pub async fn reap_if_exited(&self) -> bool {
|
||||
if self.has_exited() {
|
||||
return true;
|
||||
}
|
||||
let (sent, received) = oneshot::channel();
|
||||
if self.child_commands.send(ChildCommand::Reap(sent)).is_err() {
|
||||
return self.has_exited();
|
||||
}
|
||||
received.await.unwrap_or_else(|_| self.has_exited())
|
||||
}
|
||||
|
||||
/// Wait for the child to exit.
|
||||
pub async fn wait(&self) -> Option<ExitStatus> {
|
||||
wait_exit(&self.exit).await
|
||||
}
|
||||
|
||||
/// The stream of raw `session/update` notifications, handed out once.
|
||||
/// Notifications that arrived before the take are still queued on it.
|
||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
|
||||
/// The stream of updates (notifications + turn-done markers), handed out
|
||||
/// once. Items that arrived before the take are still queued.
|
||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
|
||||
self.updates.lock().expect("updates mutex").take()
|
||||
}
|
||||
|
||||
pub fn take_permission_requests(&self) -> Option<mpsc::UnboundedReceiver<PermissionRequest>> {
|
||||
self.permission_requests
|
||||
.lock()
|
||||
.expect("permission requests")
|
||||
.take()
|
||||
}
|
||||
|
||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||
/// child is already gone, which is the outcome cancel was after.
|
||||
pub fn cancel(&self) {
|
||||
let _ = self.commands.send(Command::Cancel);
|
||||
}
|
||||
|
||||
/// Send a user prompt and receive completion once the agent ends the turn.
|
||||
pub fn prompt(
|
||||
&self,
|
||||
text: String,
|
||||
turn: u64,
|
||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
self.commands
|
||||
.send(Command::Prompt {
|
||||
text,
|
||||
turn,
|
||||
done: done_tx,
|
||||
})
|
||||
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||
Ok(done_rx)
|
||||
}
|
||||
|
||||
/// Change one option advertised by the ACP agent.
|
||||
pub fn set_config(
|
||||
&self,
|
||||
config_id: String,
|
||||
value: SessionConfigOptionValue,
|
||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
self.commands
|
||||
.send(Command::SetConfig {
|
||||
config_id,
|
||||
value,
|
||||
done: done_tx,
|
||||
})
|
||||
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||
Ok(done_rx)
|
||||
}
|
||||
|
||||
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
||||
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
||||
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
||||
@@ -263,7 +504,9 @@ impl Drop for AcpSession {
|
||||
/// Fails without a running child: on any error the process is already
|
||||
/// signalled, so a failed session never leaves a stray adapter behind.
|
||||
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
|
||||
let provider = config.adapter;
|
||||
let adapter = resolve_adapter(provider, &config.env)
|
||||
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&adapter);
|
||||
cmd.current_dir(&config.cwd)
|
||||
@@ -291,28 +534,42 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
|
||||
let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
|
||||
adapter: provider,
|
||||
source,
|
||||
})?;
|
||||
let pid = child.id().expect("child pid is known before wait");
|
||||
let stdin = child.stdin.take().expect("piped stdin");
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
|
||||
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
|
||||
|
||||
let (exit_tx, exit_rx) = watch::channel(None);
|
||||
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
||||
tokio::spawn(supervise_child(
|
||||
child,
|
||||
pid,
|
||||
child_commands_rx,
|
||||
exit_tx,
|
||||
provider,
|
||||
));
|
||||
|
||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||
tokio::spawn(run_connection(
|
||||
transport,
|
||||
config.cwd.clone(),
|
||||
config.load_session.clone(),
|
||||
ConnectionConfig {
|
||||
cwd: config.cwd.clone(),
|
||||
load_session: config.load_session.clone(),
|
||||
adapter: provider,
|
||||
},
|
||||
updates_tx,
|
||||
permission_tx,
|
||||
commands_rx,
|
||||
ready_tx,
|
||||
));
|
||||
@@ -326,6 +583,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
commands: commands_tx,
|
||||
child_commands: child_commands_tx,
|
||||
updates: Mutex::new(Some(updates_rx)),
|
||||
permission_requests: Mutex::new(Some(permission_rx)),
|
||||
exit: exit_rx,
|
||||
});
|
||||
}
|
||||
@@ -342,6 +600,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
.to_string()
|
||||
};
|
||||
AcpError::ExitedEarly {
|
||||
adapter: provider,
|
||||
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
|
||||
stderr: if stderr.is_empty() {
|
||||
String::new()
|
||||
@@ -376,12 +635,13 @@ async fn supervise_child(
|
||||
pid: u32,
|
||||
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
|
||||
exit_tx: watch::Sender<Option<ExitStatus>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let status = status.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -392,13 +652,28 @@ async fn supervise_child(
|
||||
// so its PID cannot be reused in that window.
|
||||
unsafe { libc::kill(pid as libc::pid_t, sig) };
|
||||
}
|
||||
Some(ChildCommand::Reap(done)) => match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = done.send(true);
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(Some(status));
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = done.send(false);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
|
||||
let _ = done.send(false);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// The session vanished without a command receiver. This
|
||||
// task still owns the unreaped child, so this cannot hit
|
||||
// a reused PID.
|
||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
|
||||
let status = child.wait().await.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -417,11 +692,106 @@ fn client_capabilities() -> ClientCapabilities {
|
||||
.terminal(false)
|
||||
}
|
||||
|
||||
fn advertised_config_options(
|
||||
mut options: Vec<SessionConfigOption>,
|
||||
modes: Option<&SessionModeState>,
|
||||
models: Option<&LegacyModelState>,
|
||||
) -> Vec<SessionConfigOption> {
|
||||
let has_model = options
|
||||
.iter()
|
||||
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
|
||||
let has_mode = options
|
||||
.iter()
|
||||
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
|
||||
if let Some(models) = models.filter(|_| !has_model) {
|
||||
let mut choices = models
|
||||
.available_models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let mut option =
|
||||
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
|
||||
if model.model_id == "default" {
|
||||
option.name = "Opus (default)".into();
|
||||
}
|
||||
option.description.clone_from(&model.description);
|
||||
option
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !models
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|model| model.model_id == "fable")
|
||||
{
|
||||
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
|
||||
}
|
||||
options.push(
|
||||
SessionConfigOption::select(
|
||||
LEGACY_MODEL_CONFIG_ID,
|
||||
"Model",
|
||||
models.current_model_id.clone(),
|
||||
choices,
|
||||
)
|
||||
.category(SessionConfigOptionCategory::Model),
|
||||
);
|
||||
}
|
||||
if let Some(modes) = modes.filter(|_| !has_mode) {
|
||||
let choices = modes
|
||||
.available_modes
|
||||
.iter()
|
||||
.map(|mode| {
|
||||
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
|
||||
option.description.clone_from(&mode.description);
|
||||
option
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
options.push(
|
||||
SessionConfigOption::select(
|
||||
LEGACY_MODE_CONFIG_ID,
|
||||
"Mode",
|
||||
modes.current_mode_id.0.clone(),
|
||||
choices,
|
||||
)
|
||||
.category(SessionConfigOptionCategory::Mode),
|
||||
);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn replace_config_value(
|
||||
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
|
||||
config_id: &str,
|
||||
value: &SessionConfigOptionValue,
|
||||
) -> Vec<SessionConfigOption> {
|
||||
let mut options = options.lock().expect("config options");
|
||||
if let Some(option) = options
|
||||
.iter_mut()
|
||||
.find(|option| option.id.0.as_ref() == config_id)
|
||||
{
|
||||
match (&mut option.kind, value) {
|
||||
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
|
||||
select.current_value = value.clone();
|
||||
}
|
||||
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
|
||||
boolean.current_value = *value;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options.clone()
|
||||
}
|
||||
|
||||
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
|
||||
SessionNotification::new(
|
||||
SessionId::new(session_id),
|
||||
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_connection<T>(
|
||||
transport: T,
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||
config: ConnectionConfig,
|
||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||
) where
|
||||
@@ -433,6 +803,11 @@ async fn run_connection<T>(
|
||||
// history the event log already holds (#135).
|
||||
let replaying = Arc::new(AtomicBool::new(false));
|
||||
let replaying_seen = replaying.clone();
|
||||
// ACP callbacks have no turn id. The daemon never queues a later prompt
|
||||
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||
let permission_turn_seen = permission_turn.clone();
|
||||
let received_updates = updates_tx.clone();
|
||||
let result = Client
|
||||
.builder()
|
||||
.name("maestro")
|
||||
@@ -443,22 +818,37 @@ async fn run_connection<T>(
|
||||
return Ok(());
|
||||
}
|
||||
// A dropped receiver only means nobody wants updates any more.
|
||||
let _ = updates_tx.send(notification);
|
||||
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_notification!(),
|
||||
)
|
||||
.on_receive_request(
|
||||
async move |request: RequestPermissionRequest, responder, _cx| {
|
||||
// Stub until #139: decline rather than grant something the
|
||||
// operator never saw.
|
||||
tracing::warn!(
|
||||
session = %request.session_id,
|
||||
"session/request_permission declined: not implemented yet (#139)"
|
||||
);
|
||||
responder.respond(RequestPermissionResponse::new(
|
||||
let turn = permission_turn_seen
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.callback_turn();
|
||||
let Some(turn) = turn else {
|
||||
return responder.respond(RequestPermissionResponse::new(
|
||||
RequestPermissionOutcome::Cancelled,
|
||||
))
|
||||
));
|
||||
};
|
||||
let (response, received) = oneshot::channel();
|
||||
if permission_requests
|
||||
.send(PermissionRequest {
|
||||
turn,
|
||||
request,
|
||||
response,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return responder.respond(RequestPermissionResponse::new(
|
||||
RequestPermissionOutcome::Cancelled,
|
||||
));
|
||||
}
|
||||
let outcome = received.await.unwrap_or(RequestPermissionOutcome::Cancelled);
|
||||
responder.respond(RequestPermissionResponse::new(outcome))
|
||||
},
|
||||
agent_client_protocol::on_receive_request!(),
|
||||
)
|
||||
@@ -473,11 +863,13 @@ async fn run_connection<T>(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let session_id = match load_session {
|
||||
let (session_id, modes, initial_options, models) = match config.load_session {
|
||||
Some(existing) => {
|
||||
if !initialized.agent_capabilities.load_session {
|
||||
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
||||
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
|
||||
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
|
||||
config.adapter,
|
||||
config.adapter.binary()
|
||||
))));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -485,7 +877,11 @@ async fn run_connection<T>(
|
||||
let (loaded_tx, loaded_rx) = oneshot::channel();
|
||||
let replaying_done = replaying.clone();
|
||||
if let Err(e) = cx
|
||||
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
|
||||
.send_request(ExtendedLoadSessionRequest {
|
||||
session_id: SessionId::new(existing.as_str()),
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.on_receiving_result(move |result| {
|
||||
replaying_done.store(false, Ordering::SeqCst);
|
||||
async move {
|
||||
@@ -497,8 +893,8 @@ async fn run_connection<T>(
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
}
|
||||
match loaded_rx.await {
|
||||
Ok(Ok(_)) => {}
|
||||
let loaded = match loaded_rx.await {
|
||||
Ok(Ok(loaded)) => loaded,
|
||||
Ok(Err(e)) => {
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
@@ -509,31 +905,181 @@ async fn run_connection<T>(
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
existing
|
||||
};
|
||||
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
|
||||
}
|
||||
None => match cx
|
||||
.send_request(NewSessionRequest::new(cwd))
|
||||
.send_request(ExtendedNewSessionRequest {
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.block_task()
|
||||
.await
|
||||
{
|
||||
Ok(created) => created.session_id.0.to_string(),
|
||||
Ok(created) => (
|
||||
created.session_id.0.to_string(),
|
||||
created.modes,
|
||||
created.config_options.unwrap_or_default(),
|
||||
created.models,
|
||||
),
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
let config_options = Arc::new(Mutex::new(advertised_config_options(
|
||||
initial_options,
|
||||
modes.as_ref(),
|
||||
models.as_ref(),
|
||||
)));
|
||||
if let Some(modes) = &modes {
|
||||
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||
modes.current_mode_id.clone(),
|
||||
)),
|
||||
))));
|
||||
}
|
||||
if !config_options.lock().expect("config options").is_empty() {
|
||||
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
|
||||
&session_id,
|
||||
config_options.lock().expect("config options").clone(),
|
||||
))));
|
||||
}
|
||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = commands_rx.recv() => match cmd {
|
||||
Some(Command::Cancel) => {
|
||||
permission_turn
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.cancel_active();
|
||||
let _ = cx.send_notification(CancelNotification::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
));
|
||||
}
|
||||
Some(Command::Prompt { text, turn, done }) => {
|
||||
permission_turn
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.begin(turn);
|
||||
let request = PromptRequest::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
vec![ContentBlock::from(text)],
|
||||
);
|
||||
let done = Arc::new(Mutex::new(Some(done)));
|
||||
let callback_done = done.clone();
|
||||
let callback_turn = permission_turn.clone();
|
||||
let callback_updates = updates_tx.clone();
|
||||
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||
let callback_done = callback_done.clone();
|
||||
let callback_turn = callback_turn.clone();
|
||||
let callback_updates = callback_updates.clone();
|
||||
async move {
|
||||
callback_turn
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.finish(turn);
|
||||
// Route through the same channel as notifications so the
|
||||
// event pump sees this after every chunk for the turn.
|
||||
let result = result.map(|_| ()).map_err(|e| e.to_string());
|
||||
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
|
||||
// Channel closed — nowhere left to signal.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}) {
|
||||
permission_turn
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.finish(turn);
|
||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||
let _ = done.send(Err(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::SetConfig { config_id, value, done }) => {
|
||||
let done = Arc::new(Mutex::new(Some(done)));
|
||||
let callback_done = done.clone();
|
||||
let callback_options = config_options.clone();
|
||||
let callback_updates = updates_tx.clone();
|
||||
let callback_session = session_id.clone();
|
||||
let callback_config_id = config_id.clone();
|
||||
let callback_value = value.clone();
|
||||
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
|
||||
let Some(model_id) = value.as_value_id() else {
|
||||
let _ = done.lock().expect("config sender").take()
|
||||
.map(|done| done.send(Err("model must be a string value".into())));
|
||||
continue;
|
||||
};
|
||||
cx.send_request(LegacySetModelRequest {
|
||||
session_id: SessionId::new(session_id.as_str()),
|
||||
model_id: model_id.0.to_string(),
|
||||
}).on_receiving_result(move |result| async move {
|
||||
if result.is_ok() {
|
||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else if config_id == LEGACY_MODE_CONFIG_ID {
|
||||
let Some(mode_id) = value.as_value_id() else {
|
||||
let _ = done.lock().expect("config sender").take()
|
||||
.map(|done| done.send(Err("mode must be a string value".into())));
|
||||
continue;
|
||||
};
|
||||
cx.send_request(SetSessionModeRequest::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
mode_id.0.to_string(),
|
||||
)).on_receiving_result(move |result| async move {
|
||||
if result.is_ok() {
|
||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||
SessionId::new(callback_session.as_str()),
|
||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||
)),
|
||||
))));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
cx.send_request(SetSessionConfigOptionRequest::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
config_id,
|
||||
value,
|
||||
))
|
||||
.on_receiving_result(move |result| async move {
|
||||
if let Ok(response) = &result {
|
||||
let mut current = callback_options.lock().expect("config options");
|
||||
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||
current.extend(response.config_options.clone());
|
||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
if let Err(error) = sent {
|
||||
if let Some(done) = done.lock().expect("config sender").take() {
|
||||
let _ = done.send(Err(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Close) | None => break,
|
||||
},
|
||||
_ = cx.incoming_closed() => break,
|
||||
@@ -551,10 +1097,11 @@ async fn drain_stderr(
|
||||
stderr: tokio::process::ChildStderr,
|
||||
pid: u32,
|
||||
tail: Arc<Mutex<VecDeque<u8>>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
|
||||
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
|
||||
let mut tail = tail.lock().expect("stderr tail");
|
||||
tail.extend(line.as_bytes());
|
||||
tail.push_back(b'\n');
|
||||
|
||||
@@ -17,16 +17,28 @@ Environment knobs, all optional:
|
||||
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
|
||||
@@ -36,6 +48,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
LOG = os.environ.get("FAKE_ACP_LOG")
|
||||
|
||||
@@ -62,6 +75,45 @@ 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):
|
||||
@@ -94,6 +146,12 @@ for raw in sys.stdin:
|
||||
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")
|
||||
@@ -113,7 +171,11 @@ for raw in sys.stdin:
|
||||
"authMethods": [],
|
||||
}})
|
||||
elif method == "session/new":
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
||||
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)
|
||||
@@ -126,13 +188,71 @@ for raw in sys.stdin:
|
||||
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": {}})
|
||||
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}"}})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||
//! one ignored test against the real `claude-code-acp`.
|
||||
//! 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::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fake_agent() -> PathBuf {
|
||||
@@ -25,6 +25,7 @@ impl Fake {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
|
||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||
.await
|
||||
.expect_err("no adapter on PATH");
|
||||
assert!(matches!(err, AcpError::AdapterMissing));
|
||||
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();
|
||||
@@ -166,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
|
||||
.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 {
|
||||
@@ -180,11 +210,71 @@ async fn session_updates_reach_the_taken_receiver() {
|
||||
.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()))
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn");
|
||||
let pid = session.pid();
|
||||
@@ -225,7 +315,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
||||
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());
|
||||
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");
|
||||
@@ -284,7 +374,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||
match &err {
|
||||
AcpError::ExitedEarly { status, stderr } => {
|
||||
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||
assert!(status.contains('3'), "{status}");
|
||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||
}
|
||||
@@ -306,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
|
||||
fake.wait_for_exit().await;
|
||||
}
|
||||
|
||||
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||
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(),
|
||||
@@ -320,7 +413,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||
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()).load_session("fake-old");
|
||||
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");
|
||||
@@ -353,7 +449,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
async fn load_session_requires_the_load_capability() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
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)
|
||||
@@ -368,7 +467,10 @@ async fn load_session_requires_the_load_capability() {
|
||||
async fn load_session_error_fails_the_spawn() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
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");
|
||||
@@ -392,3 +494,21 @@ async fn real_adapter_handshake() {
|
||||
.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,
|
||||
}
|
||||
|
||||
@@ -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,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
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.
|
||||
@@ -113,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,
|
||||
@@ -138,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(_) => {
|
||||
// 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?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
let id = resolve_session(&sessions, &args.session)?;
|
||||
let view = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.view)
|
||||
.unwrap_or(SessionView::Terminal);
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -179,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 {
|
||||
@@ -190,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).
|
||||
@@ -207,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();
|
||||
}
|
||||
@@ -230,11 +264,67 @@ 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(),
|
||||
@@ -255,6 +345,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,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()
|
||||
});
|
||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -200,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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -122,21 +122,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
// 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 = async {
|
||||
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)
|
||||
.tcp_nodelay(true)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await?;
|
||||
// tmux sessions outlive the daemon by design; structured sessions'
|
||||
// adapter children do not (#134).
|
||||
manager.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
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
+2180
-211
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
//! 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 its adapter sent, one JSON
|
||||
//! object per line, appended before it is broadcast. The log is the source of
|
||||
//! 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
|
||||
@@ -21,7 +21,7 @@ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_acp::schema::SessionNotification;
|
||||
use maestro_protocol::Session;
|
||||
use maestro_protocol::{Session, TranscriptRow};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -56,6 +56,28 @@ impl SidecarStore {
|
||||
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
|
||||
@@ -90,7 +112,7 @@ impl SidecarStore {
|
||||
}
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
Ok(EventLog {
|
||||
file,
|
||||
file: Box::new(file),
|
||||
last_seq: lines,
|
||||
})
|
||||
}
|
||||
@@ -128,10 +150,10 @@ impl SidecarStore {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Ok(notification) = serde_json::from_slice::<SessionNotification>(&line) else {
|
||||
let Ok(event) = PersistedEvent::decode(&line) else {
|
||||
continue;
|
||||
};
|
||||
out.push_back(LoggedEvent { seq, notification });
|
||||
out.push_back(LoggedEvent { seq, event });
|
||||
match cursor {
|
||||
EventCursor::Since(_) if out.len() >= limit => break,
|
||||
EventCursor::Before(_) if out.len() > limit => {
|
||||
@@ -164,10 +186,12 @@ impl SidecarStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the sidecar and the event log for `id`. No-op for whichever
|
||||
/// 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> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -228,18 +252,132 @@ pub enum EventCursor {
|
||||
pub struct LoggedEvent {
|
||||
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
||||
pub seq: u64,
|
||||
pub notification: SessionNotification,
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
pub struct EventLog {
|
||||
file: File,
|
||||
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
|
||||
@@ -249,12 +387,262 @@ impl EventLog {
|
||||
/// 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.
|
||||
pub fn append(&mut self, notification: &SessionNotification) -> Result<u64, SidecarError> {
|
||||
let mut line = serde_json::to_vec(notification)?;
|
||||
///
|
||||
/// 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');
|
||||
self.file.write_all(&line)?;
|
||||
self.file.flush()?;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user