Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
</intent-filter>
</activity>

<activity
android:name=".ExecutionLogActivity"
android:exported="false" />

<service
android:name=".TunnelService"
android:exported="false"
Expand Down
130 changes: 130 additions & 0 deletions app/src/main/java/ai/sealgate/stdiod/ExecutionLogActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package ai.sealgate.stdiod

import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import ai.sealgate.stdiod.databinding.ActivityExecutionLogBinding
import ai.sealgate.stdiod.databinding.ItemExecutionLogBinding
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import java.util.Locale

class ExecutionLogActivity : AppCompatActivity() {
private lateinit var binding: ActivityExecutionLogBinding
private val expandedIds = mutableSetOf<Long>()
private val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
private var showAll = false
private var entries: List<ExecutionLogEntry> = emptyList()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityExecutionLogBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(0, systemBars.top, 0, systemBars.bottom)
insets
}

binding.backButton.setOnClickListener { finish() }
binding.showMoreButton.setOnClickListener {
showAll = true
render()
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ExecutionLogStore.entries.collect { latest ->
entries = latest
render()
}
}
}
}

private fun render() {
binding.emptyText.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
val shownEntries = if (showAll) entries else entries.take(DEFAULT_VISIBLE_ENTRIES)
binding.logList.removeAllViews()
shownEntries.forEach { entry -> addEntry(entry) }
binding.showMoreButton.visibility =
if (!showAll && entries.size > DEFAULT_VISIBLE_ENTRIES) View.VISIBLE else View.GONE
}

private fun addEntry(entry: ExecutionLogEntry) {
val row = ItemExecutionLogBinding.inflate(
LayoutInflater.from(this),
binding.logList,
false,
)
val expanded = entry.id in expandedIds
val meta = entry.metaLine()
row.headlineText.text = entry.headline
row.metaText.text = meta
row.statusIndicator.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(
this,
when {
entry.isRunning -> R.color.signal_amber
entry.exitCode == 0 -> R.color.circuit_green
else -> R.color.infra_red
},
),
)
row.expandIndicator.text = if (expanded) "⌃" else "⌄"
row.detailPanel.visibility = if (expanded) View.VISIBLE else View.GONE
row.commandText.text = entry.script.ifBlank { getString(R.string.logs_empty_command) }
row.resultText.text = entry.resultText()
row.summaryRow.contentDescription = getString(
if (expanded) R.string.logs_collapse_entry else R.string.logs_expand_entry,
"${entry.headline}, $meta",
)
row.summaryRow.setOnClickListener {
if (!expandedIds.add(entry.id)) expandedIds.remove(entry.id)
render()
}
binding.logList.addView(row.root)
}

private fun ExecutionLogEntry.metaLine(): String {
val time = timeFormatter.format(Date(startedAtMillis))
val status = when {
isRunning -> getString(R.string.logs_running)
exitCode == 0 -> getString(R.string.logs_done)
else -> getString(R.string.logs_failed, exitCode ?: 1)
}
val duration = durationMillis?.let { " · ${formatDuration(it)}" }.orEmpty()
return "$time · $status$duration"
}

private fun ExecutionLogEntry.resultText(): String {
if (isRunning) return getString(R.string.logs_running_detail)
return buildString {
if (stdout.isNotEmpty()) append(stdout.trimEnd())
if (stderr.isNotEmpty()) {
if (isNotEmpty()) append("\n\n")
append(getString(R.string.logs_stderr)).append('\n').append(stderr.trimEnd())
}
if (isEmpty()) append(getString(R.string.logs_no_output))
}
}

private fun formatDuration(millis: Long): String = when {
millis < 1_000 -> "${millis}ms"
millis < 10_000 -> String.format(Locale.getDefault(), "%.1fs", millis / 1_000.0)
else -> "${millis / 1_000}s"
}

companion object {
private const val DEFAULT_VISIBLE_ENTRIES = 5
}
}
106 changes: 106 additions & 0 deletions app/src/main/java/ai/sealgate/stdiod/ExecutionLogStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package ai.sealgate.stdiod

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong

/** A bounded, in-memory history of the Bash requests agents run on this phone. */
data class ExecutionLogEntry(
val id: Long,
val script: String,
val startedAtMillis: Long,
val finishedAtMillis: Long? = null,
val stdout: String = "",
val stderr: String = "",
val exitCode: Int? = null,
) {
val headline: String
get() {
val firstCommand = script.lineSequence()
.map(String::trim)
.firstOrNull(String::isNotEmpty)
.orEmpty()
.replace(Regex("\\s+"), " ")
return when {
firstCommand.isEmpty() -> "Empty command"
firstCommand.length <= HEADLINE_CHARACTERS -> firstCommand
else -> firstCommand.take(HEADLINE_CHARACTERS - 1).trimEnd() + "…"
}
}

val isRunning: Boolean get() = finishedAtMillis == null
val durationMillis: Long? get() = finishedAtMillis?.minus(startedAtMillis)?.coerceAtLeast(0)

companion object {
private const val HEADLINE_CHARACTERS = 72
}
}

