Compare commits

...
3 Commits
Author SHA1 Message Date
Spriteandclaude 5fbc597cbc docs: record wave 4 acceptance and what it left undone
Wave 4 is verified complete. Also records the two mechanisms that stopped F3
from reporting (the 600s background-task ceiling in `claude -p`, then the quota
wait) so the next worker launch does not repeat it, and flags the one thing
wave 4 owed and never delivered: nobody has looked at the fourteen new
screenshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:43 +00:00
Spriteandclaude 0f47ee917f orchestration: run workers as a sprite service; record wave 4 completion
setsid nohup did not survive orchestrator teardown on 09-06 (uptime was
continuous, so this was a teardown kill, not the hazard-#5 suspend).
tasks/service-worker.sh runs a worker under the sprite service supervisor
instead, which both outlives the orchestrator and holds the box awake, making
the task-lease guard redundant. It is sentinel-guarded so a supervisor restart
does not re-run a finished wave, and it stops its own service afterwards so the
sprite can suspend.

logs/WAVE4-DONE records that F3's files were all complete but the worker was
stuck re-running verification it could not finish, so the orchestrator ran the
verification itself and accepted the work. F3's own written report — including
the design critique of the rendered screens it was asked for — was never
produced; that gap is recorded in the sentinel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:18 +00:00
Spriteandclaude 5455df2d61 Wave 4 (F3): settings email, five screens' screenshots, release signing, README
Completes wave 4. Verified by the orchestrator, not self-reported:
assembleDebug / testDebugUnitTest / assembleRelease all exit 0;
102 tests, 1 skipped, 0 failures, 0 errors.

- Settings showed the PocketBase user id instead of the signed-in email,
  because login never persisted the email. AuthRepository now writes it to
  SettingsStore on success and sign-out clears it; SettingsUiState carries
  userEmail in place of userId. AuthRepositoryTest asserts both directions.
- Paparazzi coverage for the five screens library was missing: setup, detail,
  scan, locations, settings, each light + dark, populated rather than empty.
  Scan cannot show a live camera under Paparazzi, so its tests render the
  reticle overlay and the result bottom sheet over a static backdrop.
- Release signing via an optional gitignored app/keystore.properties. Without
  it assembleRelease still works and comes out debug-signed, so the build is
  not owner-only. R8 deliberately left off; nothing has proven Room, Retrofit,
  kotlinx-serialization and ML Kit survive it.
- Top-level README: shared-library model, offline-first architecture, the
  push-then-pull last-write-wins conflict rule SPEC requires be documented
  here, build/deploy/install steps, and honest current limitations.

The signed APK and the keystore are intentionally not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TyzeWmdTqi7U85iYNGy7P
2026-09-08 21:28:11 +00:00
29 changed files with 949 additions and 7 deletions
+179
View File
@@ -0,0 +1,179 @@
# Bookshelf
A private, self-hosted home library app for two people. You scan the
barcodes on your books; it looks up the metadata, stores it on your own
server, and keeps both people's phones in sync. No cloud service, no public
registration, no ads, no accounts you don't control.
## The shared-library model
Bookshelf is built for exactly one household: two people who both want to
know what's on the shelves and where. There's no concept of "my books" vs.
"your books" — every book belongs to the one shared library, and either
person can scan, edit, move, or delete anything in it.
- **Self-hosted.** You run a small [PocketBase](https://pocketbase.io/)
server on your own hardware (a NUC, an old laptop, a Raspberry Pi — see
[`server/deploy/`](server/deploy/)). Nobody else's data touches it, and it
touches nobody else's.
- **Private by construction.** The server has no public sign-up
(`createRule = null` on the `users` collection in PocketBase — only a
superuser can create an account, via `server/create-user.sh`), and every
read/write to books, shelves, and bookcases requires a logged-in user.
- **Two named accounts.** Create one account per person with
`server/create-user.sh`. That's the whole user model — there is no admin
UI, no roles, no invitations.
## Architecture
**Android app** (Kotlin, Jetpack Compose, Material 3) talking to a
**PocketBase** backend over HTTPS.
```
app/ Android Gradle project (org.modg.bookshelf)
server/ PocketBase schema, provisioning scripts, deploy docs
docs/ SPEC.md — the authoritative product/technical spec this was built from
```
### Offline-first, Room-backed
Every screen reads from a local [Room](https://developer.android.com/training/data-storage/room)
database, never directly from the network — the app has to be fully usable
(browse, search, edit notes, move books between shelves) with the home
server unreachable, which residential NAT/dynamic IP setups make a routine
occurrence, not an edge case. Every write lands in Room first and is
synced to the server later; nothing blocks on network I/O, and a sync
failure surfaces as a quiet status line, never a crash or a blocking dialog.
Deletion is always a soft tombstone (`deleted = true`), never a hard delete,
on both the client and the server — so a delete on one phone propagates to
the other on next sync instead of just disappearing from one copy.
### Sync: push-then-pull, last-write-wins
Each sync cycle (on app start, pull-to-refresh, and a ~6-hourly WorkManager
job) does two passes, in this order:
1. **Push** every locally-changed record (tracked via a `syncState` column:
`PENDING_CREATE` / `PENDING_UPDATE` / `PENDING_DELETE`) to PocketBase.
New records use a client-generated 15-character id, sent as-is on create —
PocketBase accepts client-supplied ids, so an id never has to be remapped
after the fact. A `PENDING_DELETE` is pushed as a `PATCH {deleted: true}`,
never an actual record delete.
2. **Pull** everything changed on the server since the last-seen cursor
(`updated > cursor`, paginated to exhaustion), so the two devices'
changes reconcile in one direction after the local push.
**Conflict rule: last-write-wins on the server's `updated` timestamp.**
If both phones edit the same book while offline, whichever write reaches
the server later simply overwrites the earlier one — there is no merge, no
per-field reconciliation, and no conflict UI. This is a deliberate
simplification for a two-person household doing infrequent concurrent edits,
not a limitation either of you should expect to fight with day-to-day, but
it does mean a same-book edit race can silently lose one side's change.
Book covers get the same treatment as everything else: the app downloads
the cover from the metadata source and re-uploads it to PocketBase's own
`cover` file field, so the household's library doesn't rot when an external
cover URL eventually 404s. If offline, the cover is queued locally and
uploaded on the next sync.
### Metadata lookup
Scanning a barcode looks the ISBN up against
[Open Library](https://openlibrary.org/dev/docs/api/books), falling back to
[Google Books](https://developers.google.com/books) if Open Library has
nothing. The two results are merged (prefer whichever has a title; fill in
blanks from the other); if both come up empty, the app offers manual entry
pre-filled with the scanned ISBN instead of a dead end.
## Building
Requirements: JDK 21, Android SDK (compileSdk/targetSdk 37, build-tools
37.0.0). No emulator is required or used in this project's own verification —
see "Current limitations" below.
```sh
cd app
./gradlew assembleDebug # debug APK
./gradlew testDebugUnitTest # JVM unit tests (Robolectric + Paparazzi)
./gradlew recordPaparazziDebug # re-record screenshot goldens under src/test/snapshots
./gradlew assembleRelease # release APK — see "Signing" below
```
### Signing a release build
`app/app/build.gradle.kts` reads signing credentials from
`app/keystore.properties` (gitignored, alongside the `.jks` keystore it
points at) if that file exists:
```properties
storeFile=release-keystore.jks
storePassword=...
keyAlias=bookshelf
keyPassword=...
```
Without that file, `assembleRelease` still succeeds — the release build type
simply comes out unsigned (debug-signed by AGP's defaults), so anyone who
clones this repo can build and run it without needing the household's actual
release key. Only the machine(s) that own `keystore.properties` produce an
APK you'd actually want to install permanently (Android treats a
signing-key change as a different app for update purposes, so hang on to
that keystore).
## Deploying the server
See [`server/README.md`](server/README.md) for the schema and provisioning
scripts, and [`server/deploy/`](server/deploy/) for running PocketBase
long-term (systemd or Docker), reaching it from outside your home network
(Tailscale is the recommended option — no ports opened on your router, no
TLS cert management), and backups. In short:
```sh
cd server
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
./create-user.sh you@example.com "a strong password" "Your Name"
./create-user.sh partner@example.com "a different strong password" "Partner Name"
```
Enter the server's URL (must be `https://`, unless it's Tailscale-only —
see the deploy README) on the app's first-run setup screen; it's never
hardcoded into the build.
## Installing the APK
Build (or ask whoever holds the release keystore to build)
`app/app/build/outputs/apk/release/app-release.apk`, copy it to the phone,
and open it. Android will prompt to allow installs from that source the
first time. There's no Play Store listing — this app is not, and was never
meant to be, publicly distributed.
## Current limitations
Read this before assuming more polish than exists:
- **The app has never run on a physical device or emulator.** This
environment has no KVM, so there is no Android emulator available.
Everything here was verified via `./gradlew assembleDebug`,
`testDebugUnitTest` (JVM/Robolectric unit tests), and Paparazzi screenshot
rendering (`src/test/snapshots/images/`) — real logic paths (ISBN
checksums, metadata merging, sync conflict resolution, DAO queries) are
unit-tested, and every screen has been rendered to a static PNG in both
light and dark theme, but nothing has been tap-tested on an actual screen.
Camera/barcode scanning in particular has only been exercised through unit
tests of the pure logic (`IsbnBarcodeAnalyzer`/`ScanCodeFilter`), never a
live camera.
- **Sync has been round-tripped against a real PocketBase exactly once**
(a live-server test covering auth, push with client-generated ids, pull,
last-write-wins, tombstones, and a byte-for-byte cover round-trip — see
`server/live-sync-test.sh`). It has not been exercised over an actual flaky
residential connection, nor with two devices genuinely racing each other.
- **Conflict resolution is last-write-wins with no merge and no UI** for it,
as described above — acceptable for this app's scale, but worth knowing
before relying on it under real concurrent edits.
- **No Room foreign keys** between books/shelves/bookcases (a deliberate
simplification) — an orphaned `shelfId` on a book is handled in queries,
not prevented by the schema.
- **No automated instrumented/UI tests** — only JVM unit tests and Paparazzi
screenshots. There is no CI pipeline in this repo.
+3
View File
@@ -10,3 +10,6 @@
local.properties local.properties
**/build/ **/build/
.kotlin/ .kotlin/
keystore.properties
*.jks
*.keystore
+27
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is // NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
@@ -8,6 +10,17 @@ plugins {
alias(libs.plugins.paparazzi) alias(libs.plugins.paparazzi)
} }
// Release signing is optional: `keystore.properties` (gitignored, alongside the
// keystore it points at) only exists on machines that own the release key. Anyone
// else still gets a working `assembleRelease` — it just comes out debug-signed.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
val hasReleaseKeystore = keystorePropertiesFile.exists()
android { android {
namespace = "org.modg.bookshelf" namespace = "org.modg.bookshelf"
compileSdk = 37 compileSdk = 37
@@ -23,10 +36,24 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (hasReleaseKeystore) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }
@@ -24,6 +24,7 @@ class SettingsStore(private val context: Context) {
val SERVER_URL = stringPreferencesKey("server_url") val SERVER_URL = stringPreferencesKey("server_url")
val AUTH_TOKEN = stringPreferencesKey("auth_token") val AUTH_TOKEN = stringPreferencesKey("auth_token")
val USER_ID = stringPreferencesKey("user_id") val USER_ID = stringPreferencesKey("user_id")
val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time") val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection") fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
} }
@@ -31,6 +32,7 @@ class SettingsStore(private val context: Context) {
val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] } val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] }
val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] } val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] }
val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] } val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] }
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
fun cursorFor(collection: String): Flow<String?> = fun cursorFor(collection: String): Flow<String?> =
@@ -48,6 +50,10 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.USER_ID] = userId } context.dataStore.edit { it[Keys.USER_ID] = userId }
} }
suspend fun setUserEmail(email: String) {
context.dataStore.edit { it[Keys.USER_EMAIL] = email }
}
suspend fun setCursor(collection: String, cursor: String) { suspend fun setCursor(collection: String, cursor: String) {
context.dataStore.edit { it[Keys.cursor(collection)] = cursor } context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
} }
@@ -61,6 +67,7 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { context.dataStore.edit {
it.remove(Keys.AUTH_TOKEN) it.remove(Keys.AUTH_TOKEN)
it.remove(Keys.USER_ID) it.remove(Keys.USER_ID)
it.remove(Keys.USER_EMAIL)
} }
} }
@@ -35,6 +35,7 @@ class AuthRepository(
val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password)) val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password))
settingsStore.setAuthToken(response.token) settingsStore.setAuthToken(response.token)
settingsStore.setUserId(response.record.id) settingsStore.setUserId(response.record.id)
settingsStore.setUserEmail(email)
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -76,7 +76,7 @@ fun SettingsScreen(
GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Account") SectionHeading("Account")
InfoRow(label = "Signed in as", value = state.userId ?: "Unknown") InfoRow(label = "Signed in as", value = state.userEmail ?: "Unknown")
SecondaryButton( SecondaryButton(
text = "Sign out", text = "Sign out",
onClick = { confirmSignOut = true }, onClick = { confirmSignOut = true },
@@ -17,7 +17,7 @@ import org.modg.bookshelf.ui.components.SyncStatus
data class SettingsUiState( data class SettingsUiState(
val serverUrl: String? = null, val serverUrl: String? = null,
val userId: String? = null, val userEmail: String? = null,
val bookCount: Int = 0, val bookCount: Int = 0,
val coverCount: Int = 0, val coverCount: Int = 0,
val lastSyncTime: Long? = null, val lastSyncTime: Long? = null,
@@ -35,7 +35,7 @@ data class SettingsUiState(
private data class BaseInfo( private data class BaseInfo(
val serverUrl: String?, val serverUrl: String?,
val userId: String?, val userEmail: String?,
val bookCount: Int, val bookCount: Int,
val coverCount: Int, val coverCount: Int,
val lastSyncTime: Long?, val lastSyncTime: Long?,
@@ -54,13 +54,13 @@ class SettingsViewModel(
private val baseInfo = combine( private val baseInfo = combine(
authRepository.serverUrl, authRepository.serverUrl,
settingsStore.userId, settingsStore.userEmail,
bookRepository.observeAll(), bookRepository.observeAll(),
settingsStore.lastSyncTime, settingsStore.lastSyncTime,
) { url, userId, books, lastSync -> ) { url, userEmail, books, lastSync ->
BaseInfo( BaseInfo(
serverUrl = url, serverUrl = url,
userId = userId, userEmail = userEmail,
bookCount = books.size, bookCount = books.size,
coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() }, coverCount = books.count { !it.coverUrl.isNullOrBlank() || !it.localCoverPath.isNullOrBlank() },
lastSyncTime = lastSync, lastSyncTime = lastSync,
@@ -70,7 +70,7 @@ class SettingsViewModel(
val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error -> val uiState: StateFlow<SettingsUiState> = combine(baseInfo, isSyncing, syncError) { base, syncing, error ->
SettingsUiState( SettingsUiState(
serverUrl = base.serverUrl, serverUrl = base.serverUrl,
userId = base.userId, userEmail = base.userEmail,
bookCount = base.bookCount, bookCount = base.bookCount,
coverCount = base.coverCount, coverCount = base.coverCount,
lastSyncTime = base.lastSyncTime, lastSyncTime = base.lastSyncTime,
@@ -0,0 +1,52 @@
package org.modg.bookshelf.data.repo
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Covers the settings-email gap from the wave-4 handoff: Settings used to show the
* PocketBase user id because login never persisted the entered email.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AuthRepositoryTest {
private lateinit var settingsStore: SettingsStore
private lateinit var api: FakePocketBaseApi
private lateinit var repository: AuthRepository
@Before
fun setUp() {
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
api = FakePocketBaseApi()
repository = AuthRepository(apiProvider = { api as PocketBaseApi }, settingsStore = settingsStore)
}
@Test
fun `login persists the entered email so Settings can show it`() = runTest {
val result = repository.login(email = "reader@example.com", password = "hunter2")
assertTrue(result.isSuccess)
assertEquals("reader@example.com", settingsStore.userEmail.first())
}
@Test
fun `sign out clears the persisted email`() = runTest {
repository.login(email = "reader@example.com", password = "hunter2")
repository.signOut()
assertNull(settingsStore.userEmail.first())
}
}
@@ -0,0 +1,92 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.detail.BookHeader
import org.modg.bookshelf.ui.detail.DescriptionSection
import org.modg.bookshelf.ui.detail.LocationSection
import org.modg.bookshelf.ui.detail.NotesSection
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "detail" screen — big cover, metadata, collapsible description,
* editable notes, location picker. Rebuilds [org.modg.bookshelf.ui.detail.DetailScreen]'s
* shell around its own real internal sections ([BookHeader], [DescriptionSection],
* [NotesSection], [LocationSection]) with [ScreenFixtures.detailBook], per the
* pattern in [ScreenFixtures].
*/
class DetailScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun detailPopulatedLight() = snapshotBoth("detail-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val book = ScreenFixtures.detailBook
BookshelfScaffold(
title = book.title,
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
},
actions = {
IconButton(onClick = {}) { Icon(Icons.Outlined.Edit, contentDescription = "Edit") }
IconButton(onClick = {}) { Icon(Icons.Outlined.Delete, contentDescription = "Delete") }
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
BookHeader(book = book)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
DescriptionSection(description = book.description)
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
NotesSection(book = book, onSave = {})
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
LocationSection(
book = book,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
onShelfSelected = {},
)
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,94 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.locations.BookcaseRow
import org.modg.bookshelf.ui.locations.BookcaseUi
import org.modg.bookshelf.ui.locations.ShelfUi
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "locations" screen — bookcases -> shelves tree with per-shelf book
* counts. Rebuilds [org.modg.bookshelf.ui.locations.LocationsScreen]'s shell
* around its own real [BookcaseRow] (which nests the real `ShelfRow`), fed
* counts derived from [ScreenFixtures.books], per the pattern in [ScreenFixtures].
*/
class LocationsScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
BookcaseUi(
bookcase = bookcase,
shelves = ScreenFixtures.shelves
.filter { it.bookcaseId == bookcase.id }
.map { shelf -> ShelfUi(shelf, ScreenFixtures.books.count { it.shelfId == shelf.id }) },
)
}
BookshelfScaffold(
title = "Locations",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
},
floatingActionButton = {
FloatingActionButton(onClick = {}) { Icon(Icons.Filled.Add, contentDescription = "Add bookcase") }
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
LazyColumn(contentPadding = PaddingValues(top = innerPadding.calculateTopPadding(), bottom = 96.dp)) {
items(bookcaseUis, key = { it.bookcase.id }) { bookcaseUi ->
BookcaseRow(
bookcaseUi = bookcaseUi,
onEdit = {},
onDelete = {},
onMoveUp = {},
onMoveDown = {},
onAddShelf = {},
onShelfClick = {},
onEditShelf = {},
onDeleteShelf = {},
onMoveShelfBooks = {},
onShelfMoveUp = {},
onShelfMoveDown = {},
)
GoldDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,120 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FlashOff
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.metadata.BookMetadata
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.FoundBookSheet
import org.modg.bookshelf.ui.scan.ScanReticle
import org.modg.bookshelf.ui.scan.SessionBadge
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "scan" screen — camera + reticle, on-hit bottom sheet.
*
* Paparazzi has no real camera: [org.modg.bookshelf.ui.scan.ScanScreen]'s
* `CameraPreview` binds a live `ProcessCameraProvider`/`PreviewView`, which
* cannot run headless (same class of problem [ScreenFixtures] documents for
* Room/OkHttp). So this renders the screen's real overlay pieces — [ScanReticle],
* [SessionBadge], and the real [FoundBookSheet] — over a plain dark backdrop
* standing in for the live viewfinder, per the wave-4 task instructions.
*/
class ScanScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun scanReticleLight() = snapshotBoth("scan-reticle") { ReticleOverlay() }
@Test
fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() }
@Composable
private fun ReticleOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ScanReticle(modifier = Modifier.align(Alignment.Center))
SessionBadge(count = 3, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
}
}
@Composable
private fun FoundSheetOverlay() = Shell {
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surface,
) {
FoundBookSheet(
metadata = BookMetadata(
isbn13 = "9780765326355",
title = "The Way of Kings",
authors = listOf("Brandon Sanderson"),
publisher = "Tor",
publishedDate = "2010",
pageCount = 1007,
),
duplicate = DuplicateStatus.New,
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
selectedShelfId = ScreenFixtures.deskShelf.id,
onShelfSelected = {},
onSave = {},
onSkip = {},
)
}
}
}
@Composable
private fun Shell(overlay: @Composable () -> Unit) {
BookshelfScaffold(
title = "Scan",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") }
},
actions = {
IconButton(onClick = {}) { Icon(Icons.Outlined.FlashOff, contentDescription = "Toggle flashlight") }
IconButton(onClick = {}) { Icon(Icons.Outlined.Keyboard, contentDescription = "Enter ISBN manually") }
},
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
// Static stand-in for the live CameraX viewfinder — see class doc.
.background(Color(0xFF1A1A1A)),
) {
overlay()
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,89 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.settings.InfoRow
import org.modg.bookshelf.ui.settings.SectionHeading
import org.modg.bookshelf.ui.settings.formatLastSync
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "settings" screen — server, account (now showing the signed-in
* email per the wave-4 fix), sync status, book/cover counts. Rebuilds
* [org.modg.bookshelf.ui.settings.SettingsScreen]'s shell around its own real
* [SectionHeading]/[InfoRow]/[formatLastSync], per the pattern in [ScreenFixtures].
*/
class SettingsScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun settingsPopulatedLight() = snapshotBoth("settings-populated") { Populated() }
@Composable
private fun Populated() = Shell()
@Composable
private fun Shell() {
val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
BookshelfScaffold(
title = "Settings",
navigationIcon = {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
},
syncStatusBar = {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
},
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(innerPadding).padding(16.dp)) {
SectionHeading("Server")
InfoRow(label = "URL", value = "https://library.montanaro.home")
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Account")
InfoRow(label = "Signed in as", value = "reader@example.com")
SecondaryButton(text = "Sign out", onClick = {}, modifier = Modifier.padding(top = 8.dp))
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(lastSync))
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeading("Library")
InfoRow(label = "Books", value = ScreenFixtures.books.size.toString())
InfoRow(label = "Covers", value = "0")
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,140 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.components.GoldDivider
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "setup" screen — first run, server URL + email + password.
* [org.modg.bookshelf.ui.setup.SetupScreen] wires everything straight to
* [org.modg.bookshelf.ui.setup.SetupViewModel] with no internal presentational
* split, so per [ScreenFixtures]'s established approach this rebuilds the same
* layout inline with plain, filled-in state instead of driving a real
* ViewModel (whose reachability probe builds an OkHttpClient — see
* [ScreenFixtures] for why that blows up under Paparazzi).
*/
class SetupScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun setupFilledLight() = snapshotBoth("setup-filled") { Filled() }
@Composable
private fun Filled() = Shell(
serverUrl = "https://library.montanaro.home",
email = "reader@example.com",
password = "hunter2",
urlError = null,
credentialsError = null,
)
@Composable
private fun Shell(
serverUrl: String,
email: String,
password: String,
urlError: String?,
credentialsError: String?,
) {
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text(text = "Bookshelf", style = MaterialTheme.typography.displaySmall)
Text(
text = "Connect to your home library server to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
OutlinedTextField(
value = serverUrl,
onValueChange = {},
label = { Text("Server URL") },
placeholder = { Text("https://library.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
isError = urlError != null,
modifier = Modifier.fillMaxWidth(),
)
if (urlError != null) {
Text(
text = urlError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
OutlinedTextField(
value = email,
onValueChange = {},
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = password,
onValueChange = {},
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = credentialsError != null,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (credentialsError != null) {
Text(
text = credentialsError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
PrimaryButton(
text = "Sign in",
onClick = {},
modifier = Modifier.padding(top = 24.dp),
)
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
+55
View File
@@ -230,3 +230,58 @@ Commit `a022a1b` (32 files, +3081).
3. No Room foreign keys between books/shelves/bookcases (deliberate, worker C). 3. No Room foreign keys between books/shelves/bookcases (deliberate, worker C).
4. No emulator on this box: nothing has ever been *run*, only compiled and unit-tested. 4. No emulator on this box: nothing has ever been *run*, only compiled and unit-tested.
Paparazzi PNGs are the only evidence of how any of it actually looks. Paparazzi PNGs are the only evidence of how any of it actually looks.
## Wave 4 — COMPLETE, verified by the orchestrator on 2026-09-08
This is the last planned wave. All of SPEC's build/verify gates now pass.
| Check | Result |
|---|---|
| `./tasks/gw assembleDebug` | **exit 0** |
| `./tasks/gw testDebugUnitTest` | **exit 0 — 102 tests, 1 skipped, 0 failures, 0 errors** |
| `./tasks/gw assembleRelease` | **exit 0** |
| signed APK | `app/app/build/outputs/apk/release/app-release.apk`, 41,777,344 bytes |
| `apksigner verify --print-certs` | V2 signer `CN=Bookshelf, O=Montanaro` — the real release key, not the debug cert |
| secrets | `app/release-keystore.jks` + `app/keystore.properties` gitignored and NOT committed — re-verified against the staged file list before committing |
The skipped test is `LiveSyncTest` — opt-in, it needs the live PocketBase. It
PASSED in wave 4's first half; the cover round-trip gap from wave 3 is closed.
Commits `5455df2` (app work) + `0f47ee9` (orchestration). Pushed to `origin/main`
(`ssh://git@git.jfmonty2.com:2022/jfmonty2/bookshelf.git`) — the repo now has a
remote, so pushing after each commit is the norm.
### How wave 4 actually ended — F3 never reported
F3-release wrote all four deliverables between 20:34 and 20:46 on 09-08 and then
could not finish. Two separate mechanisms:
- `claude -p` terminates background tasks after 600s (`Background tasks still
running after 600s; terminating. Set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 to
wait indefinitely.`). F3 had launched `assembleRelease` in the background with a
Monitor and ended its turn saying it would report when done — the exact failure
its own prompt forbade.
- It then hit the 5h quota and went into a 600s wait loop, so the service
supervisor would have kept re-running verification on finished work indefinitely.
The orchestrator ran the verification itself, wrote `logs/WAVE4-DONE` by hand
(noting it was NOT written by `service-worker.sh`), and stopped the service.
**If you launch another worker, set `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` in
`run-task.sh`, or tell the worker to run builds in the FOREGROUND.** A worker that
backgrounds a 10-minute Gradle build cannot ever report on it.
### The one thing wave 4 owed and did not deliver
F3 was asked to say candidly what the rendered screens get WRONG against SPEC's
design language, now that they can finally be seen. It never reported. **Fourteen
new PNGs are on disk, unreviewed by anyone.** The user explicitly cares how this
looks, so this is the top open item — not a build problem, a design-review one:
`app/app/src/test/snapshots/images/` (setup, detail, scan ×2, locations, settings,
library, scaffold — each light + dark).
## STATE: what is NOT done (as of 2026-09-08)
1. **Nobody has looked at the screenshots.** See above. Highest-value next step.
2. **The app has still never run on a device or emulator** (no KVM on this box).
Everything is compile + unit-test + Paparazzi evidence only. Installing the
signed APK on a real phone is the only way past this, and it needs the user.
3. **R8 is off.** Acceptable per F3's prompt, but the release APK is 41.8MB.
Turning it on requires proving Room/Retrofit/kotlinx-serialization/ML Kit
survive minification.
4. Still-open user questions, unchanged: where the server will actually live, and
the two account emails for `create-user.sh`.
+1
View File
@@ -0,0 +1 @@
0bdf98e8-c28a-4069-ad84-87e674234136
+19
View File
@@ -0,0 +1,19 @@
=== WAVE4-DONE written 2026-09-08T21:34Z by the Opus orchestrator ===
NOT written by service-worker.sh. F3-release's FILES were all complete and
untouched for ~50 min; it was stuck re-running verification it could not finish
(600s background-wait ceiling, then a quota wait). The orchestrator ran the
verification itself, accepted the work, and stopped the service.
Independently verified by the orchestrator (not self-reported):
./tasks/gw assembleDebug exit 0
./tasks/gw testDebugUnitTest exit 0 -- 102 tests, 1 skipped, 0 failures, 0 errors
./tasks/gw assembleRelease exit 0
app-release.apk 41,777,344 bytes, V2-signed CN=Bookshelf,O=Montanaro (real
release key, not the debug cert)
keystore + keystore.properties confirmed gitignored, NOT in the commit
skipped test = LiveSyncTest (opt-in, needs the live server)
All four F3 tasks DONE: settings-email fix, 5 remaining screens' Paparazzi
shots (light+dark), release signing + signed APK, top-level README.
NOT delivered: F3's own written report, incl. its candid design critique of
the rendered screens. The orchestrator must eyeball the PNGs instead.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# service-worker.sh <task-name> <prompt-file> <sentinel-name>
#
# Runs ONE worker under the sprite service supervisor instead of `setsid nohup`.
#
# WHY THIS EXISTS (wave-4 post-mortem, second failure):
# `setsid nohup ./tasks/run-task.sh ...` does NOT reliably outlive the orchestrator.
# On 09-06 at 19:04 the orchestrator's session was torn down and the detached worker
# AND its wave-guard died with it -- no reboot (uptime was continuous), so this was a
# teardown kill, not the hazard-#5 suspend. `setsid` did not save it.
#
# /.sprite/llm.txt: services are "long-running processes that persist across reboots"
# and "services and sessions keep sprites alive". A service therefore fixes BOTH
# failure modes at once -- it survives orchestrator teardown AND holds the box awake --
# which makes the task-lease guard redundant.
#
# IDEMPOTENCE MATTERS HERE: the supervisor restarts services. Without the sentinel
# guard below, a finished wave would be re-run on every restart and burn quota
# redoing completed work.
set -u
NAME="$1"; PROMPT="$2"; SENT="$3"
L="$HOME/bookshelf/logs"; DONE="$L/$SENT"
SVC="${SVC_NAME:-bookshelf-worker}"
log() { echo "[$(date -Is)] service-worker: $*" >> "$L/service-worker.log"; }
# Already finished -> do not re-run. Idle briefly so a restart loop stays slow.
if [ -f "$DONE" ]; then
log "$SENT already exists; nothing to do (service can be deleted)"
sleep 300
exit 0
fi
log "starting worker $NAME (prompt=$PROMPT, sentinel=$SENT)"
"$HOME/bookshelf/tasks/run-task.sh" "$NAME" "$PROMPT"
rc=$?
log "worker $NAME exited rc=$rc; writing $SENT"
{
echo "=== $SENT written $(date -Is) ==="
echo "Worker finished under the service supervisor. The orchestrator was NOT"
echo "necessarily alive for this. run-task.sh exit code: $rc"
echo
echo "--- $NAME ---"
grep -hE 'SUCCESS|GIVING UP|WALL CLOCK|QUOTA' "$L/$NAME.state" 2>/dev/null | tail -5
if [ -s "$L/$NAME.json" ]; then
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring)' "$L/$NAME.json" 2>/dev/null
jq -r '"is_error=" + ((.is_error//false)|tostring)' "$L/$NAME.json" 2>/dev/null
else
echo "!! $NAME.json is 0 bytes -> worker was KILLED, not finished (hazard #3)"
fi
echo
echo "NEXT: orchestrator must independently verify before accepting:"
echo " cd ~/bookshelf && ./tasks/gw assembleDebug && ./tasks/gw testDebugUnitTest"
echo " git status --porcelain # what did the worker actually touch?"
} > "$DONE"
sync
# Sentinel is durable now, so it is safe to stop ourselves. This lets the sprite
# suspend instead of idling hot on the owner's dime.
log "stopping service $SVC so the sprite can suspend"
sprite-env services stop "$SVC" >/dev/null 2>&1
sleep 60