feat(pet): follow fresh local telemetry across native hosts

Add Android local-document following through the shared live cursor. Existing,
duplicate and restarted packets establish a baseline; suspension discards old
observations and sound. Apple and browser use the same cursor, and native resume
uses the actual world clock. Keep file IO bounded and Android polling off the
world worker. Document producer and device-transfer limits.

Validation:
- npm --prefix pet run check: 48 passed, 0 failed.
- npm test: 66 package + 12 SDK + 446 web passed, 0 failed.
- npm run check:web: passed, 0 errors and 2 existing image warnings.
- TUI Watch: 12 passed, 0 failed; iOS Simulator build passed.
- Apple: 11 checkpoint/live-file workflows passed; macOS bundle built.
- Android: 9 native + 2 Compose lifecycle tests passed, APK/lint passed.
  Lifecycle rerun follows fixture isolation; prior mixed run had 1 failure.
- Previous 45a1130 fails stale-file browser and normal-tick native-resume
  regressions. Runtime fixture now waits for recorded coverage, not a fixed sleep.

Hosted CI is separate: 45a1130 macOS reported one subagent timeout-count
failure. That unchanged test passes locally; its hosted retry remains pending.
No release or deployment is claimed.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-12 12:33:34 -07:00
1 parent 45a1130c19
commit 43e6f02ea3
24 files changed
+736 -105

No files matched your search

