mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-12 02:28:05 +00:00
fix(android): device-feedback round 1
- decode fs.stat-derived epoch fields leniently (fractional mtimeMs from real hubs broke machines/files decode and pinned the offline banner) - offline banner: only a failed sessions fetch counts; machines/baseline failures are advisory; live SSE emission clears it and seeds the unread baseline (all-rows-unread gray dot cascade) - session row: single weighted title (short names no longer truncated at half width), unread dot moved beside the timestamp - composer restyle: borderless input pill with inline mic, hand-drawn vector glyphs replacing emoji icons, 42dp round actions, park-draft relocated to the session overflow menu
This commit is contained in:
@@ -207,7 +207,8 @@ data class MachineDirectoryEntry(
|
||||
/** `'file' | 'directory' | 'other'`. */
|
||||
val type: String,
|
||||
val size: Long? = null,
|
||||
/** Epoch ms. */
|
||||
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val modified: Long? = null,
|
||||
val isGitRepo: Boolean? = null,
|
||||
)
|
||||
|
||||
@@ -29,7 +29,8 @@ data class FileReadResponse(
|
||||
val content: String? = null,
|
||||
/** Bytes on disk. */
|
||||
val size: Long? = null,
|
||||
/** Epoch ms. */
|
||||
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val modified: Long? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -51,7 +52,8 @@ data class FileSearchItem(
|
||||
/** `'file' | 'folder'` (search results are files in practice). */
|
||||
val fileType: String,
|
||||
val size: Long? = null,
|
||||
/** Epoch ms. */
|
||||
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val modified: Long? = null,
|
||||
)
|
||||
|
||||
@@ -73,6 +75,7 @@ data class DirectoryEntry(
|
||||
/** `'file' | 'directory' | 'other'`. */
|
||||
val type: String,
|
||||
val size: Long? = null,
|
||||
/** Epoch ms. */
|
||||
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val modified: Long? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package app.hapi.protocol.wire
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
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
|
||||
|
||||
/**
|
||||
* Epoch-milliseconds field that the hub may deliver as a **fractional**
|
||||
* number: anything derived from `fs.stat` `mtimeMs` (file `modified`,
|
||||
* machine `startedCliMtimeMs`/`installedCliMtimeMs`) carries sub-millisecond
|
||||
* precision, e.g. `1786932205158.1177` — observed in real hub data. zod's
|
||||
* `z.number()` accepts that; a plain Kotlin `Long` decode throws and (before
|
||||
* this) took the whole machines/files response down with it.
|
||||
*
|
||||
* Decodes any JSON number and truncates to ms. Encoding writes a plain long
|
||||
* (we never need to reproduce the fraction).
|
||||
*/
|
||||
object LenientEpochMs : KSerializer<Long> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("app.hapi.protocol.wire.LenientEpochMs", PrimitiveKind.DOUBLE)
|
||||
|
||||
override fun deserialize(decoder: Decoder): Long = decoder.decodeDouble().toLong()
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Long) {
|
||||
encoder.encodeLong(value)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ data class MachineMetadata(
|
||||
val workspaceRoots: List<String>? = null,
|
||||
/** Machine-scoped RPC capability ids this runner registers. */
|
||||
val capabilities: List<String>? = null,
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val startedCliMtimeMs: Long? = null,
|
||||
@Serializable(with = LenientEpochMs::class)
|
||||
val installedCliMtimeMs: Long? = null,
|
||||
val supervisedRestart: Boolean? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app.hapi.protocol.wire
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Regression: `fs.stat`-derived epoch fields arrive **fractional** from real
|
||||
* hubs (`"startedCliMtimeMs":1786932205158.1177` observed in a live DB); a
|
||||
* plain `Long` decode threw and took the whole machines/files response down —
|
||||
* on device that pinned an "offline" banner over a perfectly live list.
|
||||
*/
|
||||
class LenientEpochMsTest {
|
||||
@Test
|
||||
fun `machine metadata decodes fractional cli mtimes`() {
|
||||
val machine = HapiJson.decodeFromString(
|
||||
Machine.serializer(),
|
||||
"""
|
||||
{"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2,
|
||||
"active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0,
|
||||
"metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0",
|
||||
"startedCliMtimeMs":1786932205158.1177,
|
||||
"installedCliMtimeMs":1786501585709.12}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs)
|
||||
assertEquals(1786501585709L, machine.metadata?.installedCliMtimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `integral mtimes still decode`() {
|
||||
val machine = HapiJson.decodeFromString(
|
||||
Machine.serializer(),
|
||||
"""
|
||||
{"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2,
|
||||
"active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0,
|
||||
"metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0",
|
||||
"startedCliMtimeMs":1786932205158}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory entries and file reads decode fractional modified`() {
|
||||
val dir = HapiJson.decodeFromString(
|
||||
DirectoryEntry.serializer(),
|
||||
"""{"name":"src","type":"directory","modified":1786932205158.9}""",
|
||||
)
|
||||
assertEquals(1786932205158L, dir.modified)
|
||||
|
||||
val machineDir = HapiJson.decodeFromString(
|
||||
MachineDirectoryEntry.serializer(),
|
||||
"""{"name":"repo","type":"directory","modified":1786932205158.5,"isGitRepo":true}""",
|
||||
)
|
||||
assertEquals(1786932205158L, machineDir.modified)
|
||||
|
||||
val read = HapiJson.decodeFromString(
|
||||
FileReadResponse.serializer(),
|
||||
"""{"success":true,"content":"aGk=","size":2,"modified":1786932205158.0001}""",
|
||||
)
|
||||
assertEquals(1786932205158L, read.modified)
|
||||
|
||||
val search = HapiJson.decodeFromString(
|
||||
FileSearchItem.serializer(),
|
||||
"""{"fileName":"a.ts","filePath":"src","fullPath":"src/a.ts","fileType":"file","modified":1786932205159.75}""",
|
||||
)
|
||||
assertEquals(1786932205159L, search.modified)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user