object ExecutionLogStore {
private const val MAX_ENTRIES = 50
private const val MAX_OUTPUT_CHARACTERS = 16 * 1024
private const val OUTPUT_TRUNCATED_NOTICE = "\n… output clipped in log"

private val lock = Any()
private val nextId = AtomicLong()
private val _entries = MutableStateFlow<List<ExecutionLogEntry>>(emptyList())
val entries: StateFlow<List<ExecutionLogEntry>> = _entries.asStateFlow()

fun begin(script: String, startedAtMillis: Long = System.currentTimeMillis()): Long {
val id = nextId.incrementAndGet()
synchronized(lock) {
_entries.value = listOf(
ExecutionLogEntry(
id = id,
script = script,
startedAtMillis = startedAtMillis,
),
) + _entries.value.take(MAX_ENTRIES - 1)
}
return id
}

fun finish(
id: Long,
stdout: String,
stderr: String,
exitCode: Int,
finishedAtMillis: Long = System.currentTimeMillis(),
) = update(id) { entry ->
entry.copy(
finishedAtMillis = finishedAtMillis,
stdout = stdout.clipped(),
stderr = stderr.clipped(),
exitCode = exitCode,
)
}

fun fail(
id: Long,
error: Throwable,
finishedAtMillis: Long = System.currentTimeMillis(),
) = update(id) { entry ->
entry.copy(
finishedAtMillis = finishedAtMillis,
stderr = (error.message ?: error.javaClass.simpleName).clipped(),
exitCode = 1,
)
}

private fun update(id: Long, transform: (ExecutionLogEntry) -> ExecutionLogEntry) {
synchronized(lock) {
_entries.value = _entries.value.map { entry ->
if (entry.id == id) transform(entry) else entry
}
}
}

private fun String.clipped(): String =
if (length <= MAX_OUTPUT_CHARACTERS) this
else take(MAX_OUTPUT_CHARACTERS) + OUTPUT_TRUNCATED_NOTICE

internal fun clear() = synchronized(lock) {
_entries.value = emptyList()
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/ai/sealgate/stdiod/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class MainActivity : AppCompatActivity() {
)
}

binding.logsButton.setOnClickListener {
startActivity(Intent(this, ExecutionLogActivity::class.java))
}

binding.connectionInfoButton.setOnClickListener {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.connection_info_title)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/ai/sealgate/stdiod/TunnelService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ class TunnelService : LifecycleService() {
// notifications and stays far denser than a decorated RemoteViews panel.
.setContentTitle(status)
.setContentText(route)
.setSmallIcon(R.drawable.ic_stat_sealgate)
.setSmallIcon(R.drawable.ic_notification_sealgate_safe)
.setLargeIcon(sealGateLogo)
.setStyle(pictureStyle)
.setColor(statusColor)
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/ai/sealgate/stdiod/mcp/BashModule.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.sealgate.stdiod.mcp

import ai.sealgate.stdiod.ExecutionLogStore
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
Expand Down Expand Up @@ -77,7 +78,19 @@ class BashModule(
return JsonRpc.error(JsonNull, INVALID_REQUEST, "request id is too large")
}

val result = runtime.execute(script)
val logId = ExecutionLogStore.begin(script)
val result = try {
runtime.execute(script)
} catch (error: Exception) {
ExecutionLogStore.fail(logId, error)
throw error
}
ExecutionLogStore.finish(
id = logId,
stdout = result.stdout,
stderr = result.stderr,
exitCode = result.exitCode,
)
val text = buildString {
if (result.stdout.isNotEmpty()) append(result.stdout)
if (result.stderr.isNotEmpty()) {
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/res/drawable/ic_chevron.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M9,5 L16,12 L9,19"
android:strokeColor="@color/core_cyan"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2" />
</vector>
24 changes: 7 additions & 17 deletions app/src/main/res/drawable/ic_launcher_foreground.xml
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Three "pipe" bars evoking a stdio stream tunnel, centered in the
safe zone of the adaptive icon. -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,40h40v6H34z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,51h40v6H34z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,62h40v6H34z" />
</vector>
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/sealgate_logo"
android:insetLeft="21dp"
android:insetTop="21dp"
android:insetRight="21dp"
android:insetBottom="21dp" />
7 changes: 7 additions & 0 deletions app/src/main/res/drawable/ic_launcher_monochrome.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_notification_sealgate_safe"
android:insetLeft="18dp"
android:insetTop="18dp"
android:insetRight="18dp"
android:insetBottom="18dp" />
Loading
Loading