+61 -9
View File
@@ -19,6 +19,7 @@ class PetNative {
engine = new pet_engine_js_1.PetEngineTelemetry();
engineTick = 0;
segment;
liveTape = new pet_telemetry_js_1.PetLiveTape();
constructor(pointsJSON, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion = 2) {
const points = JSON.parse(pointsJSON);
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
@@ -30,6 +31,14 @@ class PetNative {
interact(kind, x, y) { this.world.interact(kind, x, y); }
interactions() { return JSON.stringify(this.world.interactions); }
accept(packet) { this.world.acceptTelemetry(JSON.parse(packet)); }
acceptLiveTail(text) {
const packet = this.liveTape.readTail(text);
if (!packet)
return false;
this.world.acceptTelemetry(packet);
return true;
}
resetLiveInput() { this.liveTape.reset(); return this.resumeEngine(); }
recording(withCheckpoint = false) { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment() { return this.world.needsSegment; }
prepareSegment() { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
@@ -47,6 +56,7 @@ class PetNative {
throw new Error('Native habitat exceeds 8 MiB.');
const points = this.world.sim.p.map(p => [p.hx, p.hy]);
this.world = pet_world_js_1.PetWorld.fromRecording(points, JSON.parse(text));
this.liveTape.reset();
this.segment = undefined;
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
this.engine = new pet_engine_js_1.PetEngineTelemetry();
@@ -54,14 +64,8 @@ class PetNative {
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine() {
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24)
throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) {
this.world.step(1 / 30, { motion: false, sensitivity: 1 });
this.engineTick++;
}
this.world.resumeObservation();
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
this.engine = new pet_engine_js_1.PetEngineTelemetry();
this.world.voices = [];
return this.world.frame.timeMs;
@@ -375,6 +379,16 @@ class PetWorld {
world.voices = structuredClone(c.voices);
return world;
}
/** Resume observation beyond all already accepted live packets, without
* replaying their sound or exposing a stale request as current work. */
resumeObservation() {
const last = this.tapeLog.at(-1), end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.tick > 24)
throw new Error('Only a live recording can resume observation.');
while (this.tick < end)
this.step(1 / 30, { motion: false, sensitivity: 1 });
this.voices = [];
}
/** Branch at the current playhead; input is journalled for the next fixed tick.
* A live touch never needs to re-simulate the creature's entire lifetime. */
interact(kind, x, y) {
@@ -1040,7 +1054,7 @@ function runTape(sim, rows, opts) {
factories["pet-telemetry"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PET_BIN_MS = void 0;
exports.PetLiveTape = exports.PET_BIN_MS = void 0;
exports.validatePetBucket = validatePetBucket;
exports.decodePetJSONL = decodePetJSONL;
exports.compilePetTelemetry = compilePetTelemetry;
@@ -1073,6 +1087,44 @@ function decodePetJSONL(text) {
return rows.map((row, i) => { validatePetBucket(row); if (row.sequence !== i)
throw new Error('Non-contiguous pet tape.'); return row; });
}
/** A live file must advance before its contents count as a new observation.
* Existing bytes, duplicate samples and a restarted sequence establish a
* baseline; they never replay an old onset or human request. Drivers supply a
* bounded tail and reset this cursor after suspension or a new attachment. */
class PetLiveTape {
sequence;
reset() { this.sequence = undefined; }
readTail(text) {
if (!text) {
this.reset();
return;
}
if (text.length > 262_144) {
this.reset();
throw new Error('Live pet input exceeds its tail limit.');
}
if (!text.endsWith('\n'))
return;
const line = text.trimEnd().split('\n').at(-1);
if (!line)
return;
let packet;
try {
packet = JSON.parse(line);
validatePetBucket(packet);
}
catch (error) {
this.reset();
throw error;
}
const previous = this.sequence;
this.sequence = packet.sequence;
if (previous === undefined || packet.sequence <= previous)
return;
return packet;
}
}
exports.PetLiveTape = PetLiveTape;
const order = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const keyOf = (e) => JSON.stringify([e.traceId, e.id]);
const isContainer = (e) => e.attributes['whalesong.container'] === true
+23 -8
View File
@@ -17,11 +17,12 @@ npm test
npm run check:web
```
The pet suite currently contains 44 tests: event occupancy/unknown coverage,
The pet suite currently contains 48 tests: event occupancy/unknown coverage,
late failures, human request pairing, read-only local SSE reconnect/cursor
recovery and cancellation after garbage collection, deterministic world/score/PCM,
checkpoint integrity and continuation, immutable segment boundaries, pending input
retention, browser saves overlapping source changes, expression-version validation and legacy replay.
retention, browser saves overlapping source changes, live-file freshness/restarts,
delayed browser reads crossing suspension, expression-version validation and legacy replay.
The standalone verifier compares 380 checkpoints across baseline and edge tapes,
each animated and still, under expression versions 1 and 2. Version 1 must also
match pinned pre-transformation golden digests. Omission of Swift is explicit in its output.
@@ -63,7 +64,8 @@ script generates and builds the Simulator project directly from the shared
source, without first building macOS. Open `pet/ios/CodewhalePet.xcodeproj` to run.
`check-apple.sh` compiles the actual host and runs checkpoint/storage/recovery
workflows, including a generated two-hour synthetic unknown recording, segment
publication and a save conflict that must preserve the running world. It writes
publication, a save conflict that must preserve the running world, and actual
live-file appends, pause/resume, in-place restarts, replacement and recreation. It writes
only disposable fixtures under ignored conformance results and temporary storage.
Kotlin 2.3.0 can instead be on PATH; its verifier builds only the particle core
and conformance runner. The actual Compose application is a separate Gradle build:
@@ -75,8 +77,10 @@ cd pet/android
```
Use JDK 17 and Android SDK 35; connect a device/emulator for the second command.
The nine instrumentation tests run the real QuickJS binding, Kotlin renderer,
PCM cursor, storage/recovery, immutable segments and Compose lifecycle. See [Android](android/README.md).
The eleven instrumentation tests run the real QuickJS binding, Kotlin renderer,
PCM cursor, storage/recovery, immutable segments and Compose lifecycle. A test-only
document provider delivers synthetic bytes through real descriptor IO; live pause,
background, restart and malformed input reach the actual ViewModel. See [Android](android/README.md).
## Review direction
@@ -89,7 +93,7 @@ open junctions; the same particle identities return to the whale at rest.
## Local evidence before publication (2026-09-12)
- Packaged pet: 44 tests passed; existing Whalesong consumers: 306 passed after
- Packaged pet: 48 tests passed; existing Whalesong consumers: 306 passed after
canonical source relocation. These are overlapping suites, not additive coverage.
- Product Node gate: 66 package, 12 SDK and 446 web tests passed; production web
check subsequently passed with the GitHub release fetch available.
@@ -101,9 +105,10 @@ open junctions; the same particle identities return to the whale at rest.
tests with one outdated Watch-tab golden; that header was corrected. Subsequent
hosted product CI on `e69e99b` passed Linux, macOS and Windows tests, Rust lint
and the applicable safety/security checks. Each later commit needs its own verdict.
- Apple: ten checkpoint workflows passed; iOS Simulator exercised two-hour
- Apple: eleven checkpoint/live-file workflows passed; iOS Simulator exercised two-hour
restoration and visible corruption recovery without overwriting the damaged file.
- Android: debug APK and lint build pass; nine instrumentation tests pass on
- Android: debug APK and lint build pass; nine native tests and both Compose
lifecycle tests pass on
Android 15 ARM64, including 4,800 frames, checkpoint continuation, exact PCM,
lifecycle and storage recovery. The emulator audio sink runs without host
speaker output; this does not establish physical listening or power quality.
@@ -133,6 +138,16 @@ open junctions; the same particle identities return to the whale at rest.
worlds and accepting an input. The previous `5c70db4` controller fails by
treating the pending save as a failure; the correction waits, archives the new
input, and still preserves the source when a real failed save is canceled.
- Live freshness: the previous `45a1130` browser controller fails the stale-file
attachment regression. Its native core also throws on live resume after normal
host ticking because the Engine-only clock has not advanced. The shared resume
path now uses the world clock, drops old voices and retains an exact checkpoint.
Browser tests also discard a read started before suspension; this is controller
evidence, not a claim about a physical device or browser file-picker support.
- Runtime fixture timing: the HTTP test now waits for recorded waiting/error
coverage before answering, and for two sealed unknown bins before stopping.
Its former fixed sleep occasionally stopped the child before those final bins
on a busy runner. The receipt assertions and privacy checks remain unchanged.
- Authenticated read-only attachment to an existing local Runtime 0.9.13 session
journal passed. An 18-second recorder run produced 45 contiguous unknown buckets
and stopped cleanly in 41 ms after Ctrl+C, without treating historical work as
+13 -2
View File
@@ -121,7 +121,18 @@ old saved habitats replayable while letting new worlds change their visual form.
The older TSV is a particle conformance tape and cannot preserve audio onsets.
macOS watches `~/.codewhale/pet-state`. iOS watches `pet-state` in Documents.
File replacement and source restart are handled by the existing host watchers.
Android uses More → Follow local tape to select a seekable device document.
The browser's Follow local tape uses a user-granted File System Access handle.
All three file readers use the same shared live cursor: the first complete packet
establishes a baseline, and only an advancing sequence becomes an observation.
Duplicate input, a restarted sequence, or bytes read during suspension cannot
replay an old onset or human request. Missing/invalid input expires to unknown;
resuming advances beyond already accepted input without replaying its sound.
Apple watches appends and directory replacement. Android reads a bounded 256 KiB
tail on an IO worker, closes it on pause/background, and discards delayed delivery.
The file must be updated by a producer; selecting a completed tape does not make
it live. Use Import to replay that tape. Device files are not automatically synced
from the desktop recorder.
Authenticated read-only attachment has been exercised against a running local
Runtime 0.9.13 and an existing session journal. Old completed work remains unknown
at the recorder's current clock. The recorder also closes an idle stream after
@@ -134,7 +145,7 @@ The browser commits checkpoint and recording together with an optimistic
IndexedDB revision. A saved live source reopens as Replay until explicitly
reattached. TUI Watch saves under the owning session's `artifacts/pet/habitat.json`;
Apple hosts keep source-specific files in Application Support/CodewhalePet.
Android keeps separate wild/demo/recording habitats in private app storage.
Android keeps separate wild/demo/recording/live habitats in private app storage.
Native writes are private and atomic, use a writer lock and content revision,
and preserve corrupt files or external edits. Recovery is visible in the UI.
+19 -3
View File
@@ -28,7 +28,19 @@ Checkpoint-bearing recordings resume their exact world; exports without a
checkpoint replay from the segment's start (or time zero for version 1). Missing expression versions retain v1.
Both kinds can be exported again. Autosave and native import are bounded to
8 MiB. Export includes the current checkpoint and writes small chunks through a
private staging file, up to 64 MiB; larger-than-autosave files open in the browser. There is no live companion connection yet.
private staging file, up to 64 MiB; larger-than-autosave files open in the browser.
More → Follow local tape selects a seekable document through Android's file
picker. A local producer must keep appending canonical PetBucket JSONL to it;
desktop recorder output is not automatically transferred to the device. The
first complete packet establishes a baseline. Only later sequence advancement
is observed, so selecting an old file cannot resurrect its last human request.
The shared cursor also handles sequence restarts, invalid input and duplicates.
Polling reads at most 256 KiB every 400 ms on an IO worker. Pause/background
closes the reader; resume establishes a fresh baseline and discards old sound.
Unavailable, non-seekable or delayed files leave the live world unobserved.
The selected document's read grant and URI are retained when its provider permits;
use Follow again if access expires. This adds no network permission.
Sound starts off on each process launch. One native AudioTrack receives the
core's stereo 48 kHz float PCM. A bounded queue drops late output. Pause,
@@ -51,11 +63,15 @@ cover that binding's older runtime. There are no Java host bindings or remote
script loads. Gradle packages `../ios/Resources/pet-native.js` and its demo
directly; run `npm --prefix pet run sync` after changing the canonical core.
Nine instrumentation tests exercise the real embedded engine, 4,800 shared
Eleven instrumentation tests exercise the real embedded engine, 4,800 shared
world frames and Kotlin digests, 3,000 additional checkpoint continuation
frames, version/import boundaries, sample-exact PCM, atomic storage/recovery,
the Compose pause/still/audio/background lifecycle, and recovery export after
a store conflict or with 90,000 pending interactions beyond the autosave limit.
They also check segment publication, exact continuation, archive corruption and
native restoration of long particle clocks beyond the former 24-hour limit. The separate
native restoration of long particle clocks beyond the former 24-hour limit.
Live tests use an instrumentation-only document provider to exercise actual
ContentResolver/descriptor reads, the ViewModel and Compose lifecycle, producer
restart and malformed input. The native live-resume test checks Kotlin geometry
against the resumed shared checkpoint and verifies that old requests stay unknown. The separate
`verify.sh` retains all 380 pure Kotlin conformance checkpoints.
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Instrumentation-only, synthetic local-file fixture. -->
<provider android:name="codewhale.pet.PetLiveTestProvider"
android:authorities="dev.shannonlabs.codewhale.pet.test.live"
android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,48 @@
package codewhale.pet;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.atomic.AtomicInteger;
/** Test APK only. Uses framework/Java APIs because this separate provider
* process does not load the target app's Kotlin or QuickJS dependencies. */
public class PetLiveTestProvider extends ContentProvider {
private final AtomicInteger reads = new AtomicInteger();
private File tape() { return new File(getContext().getCacheDir(), "synthetic-live.jsonl"); }
@Override public boolean onCreate() { return true; }
@Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
if (!"/tape".equals(uri.getPath()) || !"r".equals(mode)) throw new FileNotFoundException();
reads.incrementAndGet();
return ParcelFileDescriptor.open(tape(), ParcelFileDescriptor.MODE_READ_ONLY);
}
@Override public Bundle call(String method, String arg, Bundle extras) {
try {
switch (method) {
case "replace": case "append":
if (arg == null || arg.length() > 65_536) throw new IllegalArgumentException();
if (method.equals("append")) Files.write(tape().toPath(), arg.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
else Files.write(tape().toPath(), arg.getBytes(StandardCharsets.UTF_8));
break;
case "delete": Files.deleteIfExists(tape().toPath()); break;
case "stats": break;
default: throw new IllegalArgumentException("Unknown fixture action");
}
} catch (IOException error) { throw new IllegalStateException(error); }
Bundle result = new Bundle(); result.putInt("reads", reads.get()); return result;
}
@Override public String getType(Uri uri) { return "text/plain"; }
@Override public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sort) { return null; }
@Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); }
@Override public int update(Uri uri, ContentValues values, String selection, String[] args) { throw new UnsupportedOperationException(); }
@Override public int delete(Uri uri, String selection, String[] args) { throw new UnsupportedOperationException(); }
}
@@ -5,6 +5,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import android.net.Uri
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
@@ -50,4 +52,67 @@ class PetLifecycleTest {
compose.onNodeWithText("Still on").performClick()
assertFalse(model.ui.value.sound)
}
@Test fun localLiveFileStaysFreshAcrossPauseBackgroundAndProducerRestart() {
val model = ViewModelProvider(compose.activity)[PetViewModel::class.java]
compose.waitUntil(15_000) { model.ui.value.scene != null }
val resolver = compose.activity.contentResolver
val uri = Uri.parse("content://dev.shannonlabs.codewhale.pet.test.live/tape")
val human = compose.activity.assets.open("demo.jsonl").bufferedReader().use { input ->
input.lineSequence().map(::JSONObject).first { it.getString("channel") == "human" && it.getBoolean("waiting") }.toString()
}
fun packet(sequence: Int) = JSONObject(human).put("sequence", sequence).put("simTimeMs", sequence * 400).toString() + "\n"
fun action(name: String, text: String? = null) = resolver.call(uri, name, text, null)!!.getInt("reads")
fun observed() = model.ui.value.scene!!.state.observed >= .92
action("replace", packet(10))
val initialReads = action("stats")
compose.runOnIdle { model.setPaused(false); model.followLocalTape(uri) }
try {
compose.waitUntil(10_000) { model.ui.value.mode == PetMode.LIVE && action("stats") >= initialReads + 2 }
assertFalse("Existing file must establish a baseline", observed())
action("append", packet(11))
compose.waitUntil(5_000) { observed() && model.ui.value.scene!!.state.channel == "human" }
compose.onNodeWithText("Pause").performClick()
compose.waitUntil(5_000) { model.ui.value.paused && !model.ui.value.running }
val stoppedReads = action("stats")
action("append", packet(12)); Thread.sleep(600)
assertEquals("Pause closes live polling", stoppedReads, action("stats"))
compose.onNodeWithText("Resume").performClick()
compose.waitUntil(5_000) { model.ui.value.running && action("stats") >= stoppedReads + 2 }
assertFalse("Background bytes are not fresh requests", observed())
assertEquals("none", model.ui.value.scene!!.needs)
action("append", packet(13))
compose.waitUntil(5_000) { observed() }
compose.waitUntil(5_000) { !observed() }
val beforeRestart = action("stats")
action("replace", packet(0))
compose.waitUntil(5_000) { action("stats") >= beforeRestart + 2 }
assertFalse(observed())
action("append", packet(1))
compose.waitUntil(5_000) { observed() }
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.waitUntil(5_000) { !model.ui.value.running }
val backgroundReads = action("stats")
action("append", packet(2)); Thread.sleep(600)
assertEquals(backgroundReads, action("stats"))
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitUntil(5_000) { model.ui.value.running && action("stats") >= backgroundReads + 2 }
assertFalse(observed())
val beforeError = model.ui.value.scene!!.timeMs
action("replace", "{invalid}\n")
compose.waitUntil(5_000) { model.ui.value.message?.startsWith("Invalid local telemetry") == true }
compose.waitUntil(5_000) { model.ui.value.scene!!.timeMs > beforeError + 800 }
assertFalse(model.ui.value.paused)
assertFalse(observed())
} finally {
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.runOnIdle { model.mode(PetMode.WILD) }
compose.waitUntil(5_000) { model.ui.value.mode == PetMode.WILD }
// Settle this activity's final frame/save before the next test opens
// the same private habitat with a new ViewModel and store revision.
compose.runOnIdle { model.setPaused(true) }
compose.waitUntil(5_000) { model.ui.value.paused && !model.ui.value.running && model.ui.value.savedAtMs == model.ui.value.scene!!.timeMs }
action("delete")
}
}
}
@@ -17,6 +17,35 @@ class PetNativeTest {
private fun asset(name: String) = context.assets.open(name).bufferedReader().use { it.readText() }
private fun core(saved: String? = null) = PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), asset("demo.jsonl"), saved)
@Test fun liveResumeKeepsNativeGeometryAlignedAndDoesNotReplayHumanInput() {
val human = asset("demo.jsonl").lineSequence().filter { it.isNotBlank() }.map(::JSONObject)
.first { it.getString("channel") == "human" && it.getBoolean("waiting") }.toString()
fun packet(sequence: Int) = JSONObject(human).put("sequence", sequence).put("simTimeMs", sequence * 400).toString() + "\n"
PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), live = true).use { world ->
repeat(90) { world.tick(true) }
assertFalse(world.acceptLiveTail(packet(10)))
assertTrue(world.acceptLiveTail(packet(11)))
repeat(12) { world.tick(true) }
assertEquals("human", world.scene().state.channel)
val before = world.timeMs
world.resumeLiveInput()
assertTrue(world.timeMs - before in 0.0..800.0)
assertEquals(0.0, world.scene().state.observed, 0.0)
assertEquals("none", world.scene().needs)
assertEquals(0, world.voices.length())
assertEquals(world.scene().digest, petDigest(world.sim))
assertFalse(world.acceptLiveTail(packet(12)))
assertTrue(world.acceptLiveTail(packet(13)))
repeat(12) { assertEquals(world.tick(true).digest, petDigest(world.sim)) }
assertEquals("human", world.scene().state.channel)
PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), saved = world.recording(), live = true).use { restored ->
assertEquals(0.0, restored.scene().state.observed, 0.0)
assertEquals(restored.scene().digest, petDigest(restored.sim))
assertFalse(restored.acceptLiveTail(packet(14)))
}
}
}
@Test fun nativeRendererMatchesEverySharedWorldFrameAndResumesCheckpoint() {
for (motion in listOf(true, false)) {
core().use { world ->
@@ -70,6 +70,7 @@ class PetActivity : ComponentActivity() {
private fun PetScreen(model: PetViewModel) {
val ui by model.ui.collectAsStateWithLifecycle()
val import = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::importRecording) }
val follow = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { it?.let(model::followLocalTape) }
val export = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecording) }
val recovery = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecovery) }
var selectedArchive by rememberSaveable { mutableStateOf<String?>(null) }
@@ -106,6 +107,7 @@ private fun PetScreen(model: PetViewModel) {
TextButton(onClick = { menu = true }) { Text("More") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Import recording") }, onClick = { menu = false; import.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) })
DropdownMenuItem(text = { Text("Follow local tape") }, onClick = { menu = false; follow.launch(arrayOf("*/*")) })
DropdownMenuItem(text = { Text("Export recording") }, enabled = ui.scene != null, onClick = { menu = false; export.launch("codewhale-pet.json") })
DropdownMenuItem(text = { Text("Earlier recordings") }, enabled = ui.archives.isNotEmpty(), onClick = { menu = false; showArchives = true })
DropdownMenuItem(text = { Text("Reopen saved habitat") }, onClick = { menu = false; reload = true })
@@ -152,7 +154,7 @@ private fun Habitat(ui: PetUiState, modifier: Modifier) {
private fun Controls(ui: PetUiState, model: PetViewModel, modifier: Modifier) {
Column(modifier.padding(horizontal = 18.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(PetMode.WILD, PetMode.DEMO).forEach { mode ->
(listOf(PetMode.WILD, PetMode.DEMO) + if (ui.canFollowLive) listOf(PetMode.LIVE) else emptyList()).forEach { mode ->
FilterChip(selected = ui.mode == mode, onClick = { model.mode(mode) }, label = { Text(mode.label) })
}
if (ui.mode == PetMode.RECORDING) FilterChip(selected = true, onClick = {}, label = { Text("Recording") })
@@ -11,7 +11,7 @@ import java.security.MessageDigest
/** Private, bounded, atomic recordings. The revision check also protects a
* second window/process from silently overwriting a newer habitat. */
class PetHabitatStore(private val directory: File, private val name: String) {
init { require(name in setOf("wild", "demo", "recording")) }
init { require(name in setOf("wild", "demo", "recording", "live")) }
private val file = AtomicFile(File(directory, "pet-$name.json"))
private val lockFile = File(directory, "pet-$name.lock")
var revision: String? = null; private set
@@ -0,0 +1,36 @@
package codewhale.pet
import android.content.ContentResolver
import android.net.Uri
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import java.nio.ByteBuffer
/** Read only the tail of a user-selected, seekable local document. Reads stay
* off the world worker; cancellation closes the descriptor and provider request.
* Packet validation, sequence restarts and freshness belong to the shared core. */
class PetLiveFile(private val resolver: ContentResolver, private val uri: Uri) : AutoCloseable {
private val cancellation = CancellationSignal()
@Volatile private var descriptor: ParcelFileDescriptor? = null
init { require(uri.scheme == "content") { "Choose a local document through the file picker." } }
fun readTail(): String {
val opened = resolver.openFileDescriptor(uri, "r", cancellation) ?: error("The local tape could not be opened.")
descriptor = opened
try {
cancellation.throwIfCanceled()
return ParcelFileDescriptor.AutoCloseInputStream(opened).use { stream ->
val channel = stream.channel
val size = channel.size()
val length = minOf(size, 262_144L).toInt()
channel.position(size - length)
val buffer = ByteBuffer.allocate(length)
while (buffer.hasRemaining() && channel.read(buffer) > 0) cancellation.throwIfCanceled()
String(buffer.array(), 0, buffer.position(), Charsets.UTF_8)
}
} finally { descriptor = null; opened.close() }
}
override fun close() {
cancellation.cancel()
runCatching { descriptor?.close() }
}
}
@@ -18,7 +18,7 @@ data class PetScene(
/** Confined to one worker. QuickJS runs the committed world, validation and
* score; Kotlin only projects its state into the existing particle renderer. */
@OptIn(EngineApi::class)
class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved: String? = null) : AutoCloseable {
class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved: String? = null, live: Boolean = false) : AutoCloseable {
private val js = QuickJs.create()
private var deadline = Long.MAX_VALUE
val sim: PetSim
@@ -44,18 +44,15 @@ class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved
}.toList()
require(points.size == 980)
val body = JSONArray(points.map { listOf(it.first, it.second) }).toString()
evaluate("globalThis.pet = new PetNative(${quote(body)}, ${quote(tape)}); undefined")
evaluate("globalThis.pet = new PetNative(${quote(body)}, ${quote(tape)}, '[]', $live); undefined")
if (saved != null) {
require(saved.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB." }
evaluate("pet.restoreRecording(${quote(saved)})", 10_000)
if (live) evaluate("pet.resetLiveInput()")
}
frame = JSONObject(string("pet.snapshot()"))
sim = PetSim(points)
if (saved != null) {
val c = JSONObject(string("pet.checkpoint()")).getJSONObject("sim")
sim.restoreValidated(decodeCheckpoint(c))
check(petDigest(sim) == frame.getString("digest")) { "Restored particles differ from the shared world." }
}
if (saved != null) restoreProjection()
} catch (error: Throwable) {
js.close()
throw error
@@ -87,6 +84,15 @@ class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved
}
fun interact(food: Boolean) { evaluate("pet.interact('${if (food) "food" else "attention"}', 0.2, -0.15)") }
fun acceptLiveTail(text: String): Boolean = evaluate("pet.acceptLiveTail(${quote(text)})") == true
fun resumeLiveInput() {
evaluate("pet.resetLiveInput()")
frame = JSONObject(string("pet.snapshot()")); restoreProjection()
}
private fun restoreProjection() {
sim.restoreValidated(decodeCheckpoint(JSONObject(string("pet.checkpoint()")).getJSONObject("sim")))
check(petDigest(sim) == frame.getString("digest")) { "Restored particles differ from the shared world." }
}
fun recording(): String = string("pet.recording(true)").also {
require(it.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB; export the recording." }
}
@@ -1,6 +1,7 @@
package codewhale.pet
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import java.io.File
@@ -8,8 +9,11 @@ import org.json.JSONObject
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -24,11 +28,13 @@ enum class PetMode(val key: String, val label: String, val detail: String) {
WILD("wild", "Wild", "Simulated creature · no session attached"),
DEMO("demo", "Event demo", "Synthetic telemetry"),
RECORDING("recording", "Recording", "Saved telemetry · replay"),
LIVE("live", "Live", "Following a local telemetry file"),
}
data class PetUiState(val scene: PetScene? = null, val mode: PetMode = PetMode.WILD,
val paused: Boolean = false, val sound: Boolean = false, val still: Boolean = false,
val systemStill: Boolean = false, val message: String? = null, val savedAtMs: Double? = null,
val archives: List<String> = emptyList(), val canExportRecovery: Boolean = false, val running: Boolean = false)
val archives: List<String> = emptyList(), val canExportRecovery: Boolean = false, val running: Boolean = false,
val canFollowLive: Boolean = false)
/** A single actor owns core, audio cursor and save revision. Compose receives
* immutable projections and never reads a particle while it is being stepped. */
@@ -37,6 +43,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
data class Mode(val mode: PetMode) : Command
data class Interact(val food: Boolean) : Command
data class Import(val uri: Uri) : Command
data class Follow(val uri: Uri) : Command
data class Export(val uri: Uri) : Command
data class ExportRecovery(val uri: Uri) : Command
data class ExportArchive(val uri: Uri, val name: String) : Command
@@ -44,8 +51,9 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
data object Reload : Command
}
private val prefs = application.getSharedPreferences("pet", 0)
private var liveUri = prefs.getString("live-uri", null)?.let(Uri::parse)?.takeIf { it.scheme == "content" }
private val initialMode = PetMode.entries.firstOrNull { it.key == prefs.getString("mode", "wild") } ?: PetMode.WILD
private val mutable = MutableStateFlow(PetUiState(mode = initialMode, still = prefs.getBoolean("still", false)))
private val mutable = MutableStateFlow(PetUiState(mode = initialMode, still = prefs.getBoolean("still", false), canFollowLive = liveUri != null))
val ui = mutable.asStateFlow()
private val commands = Channel<Command>(64)
private val worker = Executors.newSingleThreadExecutor { Thread(it, "codewhale-pet-world") }.asCoroutineDispatcher()
@@ -61,6 +69,10 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
private var output: PetAudioOutput? = null
private var cursor: PetAudioCursor? = null
private var lastSave = 0L
private data class LiveSample(val reader: PetLiveFile, val text: String?, val receivedAt: Long)
private val pendingLive = AtomicReference<LiveSample?>()
private var liveReader: PetLiveFile? = null
private var liveTask: Job? = null
private val assets = application.assets
private val bundle by lazy { assets.open("pet-native.js").bufferedReader().use { it.readText() } }
private val points by lazy { assets.open("whale-points.tsv").bufferedReader().use { it.readText() } }
@@ -86,7 +98,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
val frameSystemStill = systemStill
val playing = active && !framePaused
if (!playing) {
stopAudio()
stopAudio(); stopLive()
if (wasPlaying) save()
// Acknowledge Pause only after the in-flight frame,
// output and save have settled on their owning worker.
@@ -94,6 +106,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
still = frameStill, systemStill = frameSystemStill) }
} else {
val pet = checkNotNull(core)
if (mode == PetMode.LIVE) { startLive(); acceptLive(pet, began) }
if (!sound) stopAudio()
if (sound && output == null) {
try {
@@ -125,7 +138,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
catch (e: Exception) { message(e) }
finally {
withContext(NonCancellable + worker) {
stopAudio(); runCatching { save() }; core?.close(); core = null
stopAudio(); stopLive(); runCatching { save() }; core?.close(); core = null
}
commands.close(); worker.close()
}
@@ -140,6 +153,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
fun mode(value: PetMode) = enqueue(Command.Mode(value))
fun interact(food: Boolean) = enqueue(Command.Interact(food))
fun importRecording(uri: Uri) = enqueue(Command.Import(uri))
fun followLocalTape(uri: Uri) = enqueue(Command.Follow(uri))
fun exportRecording(uri: Uri) = enqueue(Command.Export(uri))
fun exportRecovery(uri: Uri) = enqueue(Command.ExportRecovery(uri))
fun exportArchive(uri: Uri, name: String) = enqueue(Command.ExportArchive(uri, name))
@@ -152,6 +166,39 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
private fun message(e: Exception) { mutable.update { it.copy(message = e.message?.take(400) ?: "The habitat could not continue.") } }
private fun stopAudio() { output?.close(); output = null; cursor = null }
private fun audioFailed(e: Exception) { stopAudio(); setSound(false); mutable.update { it.copy(message = "Sound paused: ${e.message ?: "output unavailable"}") } }
private fun stopLive() {
liveTask?.cancel(); liveTask = null
liveReader?.close(); liveReader = null; pendingLive.set(null)
}
private fun startLive() {
if (liveReader != null) return
val uri = liveUri ?: return
checkNotNull(core).resumeLiveInput()
val reader = PetLiveFile(getApplication<Application>().contentResolver, uri)
liveReader = reader
liveTask = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
val text = try { reader.readTail() } catch (e: Exception) { if (!isActive) break; null }
if (isActive) pendingLive.set(LiveSample(reader, text, SystemClock.elapsedRealtime()))
delay(400)
}
}
}
private fun acceptLive(pet: PetNativeCore, now: Long) {
val sample = pendingLive.getAndSet(null) ?: return
if (sample.reader !== liveReader) return
try {
if (now - sample.receivedAt > 800 || sample.text == null) {
pet.acceptLiveTail("")
mutable.update { it.copy(message = "Local tape unavailable or delayed. Use Follow local tape to choose a local, seekable file.") }
} else if (pet.acceptLiveTail(sample.text)) {
mutable.update { it.copy(message = "Following local telemetry. Missing input stays unobserved.") }
}
} catch (e: Exception) {
runCatching { pet.acceptLiveTail("") }
mutable.update { it.copy(message = "Invalid local telemetry · unobserved. The current world is kept.") }
}
}
private fun save(): Boolean {
lastSave = SystemClock.elapsedRealtime()
if (saveBlocked) return false
@@ -179,12 +226,12 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
val nextStore = PetHabitatStore(getApplication<Application>().filesDir, nextMode.key)
var blocked = false
val saved = try { nextStore.read() } catch (e: Exception) { blocked = true; message(e); null }
val next = try { PetNativeCore(bundle, points, tape(nextMode), saved) } catch (e: Exception) {
val next = try { PetNativeCore(bundle, points, tape(nextMode), saved, live = nextMode == PetMode.LIVE) } catch (e: Exception) {
blocked = true
mutable.update { it.copy(message = "Saved habitat could not be opened and is kept intact. ${e.message}") }
PetNativeCore(bundle, points, tape(nextMode))
PetNativeCore(bundle, points, tape(nextMode), live = nextMode == PetMode.LIVE)
}
stopAudio(); core?.close(); core = next; store = nextStore; saveBlocked = blocked; mode = nextMode
stopAudio(); stopLive(); core?.close(); core = next; store = nextStore; saveBlocked = blocked; mode = nextMode
prefs.edit().putString("mode", nextMode.key).apply()
mutable.update { it.copy(scene = next.scene(), mode = nextMode, savedAtMs = if (saved != null && !blocked) next.timeMs else null,
canExportRecovery = recoveryFile() != null, archives = nextStore.archives()) }
@@ -193,19 +240,29 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
private fun handle(command: Command) {
when (command) {
is Command.Mode -> if (command.mode != mode) {
check(command.mode != PetMode.LIVE || liveUri != null) { "Choose Follow local tape from More first." }
check(save()) { "This world could not be saved. Export it or reopen the saved habitat before switching." }
open(command.mode)
}
is Command.Follow -> {
require(command.uri.scheme == "content") { "Choose a local document through the file picker." }
check(save()) { "This world could not be saved. Export it before changing sources." }
open(PetMode.LIVE)
liveUri = command.uri
runCatching { getApplication<Application>().contentResolver.takePersistableUriPermission(command.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
prefs.edit().putString("live-uri", command.uri.toString()).apply()
mutable.update { it.copy(canFollowLive = true, message = "Waiting for fresh local telemetry. Existing file contents are not replayed as live work.") }
}
is Command.Interact -> core?.interact(command.food)
is Command.Reload -> { open(mode); setPaused(false) }
is Command.Restart -> {
save()
val replay = if (mode == PetMode.RECORDING) core?.recording()?.let { JSONObject(it).apply { remove("checkpoint") }.toString() } else null
val next = PetNativeCore(bundle, points, tape(mode), replay)
val next = PetNativeCore(bundle, points, tape(mode), replay, live = mode == PetMode.LIVE)
try {
val backup = checkNotNull(store).restart(next.recording())
if (backup != null) prefs.edit().putString("recovery-${mode.key}", backup.name).apply()
stopAudio(); core?.close(); core = next; saveBlocked = false
stopAudio(); stopLive(); core?.close(); core = next; saveBlocked = false
mutable.update { it.copy(scene = next.scene(), savedAtMs = next.timeMs, canExportRecovery = recoveryFile() != null,
message = "Fresh habitat started. You can export the previous saved world from More.") }
lastSave = SystemClock.elapsedRealtime()
@@ -220,7 +277,7 @@ class PetViewModel(application: Application) : AndroidViewModel(application) {
val scene = next.scene()
val nextStore = PetHabitatStore(getApplication<Application>().filesDir, PetMode.RECORDING.key)
nextStore.read(); nextStore.save(next.recording())
stopAudio(); core?.close(); core = next; store = nextStore; saveBlocked = false; mode = PetMode.RECORDING
stopAudio(); stopLive(); core?.close(); core = next; store = nextStore; saveBlocked = false; mode = PetMode.RECORDING
prefs.edit().putString("mode", mode.key).apply()
mutable.update { it.copy(scene = scene, mode = mode, savedAtMs = next.timeMs) }
lastSave = SystemClock.elapsedRealtime()
+61 -9
View File
@@ -19,6 +19,7 @@ class PetNative {
engine = new pet_engine_js_1.PetEngineTelemetry();
engineTick = 0;
segment;
liveTape = new pet_telemetry_js_1.PetLiveTape();
constructor(pointsJSON, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion = 2) {
const points = JSON.parse(pointsJSON);
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
@@ -30,6 +31,14 @@ class PetNative {
interact(kind, x, y) { this.world.interact(kind, x, y); }
interactions() { return JSON.stringify(this.world.interactions); }
accept(packet) { this.world.acceptTelemetry(JSON.parse(packet)); }
acceptLiveTail(text) {
const packet = this.liveTape.readTail(text);
if (!packet)
return false;
this.world.acceptTelemetry(packet);
return true;
}
resetLiveInput() { this.liveTape.reset(); return this.resumeEngine(); }
recording(withCheckpoint = false) { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment() { return this.world.needsSegment; }
prepareSegment() { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
@@ -47,6 +56,7 @@ class PetNative {
throw new Error('Native habitat exceeds 8 MiB.');
const points = this.world.sim.p.map(p => [p.hx, p.hy]);
this.world = pet_world_js_1.PetWorld.fromRecording(points, JSON.parse(text));
this.liveTape.reset();
this.segment = undefined;
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
this.engine = new pet_engine_js_1.PetEngineTelemetry();
@@ -54,14 +64,8 @@ class PetNative {
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine() {
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24)
throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) {
this.world.step(1 / 30, { motion: false, sensitivity: 1 });
this.engineTick++;
}
this.world.resumeObservation();
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
this.engine = new pet_engine_js_1.PetEngineTelemetry();
this.world.voices = [];
return this.world.frame.timeMs;
@@ -375,6 +379,16 @@ class PetWorld {
world.voices = structuredClone(c.voices);
return world;
}
/** Resume observation beyond all already accepted live packets, without
* replaying their sound or exposing a stale request as current work. */
resumeObservation() {
const last = this.tapeLog.at(-1), end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.tick > 24)
throw new Error('Only a live recording can resume observation.');
while (this.tick < end)
this.step(1 / 30, { motion: false, sensitivity: 1 });
this.voices = [];
}
/** Branch at the current playhead; input is journalled for the next fixed tick.
* A live touch never needs to re-simulate the creature's entire lifetime. */
interact(kind, x, y) {
@@ -1040,7 +1054,7 @@ function runTape(sim, rows, opts) {
factories["pet-telemetry"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PET_BIN_MS = void 0;
exports.PetLiveTape = exports.PET_BIN_MS = void 0;
exports.validatePetBucket = validatePetBucket;
exports.decodePetJSONL = decodePetJSONL;
exports.compilePetTelemetry = compilePetTelemetry;
@@ -1073,6 +1087,44 @@ function decodePetJSONL(text) {
return rows.map((row, i) => { validatePetBucket(row); if (row.sequence !== i)
throw new Error('Non-contiguous pet tape.'); return row; });
}
/** A live file must advance before its contents count as a new observation.
* Existing bytes, duplicate samples and a restarted sequence establish a
* baseline; they never replay an old onset or human request. Drivers supply a
* bounded tail and reset this cursor after suspension or a new attachment. */
class PetLiveTape {
sequence;
reset() { this.sequence = undefined; }
readTail(text) {
if (!text) {
this.reset();
return;
}
if (text.length > 262_144) {
this.reset();
throw new Error('Live pet input exceeds its tail limit.');
}
if (!text.endsWith('\n'))
return;
const line = text.trimEnd().split('\n').at(-1);
if (!line)
return;
let packet;
try {
packet = JSON.parse(line);
validatePetBucket(packet);
}
catch (error) {
this.reset();
throw error;
}
const previous = this.sequence;
this.sequence = packet.sequence;
if (previous === undefined || packet.sequence <= previous)
return;
return packet;
}
}
exports.PetLiveTape = PetLiveTape;
const order = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const keyOf = (e) => JSON.stringify([e.traceId, e.id]);
const isContainer = (e) => e.attributes['whalesong.container'] === true
+10 -5
View File
@@ -1,5 +1,5 @@
import { PetWorld, type PetInteraction, type PetSegment } from './pet-world.js';
import { compilePetTelemetry, decodePetJSONL } from './pet-telemetry.js';
import { compilePetTelemetry, decodePetJSONL, PetLiveTape } from './pet-telemetry.js';
import { ARCH_OF, digest, layout } from './pet-sim.js';
import { renderPetPCM, type PetVoice } from './pet-audio.js';
import { PetEngineTelemetry } from './pet-engine.js';
@@ -11,6 +11,7 @@ export class PetNative {
private engine = new PetEngineTelemetry();
private engineTick = 0;
private segment?: PetSegment;
private liveTape = new PetLiveTape();
constructor(pointsJSON: string, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion: 1 | 2 = 2) {
const points = JSON.parse(pointsJSON) as [number, number][];
if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1)))
@@ -22,6 +23,11 @@ export class PetNative {
interact(kind: PetInteraction['kind'], x: number, y: number): void { this.world.interact(kind, x, y); }
interactions(): string { return JSON.stringify(this.world.interactions); }
accept(packet: string): void { this.world.acceptTelemetry(JSON.parse(packet)); }
acceptLiveTail(text: string): boolean {
const packet = this.liveTape.readTail(text); if (!packet) return false;
this.world.acceptTelemetry(packet); return true;
}
resetLiveInput(): number { this.liveTape.reset(); return this.resumeEngine(); }
recording(withCheckpoint = false): string { return JSON.stringify(this.world.recording(withCheckpoint)); }
needsSegment(): boolean { return this.world.needsSegment; }
prepareSegment(): string { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); }
@@ -36,15 +42,14 @@ export class PetNative {
if (text.length > 8 * 1024 * 1024) throw new Error('Native habitat exceeds 8 MiB.');
const points = this.world.sim.p.map(p => [p.hx, p.hy] as [number, number]);
this.world = PetWorld.fromRecording(points, JSON.parse(text));
this.liveTape.reset();
this.segment = undefined; this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000); this.engine = new PetEngineTelemetry();
}
/** Resume a live host at the first unrecorded bucket. Keep every accepted
* interval, but never present its last observed frame as current evidence. */
resumeEngine(): number {
const last = this.world.tape.at(-1);
const end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.engineTick > 24) throw new Error('Only a live recording can resume Engine observation.');
while (this.engineTick < end) { this.world.step(1 / 30, { motion: false, sensitivity: 1 }); this.engineTick++; }
this.world.resumeObservation();
this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000);
this.engine = new PetEngineTelemetry();
this.world.voices = [];
return this.world.frame.timeMs;
+22
View File
@@ -37,6 +37,28 @@ export function decodePetJSONL(text: string): PetBucket[] {
return rows.map((row, i) => { validatePetBucket(row); if (row.sequence !== i) throw new Error('Non-contiguous pet tape.'); return row; });
}
/** A live file must advance before its contents count as a new observation.
* Existing bytes, duplicate samples and a restarted sequence establish a
* baseline; they never replay an old onset or human request. Drivers supply a
* bounded tail and reset this cursor after suspension or a new attachment. */
export class PetLiveTape {
private sequence: number | undefined;
reset(): void { this.sequence = undefined; }
readTail(text: string): PetBucket | undefined {
if (!text) { this.reset(); return; }
if (text.length > 262_144) { this.reset(); throw new Error('Live pet input exceeds its tail limit.'); }
if (!text.endsWith('\n')) return;
const line = text.trimEnd().split('\n').at(-1);
if (!line) return;
let packet: unknown;
try { packet = JSON.parse(line); validatePetBucket(packet); }
catch (error) { this.reset(); throw error; }
const previous = this.sequence; this.sequence = packet.sequence;
if (previous === undefined || packet.sequence <= previous) return;
return packet;
}
}
const order = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
const keyOf = (e: WhaleEvent) => JSON.stringify([e.traceId, e.id]);
const isContainer = (e: WhaleEvent) => e.attributes['whalesong.container'] === true
+9
View File
@@ -266,6 +266,15 @@ export class PetWorld {
return world;
}
/** Resume observation beyond all already accepted live packets, without
* replaying their sound or exposing a stale request as current work. */
resumeObservation(): void {
const last = this.tapeLog.at(-1), end = last ? (last.sequence + 1) * 12 : 0;
if (!end || end - this.tick > 24) throw new Error('Only a live recording can resume observation.');
while (this.tick < end) this.step(1 / 30, { motion: false, sensitivity: 1 });
this.voices = [];
}
/** Branch at the current playhead; input is journalled for the next fixed tick.
* A live touch never needs to re-simulate the creature's entire lifetime. */
interact(kind: PetInteraction['kind'], x: number, y: number): void {
+20 -13
View File
@@ -1,5 +1,5 @@
import { PetWorld, PET_MAX_SECONDS, type PetInteraction, type PetWorldCheckpoint } from '../core/pet-world.js';
import { compilePetTelemetry, decodePetJSONL, validatePetBucket, type PetBucket } from '../core/pet-telemetry.js';
import { compilePetTelemetry, decodePetJSONL, PetLiveTape, type PetBucket } from '../core/pet-telemetry.js';
import { petDemoEvents } from '../core/pet-demo.js';
import { importTrace } from '../core/ingest.js';
import { layout } from '../core/pet-sim.js';
@@ -19,6 +19,8 @@ let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false;
let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0;
let imported: { world: PetWorld; name: string } | undefined;
const library = new TraceLibrary();
const liveTape = new PetLiveTape();
let liveObservation = 0;
let savedRevision: number | undefined, saving: Promise<boolean> | undefined, persistenceReady = false, persistenceFailed = false;
let audio: AudioContext | undefined, anchor = 0, sound = false;
const playing = new Set<AudioBufferSourceNode>();
@@ -69,7 +71,7 @@ function adoptWorld(next: PetWorld) {
if (audio) anchor = audio.currentTime - seconds + .08;
draw();
}
function stopFollowing() { liveGeneration++; clearTimeout(liveTimer); get<HTMLButtonElement>('pause').disabled = false; seek.disabled = false; }
function stopFollowing() { liveGeneration++; clearTimeout(liveTimer); liveTape.reset(); get<HTMLButtonElement>('pause').disabled = false; seek.disabled = false; }
async function refreshArchives() {
const list = get<HTMLSelectElement>('archive');
const entries = await library.petArchives();
@@ -233,28 +235,33 @@ get('follow').onclick = async () => {
get<HTMLButtonElement>('pause').disabled = true; seek.disabled = true;
expressionVersion = 2; tape = compilePetTelemetry([]); interactions = []; seek.max = '120';
source.textContent = `Live local tape · ${file.name}`; await rebuild();
let lastSequence = -1, lastSize = -1;
const poll = async () => {
if (ticket !== liveGeneration) return;
if (document.hidden) { liveTimer = window.setTimeout(poll, 400); return; }
const observation = liveObservation;
try {
const current = await handle.getFile();
const tail = await current.slice(Math.max(0, current.size - 262_144)).text();
if (ticket !== liveGeneration) return;
if (restoring) { liveTimer = window.setTimeout(poll, 400); return; }
if (current.size < lastSize) lastSequence = -1;
lastSize = current.size;
if (tail.endsWith('\n')) {
const packet = JSON.parse(tail.trimEnd().split('\n').at(-1) ?? '{}'); validatePetBucket(packet);
if (packet.sequence > lastSequence) { world.acceptTelemetry(packet); tape = world.tape; lastSequence = packet.sequence; }
message.textContent = 'Following local telemetry. Unchanged or unavailable input becomes an unobserved gap.';
}
} catch { if (ticket === liveGeneration) message.textContent = 'Local tape unavailable or invalid · unobserved. Select the file again if it was replaced.'; }
if (restoring || document.hidden || observation !== liveObservation) { liveTimer = window.setTimeout(poll, 400); return; }
const packet = liveTape.readTail(tail);
if (packet) { world.acceptTelemetry(packet); tape = world.tape; }
message.textContent = 'Following local telemetry. Existing or unchanged input stays unobserved until new packets arrive.';
} catch { if (ticket === liveGeneration && observation === liveObservation) { liveTape.reset(); message.textContent = 'Local tape unavailable or invalid · unobserved. Select the file again if it was replaced.'; } }
if (ticket === liveGeneration) liveTimer = window.setTimeout(poll, 400);
};
await poll();
} catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) message.textContent = 'Unable to open this local tape.'; }
};
document.addEventListener('visibilitychange', () => { last = 0; silence(); void persist(); if (audio) anchor = audio.currentTime - seconds + .08; });
document.addEventListener('visibilitychange', () => {
last = 0; silence();
if (worldMode === 'live') {
liveObservation++;
liveTape.reset();
if (!document.hidden && world && !restoring) { world.resumeObservation(); seconds = world.frame.timeMs / 1000; }
}
void persist(); if (audio) anchor = audio.currentTime - seconds + .08;
});
window.addEventListener('pagehide', () => { void persist(); });
function animate(now: number) {
if (world && !restoring && !paused && !document.hidden) {
+29 -18
View File
@@ -37,9 +37,6 @@ public func petArchiveLabel(_ name: String) -> String {
private var timer: Timer?
private var monitor: DispatchSourceFileSystemObject?
private var fileMonitor: DispatchSourceFileSystemObject?
private var lastPacket: String?
private var lastSourceSequence = -1
private var fileIdentity: UInt64?
private var count = 0
private var stateURL: URL?
private var paused = false
@@ -56,7 +53,18 @@ public func petArchiveLabel(_ name: String) -> String {
Task { @MainActor in self?.tick() }
}
}
public func suspend(_ value: Bool) { paused = value; if value { save(); audio.stop() } else { configureSound() } }
public func suspend(_ value: Bool) {
let wasPaused = paused; paused = value
if value {
save(); audio.stop(); monitor?.cancel(); fileMonitor?.cancel(); monitor = nil; fileMonitor = nil
} else {
if wasPaused && source == .live {
do { try core?.resumeLiveInput(); if let stateURL { watch(stateURL) }; objectWillChange.send() }
catch { message = error.localizedDescription }
}
configureSound()
}
}
@discardableResult public func selectSource(_ next: PetSource, discardingUnsaved: Bool = false) -> Bool {
guard next != source else { return true }
guard discardingUnsaved || save() else { return false }
@@ -64,7 +72,7 @@ public func petArchiveLabel(_ name: String) -> String {
return true
}
private func restart() {
audio.stop(); restoring = false; failed = false; monitor?.cancel(); fileMonitor?.cancel(); monitor = nil; fileMonitor = nil; lastPacket = nil; lastSourceSequence = -1; fileIdentity = nil
audio.stop(); restoring = false; failed = false; monitor?.cancel(); fileMonitor?.cancel(); monitor = nil; fileMonitor = nil
store = nil; archiveStore = nil; archives = []; persistenceMessage = ""; migratingLegacy = false; count = 0
do {
guard let script = bundle.url(forResource: "pet-native", withExtension: "js") else { throw PetCoreError.invalid("The shared pet core is missing from this app.") }
@@ -118,7 +126,14 @@ public func petArchiveLabel(_ name: String) -> String {
if !restoring { configureSound() }
} catch { core = nil; message = error.localizedDescription }
}
public func setLiveFile(_ url: URL) { stateURL = url; if source == .live { monitor?.cancel(); fileMonitor?.cancel(); watch(url) } }
public func setLiveFile(_ url: URL) {
let changed = stateURL != url; stateURL = url
if source == .live && !paused {
monitor?.cancel(); fileMonitor?.cancel()
do { if changed { try core?.resumeLiveInput() }; watch(url) }
catch { message = error.localizedDescription }
}
}
public func interact(food: Bool) {
do { try core?.interact(food: food); save() } catch { message = error.localizedDescription }
}
@@ -166,6 +181,7 @@ public func petArchiveLabel(_ name: String) -> String {
}
}
private func watch(_ url: URL) {
guard source == .live, stateURL == url, !paused else { return }
// Watch the directory so atomic file replacement and initial creation work.
let descriptor = open(url.deletingLastPathComponent().path, O_EVTONLY)
guard descriptor >= 0 else { message = "Create the telemetry directory, then select Live again."; return }
@@ -174,28 +190,23 @@ public func petArchiveLabel(_ name: String) -> String {
source.setCancelHandler { close(descriptor) }; monitor = source; source.resume(); watchContents(url)
}
private func watchContents(_ url: URL) {
guard source == .live, stateURL == url else { return }
guard source == .live, stateURL == url, !paused else { return }
fileMonitor?.cancel(); fileMonitor = nil
let identity = (try? FileManager.default.attributesOfItem(atPath: url.path)[.systemFileNumber] as? NSNumber)?.uint64Value
if identity != fileIdentity { lastSourceSequence = -1; lastPacket = nil; fileIdentity = identity }
let descriptor = open(url.path, O_EVTONLY)
guard descriptor >= 0 else { message = "Telemetry unavailable · unobserved"; return }
guard descriptor >= 0 else { _ = try? core?.acceptLiveTail(""); message = "Telemetry unavailable · unobserved"; return }
let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor, eventMask: [.write, .rename, .delete], queue: .main)
source.setEventHandler { [weak self] in Task { @MainActor in self?.readPacket(url) } }
source.setCancelHandler { close(descriptor) }; fileMonitor = source; source.resume(); readPacket(url)
}
private func readPacket(_ url: URL) {
guard source == .live, stateURL == url else { return }
guard source == .live, stateURL == url, !paused else { return }
do {
let handle = try FileHandle(forReadingFrom: url); defer { try? handle.close() }
let size = try handle.seekToEnd(); try handle.seek(toOffset: size > 262_144 ? size - 262_144 : 0)
let data = try handle.readToEnd() ?? Data()
guard data.last == 10, let text = String(data: data, encoding: .utf8), let last = text.split(separator: "\n").last else { return }
let packet = String(last); if packet == lastPacket { return }
guard let value = try JSONSerialization.jsonObject(with: Data(packet.utf8)) as? [String: Any], let sequence = value["sequence"] as? Int else { throw PetCoreError.invalid("Invalid telemetry packet.") }
if sequence <= lastSourceSequence { return }
try core?.accept(packet: packet); lastPacket = packet; lastSourceSequence = sequence; message = "Local telemetry connected"
} catch { message = "Telemetry unavailable · unobserved" }
let data = try handle.read(upToCount: 262_144) ?? Data()
try core?.acceptLiveTail(String(decoding: data, as: UTF8.self))
message = "Following local telemetry"
} catch { _ = try? core?.acceptLiveTail(""); message = "Telemetry unavailable · unobserved" }
// Missing/unchanged input never falls back to a demo. The recorded 400ms
// packet expires in the core and subsequent ticks are visibly unobserved.
}
+20 -5
View File
@@ -52,11 +52,7 @@ public enum PetCoreError: Error, LocalizedError {
self.frame = try JSONDecoder().decode(PetWorldFrame.self, from: Data(text.utf8))
self.sim = PetSim(points: points, expressionVersion: expressionVersion)
if saved != nil {
struct Projection: Decodable { let sim: PetParticleCheckpoint }
guard let text = world.invokeMethod("checkpoint", withArguments: [])?.toString(), context.exception == nil
else { throw PetCoreError.invalid(context.exception?.toString() ?? "Unable to restore the particle renderer.") }
try sim.restoreValidated(JSONDecoder().decode(Projection.self, from: Data(text.utf8)).sim)
guard petDigest(sim) == frame.digest else { throw PetCoreError.invalid("The restored particle renderer differs from the shared world.") }
try restoreProjection()
}
context.exceptionHandler = { [weak self] _, error in self?.failure = error?.toString() ?? "Pet runtime failed." }
}
@@ -87,6 +83,25 @@ public enum PetCoreError: Error, LocalizedError {
world.invokeMethod("accept", withArguments: [packet])
if let failure { throw PetCoreError.invalid(failure) }
}
@discardableResult public func acceptLiveTail(_ text: String) throws -> Bool {
failure = nil
guard let value = world.invokeMethod("acceptLiveTail", withArguments: [text]), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to read local telemetry.") }
return value.toBool()
}
public func resumeLiveInput() throws {
failure = nil
world.invokeMethod("resetLiveInput", withArguments: [])
guard failure == nil, let text = world.invokeMethod("snapshot", withArguments: [])?.toString(), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to resume live telemetry.") }
frame = try JSONDecoder().decode(PetWorldFrame.self, from: Data(text.utf8))
try restoreProjection()
}
private func restoreProjection() throws {
struct Projection: Decodable { let sim: PetParticleCheckpoint }
guard let text = world.invokeMethod("checkpoint", withArguments: [])?.toString(), context.exception == nil
else { throw PetCoreError.invalid(context.exception?.toString() ?? "Unable to restore the particle renderer.") }
try sim.restoreValidated(JSONDecoder().decode(Projection.self, from: Data(text.utf8)).sim)
guard petDigest(sim) == frame.digest else { throw PetCoreError.invalid("The restored particle renderer differs from the shared world.") }
}
public func recording(checkpoint: Bool = false) throws -> Data {
failure = nil
guard let value = world.invokeMethod("recording", withArguments: [checkpoint])?.toString(), failure == nil else {
+59 -1
View File
@@ -13,6 +13,13 @@ import Darwin
let left = try JSONSerialization.jsonObject(with: a) as! NSDictionary
return try left.isEqual(JSONSerialization.jsonObject(with: b))
}
@MainActor static func eventually(_ message: String, _ condition: () throws -> Bool) async throws {
for _ in 0..<100 {
if try condition() { return }
try await Task.sleep(for: .milliseconds(50))
}
throw PetCoreError.invalid(message)
}
@MainActor static func main() async throws {
let args = CommandLine.arguments
let pet = URL(fileURLWithPath: args[1]), out = URL(fileURLWithPath: args[2])
@@ -60,6 +67,57 @@ import Darwin
let root = out.appendingPathComponent("apple-checkpoint-fixture-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let liveSuite = "dev.shannonlabs.pet-live-test." + UUID().uuidString
let liveDefaults = UserDefaults(suiteName: liveSuite)!
defer { liveDefaults.removePersistentDomain(forName: liveSuite) }
liveDefaults.set("live", forKey: "pet.source")
let liveFile = root.appendingPathComponent("local.jsonl")
func packet(_ sequence: Int) throws -> Data {
var value = try JSONSerialization.jsonObject(with: Data(human.utf8)) as! [String: Any]
value["sequence"] = sequence; value["simTimeMs"] = sequence * 400
return try JSONSerialization.data(withJSONObject: value) + Data("\n".utf8)
}
func append(_ sequence: Int) throws {
let handle = try FileHandle(forWritingTo: liveFile); defer { try? handle.close() }
try handle.seekToEnd(); try handle.write(contentsOf: packet(sequence))
}
try packet(40).write(to: liveFile)
let following = PetHost(points: points, bundle: bundle, defaults: liveDefaults, storageDirectory: root.appendingPathComponent("live-host"))
defer { following.suspend(true) }
following.setLiveFile(liveFile)
func observations() throws -> Int {
let recording = try JSONSerialization.jsonObject(with: following.core!.recording()) as! [String: Any]
return (recording["tape"] as! [[String: Any]]).filter { ($0["observed"] as? Double ?? 0) > 0 }.count
}
try require(observations() == 0, "Existing live file replayed an old request")
try append(41)
try await eventually("File append was not delivered to the actual Apple host") { try observations() == 1 }
for _ in 0..<12 { try following.core!.tick(motion: true) }
try require(following.core!.frame.state.channel == "human", "Fresh file input did not reach the visible world")
following.suspend(true); try append(42); following.suspend(false)
try require(following.core!.frame.needs == "none" && following.core!.frame.state.observed == 0, "Suspended request survived resume")
try require(petDigest(following.core!.sim) == following.core!.frame.digest, "Resume did not update native geometry")
try require(observations() == 1, "Background bytes became a fresh observation")
try append(43)
try await eventually("Resumed file did not continue") { try observations() == 2 }
for _ in 0..<12 { try following.core!.tick(motion: true) }
let truncated = try FileHandle(forWritingTo: liveFile)
try truncated.truncate(atOffset: 0); try truncated.write(contentsOf: packet(0)); try truncated.close()
try await Task.sleep(for: .milliseconds(150))
try require(observations() == 2, "Producer restart replayed its baseline")
try packet(1).write(to: liveFile, options: .atomic)
try await eventually("In-place restart followed by atomic replacement was ignored") { try observations() == 3 }
for _ in 0..<12 { try following.core!.tick(motion: true) }
try FileManager.default.removeItem(at: liveFile)
try await eventually("Missing file was not reported") { following.message.contains("unavailable") }
try packet(100).write(to: liveFile)
try await Task.sleep(for: .milliseconds(150))
try require(observations() == 3, "Recreated file did not establish a baseline")
try append(101)
try await eventually("Recreated producer did not continue") { try observations() == 4 }
following.suspend(true)
print("PASS actual Apple live file: stale attachment, append, pause/resume, in-place restart, atomic replacement and deletion/recreation")
let files = try PetHabitatStore(directory: root, source: "wild")
let stale = try PetHabitatStore(directory: root, source: "wild")
try require(files.load() == nil && stale.load() == nil, "Fixture was not empty")
@@ -186,6 +244,6 @@ import Darwin
try require(same(beforeConflict, conflict.core!.recording(checkpoint: true)), "Failed archive retired live history")
try require(Data(contentsOf: conflictDir.appendingPathComponent("live.json")) == external, "Archiving replaced another writer")
print("PASS actual Apple host: archive conflict preserves all live history and the other writer's file")
print("PASS 10 Apple checkpoint workflows; fixtures retained at \(root.path)")
print("PASS 11 Apple checkpoint workflows; fixtures retained at \(root.path)")
}
}
+38 -9
View File
@@ -10,10 +10,10 @@ import * as sim from '../dist/core/pet-sim.js';
import * as audio from '../dist/core/pet-audio.js';
/** Run the compiled browser controller and real world with minimal DOM sinks.
* Only storage completion is controlled: this tests the async caller contract,
* not IndexedDB's transaction implementation or browser rendering. */
async function browser() {
const nodes = new Map(), saves = [], intervals = [];
* Storage and file-read completion are controlled: this tests async caller
* contracts, not IndexedDB transactions, file-picker grants or rendering. */
async function browser({ deferFirstSave = true, handle } = {}) {
const nodes = new Map(), saves = [], intervals = [], timers = [], listeners = new Map();
let complete, fail, confirmations = 0;
const context = new Proxy({}, { get: (object, key) => object[key] ?? (() => {}) });
const node = id => {
@@ -28,7 +28,7 @@ async function browser() {
async petArchives() { return []; }
saveHabitat(habitat, revision, archive) {
saves.push(structuredClone({ habitat, revision, archive }));
if (saves.length === 1) return new Promise((resolve, reject) => { complete = resolve; fail = reject; });
if (deferFirstSave && saves.length === 1) return new Promise((resolve, reject) => { complete = resolve; fail = reject; });
return Promise.resolve(saves.length);
}
}
@@ -36,16 +36,20 @@ async function browser() {
// Imports bind to the real core above, while the controller body stays intact.
const controller = (await readFile(new URL('../dist/ui/pet.js', import.meta.url), 'utf8')).replace(/^import .*;\r?\n/gm, '');
const environment = { ...world, ...telemetry, ...demo, ...ingest, ...sim, ...audio, TraceLibrary,
document: { hidden: false, getElementById: node, addEventListener() {} },
window: { addEventListener() {}, confirm() { confirmations++; return false; } },
document: { hidden: false, getElementById: node, addEventListener: (name, callback) => listeners.set(name, callback) },
window: { addEventListener() {}, confirm() { confirmations++; return false; },
showOpenFilePicker: handle ? async () => [handle] : undefined,
setTimeout: callback => timers.push(callback) },
matchMedia: () => ({ matches: false, addEventListener() {} }),
fetch: async () => ({ ok: true, text: async () => points }),
Option: class {}, requestAnimationFrame() {}, setInterval: callback => intervals.push(callback),
setTimeout, clearTimeout, structuredClone, TextEncoder, devicePixelRatio: 1 };
await vm.runInNewContext(`(async () => { ${controller}\n })()`, environment);
const inspect = await vm.runInNewContext(`(async () => { ${controller}\n return () => world.recording(true); })()`, environment);
assert.equal(intervals.length, 1, 'Controller starts its autosave after loading');
return { node, saves, autosave: intervals[0], complete: () => complete(1), fail: () => fail(new Error('Disk unavailable')),
confirmations: () => confirmations };
confirmations: () => confirmations, recording: () => structuredClone(inspect()),
poll: () => { assert.ok(timers.length); return timers.shift()(); },
visibility: hidden => { environment.document.hidden = hidden; listeners.get('visibilitychange')(); } };
}
test('browser source change waits for autosave and archives inputs accepted during that save', async () => {
@@ -78,3 +82,28 @@ test('browser failed autosave asks before leaving and cancel preserves the curre
assert.equal(page.node('mode').value, 'wild');
assert.equal(page.saves.length, 1, 'No archive or replacement after the failed write');
});
test('browser live follow discards a read crossing suspension and primes the resumed file', async () => {
const human = telemetry.compilePetTelemetry(demo.petDemoEvents()).find(b => b.waiting);
const file = sequence => {
const text = telemetry.encodePetJSONL([{ ...human, sequence, simTimeMs: sequence * 400 }]);
return { name: 'local.jsonl', size: text.length, slice: () => ({ text: async () => text }) };
};
let current = file(10), pending;
const handle = { getFile: async () => current };
const page = await browser({ deferFirstSave: false, handle });
await page.node('follow').onclick();
assert.equal(page.recording().tape.length, 1, 'Existing file is not a live observation');
handle.getFile = () => new Promise(resolve => { pending = resolve; });
const reading = page.poll();
page.visibility(true); page.visibility(false);
pending(file(11)); await reading;
assert.equal(page.recording().tape.length, 1, 'Do not prime from a read started before suspension');
handle.getFile = async () => current;
current = file(12); await page.poll();
assert.equal(page.recording().tape.length, 1, 'The first resumed read establishes the new baseline');
current = file(13); await page.poll();
assert.equal(page.recording().tape.at(-1).channel, 'human');
assert.equal(page.recording().tape.length, 2);
await page.poll(); assert.equal(page.recording().tape.length, 2, 'Duplicate reads add no onsets');
});
+63
View File
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { PetNative } from '../dist/core/pet-native.js';
import { PetLiveTape, compilePetTelemetry } from '../dist/core/pet-telemetry.js';
import { petDemoEvents } from '../dist/core/pet-demo.js';
const points = JSON.stringify(readFileSync(new URL('../public/whale-points.tsv', import.meta.url), 'utf8').trim().split('\n').map(row => row.split(/\s+/).map(Number)));
const human = compilePetTelemetry(petDemoEvents()).find(b => b.channel === 'human' && b.waiting);
const packet = sequence => ({ ...human, sequence, simTimeMs: sequence * 400 });
const line = sequence => JSON.stringify(packet(sequence)) + '\n';
test('live tails require advancement after attachment, replacement, invalid input and resume', () => {
const live = new PetLiveTape();
assert.equal(live.readTail(line(50)), undefined, 'Existing bytes establish a baseline');
assert.equal(live.readTail(line(50)), undefined);
assert.deepEqual(live.readTail(line(51)), packet(51));
assert.equal(live.readTail(line(0)), undefined, 'An in-place producer restart is a new baseline');
assert.deepEqual(live.readTail(line(1)), packet(1));
assert.equal(live.readTail(line(2).trimEnd()), undefined, 'Wait for a complete append');
assert.deepEqual(live.readTail(line(2)), packet(2));
assert.throws(() => live.readTail('{bad}\n'));
assert.equal(live.readTail(line(3)), undefined);
assert.deepEqual(live.readTail(line(4)), packet(4));
live.readTail(''); assert.equal(live.readTail(line(5)), undefined);
assert.deepEqual(live.readTail('partial first line\n' + line(6)), packet(6));
live.reset(); assert.equal(live.readTail(line(7)), undefined);
assert.deepEqual(live.readTail(line(8)), packet(8));
assert.throws(() => live.readTail('x'.repeat(262_145)));
assert.equal(live.readTail(line(9)), undefined);
});
test('native normal-tick live resume uses the world clock and discards old voices', () => {
const pet = new PetNative(points, '', '[]', true);
for (let i = 0; i < 90; i++) pet.step(1 / 30, true);
pet.accept(JSON.stringify(packet(40)));
for (let i = 0; i < 12; i++) pet.step(1 / 30, true);
const before = JSON.parse(pet.snapshot());
assert.equal(before.state.channel, 'human');
assert.ok(before.state.observed >= .92);
const resumedAt = pet.resumeEngine(), after = JSON.parse(pet.snapshot());
assert.ok(resumedAt >= before.timeMs && resumedAt - before.timeMs <= 800, 'Skip only the unexpired live interval');
assert.equal(after.state.observed, 0);
assert.equal(after.needs, 'none'); assert.deepEqual(after.voices, []);
const copy = new PetNative(points); copy.restoreRecording(pet.recording(true));
assert.deepEqual(JSON.parse(copy.snapshot()), after);
for (let i = 0; i < 60; i++) assert.equal(pet.step(1 / 30, true), copy.step(1 / 30, true));
});
test('native live delivery journals only newly observed packets across resume', () => {
const pet = new PetNative(points, '', '[]', true);
assert.equal(pet.acceptLiveTail(line(20)), false);
assert.equal(JSON.parse(pet.recording()).tape.length, 1);
assert.equal(pet.acceptLiveTail(line(21)), true);
for (let i = 0; i < 12; i++) pet.step(1 / 30, true);
assert.equal(JSON.parse(pet.snapshot()).state.channel, 'human');
pet.resetLiveInput();
assert.equal(pet.acceptLiveTail(line(22)), false, 'A background append establishes the resumed baseline');
assert.equal(JSON.parse(pet.snapshot()).needs, 'none');
assert.equal(pet.acceptLiveTail(line(23)), true);
for (let i = 0; i < 12; i++) pet.step(1 / 30, true);
assert.equal(JSON.parse(pet.snapshot()).state.channel, 'human');
});
+19 -4
View File
@@ -109,8 +109,8 @@ test('the CLI follows real Runtime SSE envelopes through disconnect and cursor r
assert.doesNotMatch(text + log, /fixture-private-text|fixture-token/);
});
test('live Runtime recording retains human waits and a brief late failure exactly once between timer ticks', { timeout: 12_000 }, async t => {
let sequence = 0, response;
test('live Runtime recording retains human waits and a brief late failure exactly once between timer ticks', { timeout: 15_000 }, async t => {
let sequence = 0, response, answer;
const timers = [], requests = [];
const server = createServer((req, res) => {
requests.push({ method: req.method, url: req.url }); response = res;
@@ -123,7 +123,7 @@ test('live Runtime recording retains human waits and a brief late failure exactl
emit('item.started', { item: { id: 'old-work', kind: 'tool_call', status: 'running', started_at: oldStart }, tool: 'bash' });
timers.push(setTimeout(() => emit('user_input.required', { id: 'question', request: { questions: ['fixture-private-question'] } }), 110));
timers.push(setTimeout(() => emit('item.completed', { item: { id: 'old-work', kind: 'tool_call', status: 'failed', started_at: oldStart, ended_at: oldEnd, detail: 'fixture-private-result' }, tool: 'bash' }), 650));
timers.push(setTimeout(() => emit('user_input.answered', { input_id: 'question', answers: ['fixture-private-answer'] }), 1900));
answer = () => emit('user_input.answered', { input_id: 'question', answers: ['fixture-private-answer'] });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const dir = await mkdtemp(join(tmpdir(), 'pet-runtime-lifecycle-')), output = join(dir, 'pet.jsonl');
@@ -133,7 +133,22 @@ test('live Runtime recording retains human waits and a brief late failure exactl
child.stdout.on('data', b => log += b); child.stderr.on('data', b => log += b);
t.after(async () => { timers.forEach(clearTimeout); if (child.exitCode === null) child.kill('SIGTERM'); response?.destroy(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); });
for (let i = 0; !response && i < 100; i++) await delay(20);
assert.ok(response, log); await delay(3300);
assert.ok(response, log);
const waitForTape = async (condition, message) => {
for (let i = 0; i < 125; i++) {
let text = '';
try { text = await readFile(output, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
const rows = decodePetJSONL(text.slice(0, text.lastIndexOf('\n') + 1));
if (condition(rows)) return;
await delay(40);
}
assert.fail(message + '\n' + log);
};
// Drive the answer after actual recorded coverage. Wall-clock sleeps alone
// can stop the child before it seals the final unknown bins on a busy runner.
await waitForTape(rows => rows.filter(b => b.waiting).length >= 3 && rows.some(b => b.errors), 'Waiting/error receipts were not recorded');
answer();
await waitForTape(rows => rows.length >= 2 && rows.slice(-2).every(b => !b.waiting && !b.observed), 'Answered input did not expire to unknown');
child.kill('SIGINT'); const [code] = await exited; assert.equal(code, 0, log);
const text = await readFile(output, 'utf8'), tape = decodePetJSONL(text);
assert.equal(tape.reduce((sum, b) => sum + b.errors, 0), 1);