@reefclaw/openclaw-plugin 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,8 @@ export type GatewayWsEventPayload = {
45
45
  stream: string;
46
46
  data: unknown;
47
47
  seq?: number;
48
+ sessionKey?: string;
49
+ sessionId?: string;
48
50
  };
49
51
  presence: {
50
52
  payload: unknown;
@@ -270,11 +270,17 @@ export class GatewayWsClient {
270
270
  this.resetStaleTimer();
271
271
  if (frame.payload && typeof frame.payload === 'object') {
272
272
  const p = frame.payload;
273
+ // sessionKey/sessionId ride along (gateway AgentEventPayload): a
274
+ // cron beat's key is `agent:main:cron:<jobId>:run:<sessionId>`,
275
+ // which is how the heartbeat flight recorder tells a beat's turn
276
+ // from an operator chat turn. Optional — older gateways omit them.
273
277
  this.emit('agent', {
274
278
  runId: p.runId,
275
279
  stream: p.stream,
276
280
  data: p.data,
277
281
  seq: frame.seq,
282
+ ...(typeof p.sessionKey === 'string' ? { sessionKey: p.sessionKey } : {}),
283
+ ...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
278
284
  });
279
285
  }
280
286
  break;
@@ -0,0 +1,16 @@
1
+ export declare const STATE_VERSION = 1;
2
+ /** Session ids remembered — ~2 days of 15-min beats. */
3
+ export declare const RECORDED_MAX = 300;
4
+ export interface HeartbeatRunsState {
5
+ version: number;
6
+ /** Session ids already recorded (or deliberately skipped), oldest first. */
7
+ recorded: string[];
8
+ /** mtime watermark (epoch ms) of the last completed scan; 0 = never. */
9
+ lastScanMtimeMs: number;
10
+ }
11
+ export declare function defaultStateFile(): string;
12
+ export declare function emptyState(): HeartbeatRunsState;
13
+ export declare function loadState(file?: string): HeartbeatRunsState;
14
+ /** Best-effort write; returns false when the filesystem refused. */
15
+ export declare function saveState(state: HeartbeatRunsState, file?: string): boolean;
16
+ export declare function markRecorded(state: HeartbeatRunsState, sessionId: string): void;
@@ -0,0 +1,58 @@
1
+ // Heartbeat flight recorder — persisted scan state (fs only, network-free).
2
+ //
3
+ // The recorder must survive a bridge restart without re-posting every beat it
4
+ // already reported, so it keeps a bounded list of recorded session ids plus
5
+ // the mtime watermark of its last scan at ~/.reefclaw/heartbeat-runs-state.json.
6
+ // Fail-open everywhere: an unreadable/garbled file is the same as a fresh
7
+ // install (the webapp upsert is idempotent on (user, run id), so a re-post is
8
+ // harmless — just wasted bytes).
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ export const STATE_VERSION = 1;
13
+ /** Session ids remembered — ~2 days of 15-min beats. */
14
+ export const RECORDED_MAX = 300;
15
+ export function defaultStateFile() {
16
+ return join(homedir(), '.reefclaw', 'heartbeat-runs-state.json');
17
+ }
18
+ export function emptyState() {
19
+ return { version: STATE_VERSION, recorded: [], lastScanMtimeMs: 0 };
20
+ }
21
+ export function loadState(file = defaultStateFile()) {
22
+ try {
23
+ if (!existsSync(file))
24
+ return emptyState();
25
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
26
+ if (!raw || typeof raw !== 'object' || raw.version !== STATE_VERSION)
27
+ return emptyState();
28
+ const recorded = Array.isArray(raw.recorded) ? raw.recorded.filter((s) => typeof s === 'string') : [];
29
+ const last = typeof raw.lastScanMtimeMs === 'number' && Number.isFinite(raw.lastScanMtimeMs) ? raw.lastScanMtimeMs : 0;
30
+ return { version: STATE_VERSION, recorded: recorded.slice(-RECORDED_MAX), lastScanMtimeMs: last };
31
+ }
32
+ catch {
33
+ return emptyState();
34
+ }
35
+ }
36
+ /** Best-effort write; returns false when the filesystem refused. */
37
+ export function saveState(state, file = defaultStateFile()) {
38
+ try {
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ const trimmed = {
41
+ version: STATE_VERSION,
42
+ recorded: state.recorded.slice(-RECORDED_MAX),
43
+ lastScanMtimeMs: state.lastScanMtimeMs,
44
+ };
45
+ writeFileSync(file, JSON.stringify(trimmed), 'utf8');
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ export function markRecorded(state, sessionId) {
53
+ if (state.recorded.includes(sessionId))
54
+ return;
55
+ state.recorded.push(sessionId);
56
+ if (state.recorded.length > RECORDED_MAX)
57
+ state.recorded.splice(0, state.recorded.length - RECORDED_MAX);
58
+ }
@@ -0,0 +1,99 @@
1
+ import { type CronRunLogLite, type HeartbeatRunRecord } from './heartbeat-transcript.js';
2
+ import { type HeartbeatRunsState } from './heartbeat-runs-state.js';
3
+ import type { AgentRunLifecycleData } from './types.js';
4
+ /** Regular scan cadence. */
5
+ export declare const SCAN_INTERVAL_MS = 60000;
6
+ /** Delay after a cron-session turn ends before scanning (lets the gateway
7
+ * flush the transcript tail + write the run-log row). */
8
+ export declare const POST_TURN_DELAY_MS = 4000;
9
+ /** First scan after start — after the WS connects and the box settles. */
10
+ export declare const FIRST_SCAN_DELAY_MS = 15000;
11
+ /** A file changed more recently than this is possibly still being written. */
12
+ export declare const SETTLE_MS = 3000;
13
+ /** A mid-chain transcript older than this is recorded as 'incomplete'. */
14
+ export declare const INCOMPLETE_AFTER_MS: number;
15
+ /** Watermark slack — files can land with an mtime older than "now". */
16
+ export declare const SCAN_SLACK_MS: number;
17
+ /** Fresh install: look back this far for beats to backfill. */
18
+ export declare const BOOTSTRAP_LOOKBACK_MS: number;
19
+ /** Records processed per scan (bounds CPU + POST burst on a backfill). */
20
+ export declare const MAX_PER_SCAN = 5;
21
+ /** Give up on a run after this many failed POST cycles. */
22
+ export declare const MAX_POST_ATTEMPTS = 5;
23
+ /** Cool down `cron.runs` for this long after it errors (older gateway). */
24
+ export declare const CRON_RUNS_COOLDOWN_MS: number;
25
+ export type { AgentRunLifecycleData };
26
+ /** What the recorder needs from the provider — kept narrow so it is trivially
27
+ * mockable and the provider stays the only thing that talks to the gateway. */
28
+ export interface HeartbeatRunSource {
29
+ /** `cron.runs` for the heartbeat job, newest first; null when unavailable. */
30
+ fetchCronRuns(limit: number): Promise<CronRunLogLite[] | null>;
31
+ /** Dashboard hook: the provider stores a compact summary + model health. */
32
+ noteHeartbeatRun(record: HeartbeatRunRecord): void;
33
+ /** Active trading book, for the record's mode tag. */
34
+ getTradingBook(): 'paper' | 'live';
35
+ /** Subscribe to agent turn lifecycle (with the gateway's session key). */
36
+ onAgentRunLifecycle(listener: (d: AgentRunLifecycleData) => void): () => void;
37
+ }
38
+ export interface HeartbeatRunRecorderOptions {
39
+ source: HeartbeatRunSource;
40
+ /** The rc_ connection token (Bearer for the webapp ingest). */
41
+ token: string;
42
+ webappUrl?: string;
43
+ sessionsDir?: string;
44
+ stateFile?: string;
45
+ fetchImpl?: typeof fetch;
46
+ now?: () => number;
47
+ scanIntervalMs?: number;
48
+ /** Disable the periodic timer (tests drive `scan()` directly). */
49
+ manual?: boolean;
50
+ }
51
+ export interface PostOutcome {
52
+ ok: boolean;
53
+ /** 4xx → terminal (never retry); network/5xx → retry. */
54
+ terminal: boolean;
55
+ status: number | null;
56
+ error?: string;
57
+ }
58
+ export declare class HeartbeatRunRecorder {
59
+ private readonly opts;
60
+ private readonly sessionsDir;
61
+ private readonly stateFile;
62
+ private timer;
63
+ private kickTimer;
64
+ private unsubscribe;
65
+ private scanning;
66
+ private stopped;
67
+ private readonly attempts;
68
+ private cronRunsUnavailableUntil;
69
+ private probeLogged;
70
+ /** Counters for a one-line health summary in logs. */
71
+ readonly stats: {
72
+ scans: number;
73
+ recorded: number;
74
+ posted: number;
75
+ postFailed: number;
76
+ skippedNonCron: number;
77
+ deferred: number;
78
+ };
79
+ constructor(opts: HeartbeatRunRecorderOptions);
80
+ private now;
81
+ start(): void;
82
+ stop(): void;
83
+ /** A cron-session turn ended → scan shortly (debounced). */
84
+ onLifecycle(d: AgentRunLifecycleData): void;
85
+ private kick;
86
+ /** One scan cycle. Safe to call concurrently (re-entrancy guarded). */
87
+ scan(): Promise<{
88
+ processed: number;
89
+ deferred: number;
90
+ }>;
91
+ /** Is this session a heartbeat? Head marker first (cheap), trajectory key
92
+ * second (covers a prompt without the marker), else not a heartbeat. */
93
+ private classify;
94
+ private fetchCronRunsSafe;
95
+ /** POST one record to the webapp. Never throws. */
96
+ post(record: HeartbeatRunRecord): Promise<PostOutcome>;
97
+ }
98
+ /** Test seam: expose the state loader so tests can assert persistence. */
99
+ export declare function readRecorderState(stateFile: string): HeartbeatRunsState;
@@ -0,0 +1,300 @@
1
+ // Heartbeat flight recorder — RECORDER (scheduling + cron run-log enrichment
2
+ // + webapp POST). File reads live in heartbeat-transcript.ts / -state.ts.
3
+ //
4
+ // What it does, every 60s and ~4s after any cron-session turn ends:
5
+ // 1. list transcripts in the OpenClaw sessions dir newer than the last
6
+ // scan watermark (minus slack) that are not yet recorded;
7
+ // 2. keep the ones whose first user message carries the `[cron:…]`
8
+ // heartbeat marker (or whose trajectory key is a cron key); mark every
9
+ // other session (the main chat session, subagents) as skipped once so
10
+ // it is never re-read;
11
+ // 3. skip a beat still being written (last assistant message ended on a
12
+ // tool call and the file changed <25 min ago) — it comes back next scan;
13
+ // 4. enrich from the gateway's `cron.runs` log (status, FailoverReason,
14
+ // duration, model, TOKEN COUNTS) when the RPC is available;
15
+ // 5. hand the record to the provider (dashboard header: last beat + model
16
+ // health) and POST it to the webapp (idempotent on (user, run id)).
17
+ //
18
+ // Failure posture: fail-open and bounded. A webapp outage retries the same
19
+ // run up to 5 cycles then gives up on it (the next beat is a fresh record);
20
+ // a 4xx is terminal (never re-posted); the recorder never blocks trading,
21
+ // chat, or emergency paths — it only reads files and posts JSON.
22
+ //
23
+ // Deliberately NOT recorded: shock wakes and operator chat turns (they run in
24
+ // the main session, whose transcript is one long file). Heartbeats only.
25
+ import { logger } from './logger.js';
26
+ import { buildHeartbeatRunRecord, isCronSessionKey, listSessionCandidates, readCronMarker, readSession, resolveSessionsDir, shrinkRecord, } from './heartbeat-transcript.js';
27
+ import { defaultStateFile, loadState, markRecorded, saveState } from './heartbeat-runs-state.js';
28
+ const TAG = 'heartbeat-runs';
29
+ /** Regular scan cadence. */
30
+ export const SCAN_INTERVAL_MS = 60_000;
31
+ /** Delay after a cron-session turn ends before scanning (lets the gateway
32
+ * flush the transcript tail + write the run-log row). */
33
+ export const POST_TURN_DELAY_MS = 4_000;
34
+ /** First scan after start — after the WS connects and the box settles. */
35
+ export const FIRST_SCAN_DELAY_MS = 15_000;
36
+ /** A file changed more recently than this is possibly still being written. */
37
+ export const SETTLE_MS = 3_000;
38
+ /** A mid-chain transcript older than this is recorded as 'incomplete'. */
39
+ export const INCOMPLETE_AFTER_MS = 25 * 60_000;
40
+ /** Watermark slack — files can land with an mtime older than "now". */
41
+ export const SCAN_SLACK_MS = 15 * 60_000;
42
+ /** Fresh install: look back this far for beats to backfill. */
43
+ export const BOOTSTRAP_LOOKBACK_MS = 6 * 60 * 60_000;
44
+ /** Records processed per scan (bounds CPU + POST burst on a backfill). */
45
+ export const MAX_PER_SCAN = 5;
46
+ /** Give up on a run after this many failed POST cycles. */
47
+ export const MAX_POST_ATTEMPTS = 5;
48
+ /** Cool down `cron.runs` for this long after it errors (older gateway). */
49
+ export const CRON_RUNS_COOLDOWN_MS = 60 * 60_000;
50
+ export class HeartbeatRunRecorder {
51
+ opts;
52
+ sessionsDir;
53
+ stateFile;
54
+ timer = null;
55
+ kickTimer = null;
56
+ unsubscribe = null;
57
+ scanning = false;
58
+ stopped = false;
59
+ attempts = new Map();
60
+ cronRunsUnavailableUntil = 0;
61
+ probeLogged = false;
62
+ /** Counters for a one-line health summary in logs. */
63
+ stats = { scans: 0, recorded: 0, posted: 0, postFailed: 0, skippedNonCron: 0, deferred: 0 };
64
+ constructor(opts) {
65
+ this.opts = opts;
66
+ this.sessionsDir = opts.sessionsDir ?? resolveSessionsDir();
67
+ this.stateFile = opts.stateFile ?? defaultStateFile();
68
+ }
69
+ now() {
70
+ return this.opts.now?.() ?? Date.now();
71
+ }
72
+ start() {
73
+ if (this.stopped)
74
+ return;
75
+ this.unsubscribe = this.opts.source.onAgentRunLifecycle((d) => this.onLifecycle(d));
76
+ if (this.opts.manual)
77
+ return;
78
+ const interval = this.opts.scanIntervalMs ?? SCAN_INTERVAL_MS;
79
+ this.kick(FIRST_SCAN_DELAY_MS);
80
+ this.timer = setInterval(() => void this.scan(), interval);
81
+ this.timer.unref?.();
82
+ logger.info(TAG, `Heartbeat flight recorder started (dir=${this.sessionsDir}, every ${Math.round(interval / 1000)}s)`);
83
+ }
84
+ stop() {
85
+ this.stopped = true;
86
+ if (this.timer)
87
+ clearInterval(this.timer);
88
+ if (this.kickTimer)
89
+ clearTimeout(this.kickTimer);
90
+ this.timer = null;
91
+ this.kickTimer = null;
92
+ this.unsubscribe?.();
93
+ this.unsubscribe = null;
94
+ }
95
+ /** A cron-session turn ended → scan shortly (debounced). */
96
+ onLifecycle(d) {
97
+ if (d.phase === 'start')
98
+ return;
99
+ if (!isCronSessionKey(d.sessionKey))
100
+ return;
101
+ this.kick(POST_TURN_DELAY_MS);
102
+ }
103
+ kick(delayMs) {
104
+ if (this.stopped || this.opts.manual)
105
+ return;
106
+ if (this.kickTimer)
107
+ clearTimeout(this.kickTimer);
108
+ this.kickTimer = setTimeout(() => {
109
+ this.kickTimer = null;
110
+ void this.scan();
111
+ }, delayMs);
112
+ this.kickTimer.unref?.();
113
+ }
114
+ /** One scan cycle. Safe to call concurrently (re-entrancy guarded). */
115
+ async scan() {
116
+ if (this.scanning || this.stopped)
117
+ return { processed: 0, deferred: 0 };
118
+ this.scanning = true;
119
+ this.stats.scans++;
120
+ const now = this.now();
121
+ let processed = 0;
122
+ let deferred = 0;
123
+ try {
124
+ const state = loadState(this.stateFile);
125
+ const since = state.lastScanMtimeMs > 0 ? state.lastScanMtimeMs - SCAN_SLACK_MS : now - BOOTSTRAP_LOOKBACK_MS;
126
+ const exclude = new Set(state.recorded);
127
+ const candidates = listSessionCandidates(this.sessionsDir, { minMtimeMs: since, exclude })
128
+ .filter((c) => now - c.mtimeMs >= SETTLE_MS)
129
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
130
+ let cronRuns; // undefined = not fetched yet
131
+ let dirty = false;
132
+ for (const c of candidates) {
133
+ if (processed >= MAX_PER_SCAN) {
134
+ deferred++;
135
+ continue;
136
+ }
137
+ const verdict = this.classify(c);
138
+ if (verdict === 'not-cron') {
139
+ markRecorded(state, c.sessionId);
140
+ this.stats.skippedNonCron++;
141
+ dirty = true;
142
+ continue;
143
+ }
144
+ if (verdict === 'unreadable') {
145
+ deferred++;
146
+ continue;
147
+ }
148
+ const parsed = readSession(c);
149
+ if (!parsed) {
150
+ deferred++;
151
+ continue;
152
+ }
153
+ const { transcript, trajectory } = parsed;
154
+ const finished = transcript.complete || trajectory?.ended != null;
155
+ if (!finished && now - c.mtimeMs < INCOMPLETE_AFTER_MS) {
156
+ deferred++;
157
+ this.stats.deferred++;
158
+ continue;
159
+ }
160
+ if (cronRuns === undefined)
161
+ cronRuns = await this.fetchCronRunsSafe(now);
162
+ const runLog = cronRuns?.find((e) => e.sessionId === c.sessionId || e.runId === c.sessionId) ?? null;
163
+ let record = buildHeartbeatRunRecord({
164
+ sessionId: c.sessionId,
165
+ transcript,
166
+ trajectory,
167
+ runLog,
168
+ mode: this.opts.source.getTradingBook(),
169
+ fallbackStartedAtMs: c.mtimeMs,
170
+ });
171
+ record = shrinkRecord(record);
172
+ try {
173
+ this.opts.source.noteHeartbeatRun(record);
174
+ }
175
+ catch (err) {
176
+ logger.debug(TAG, `noteHeartbeatRun threw: ${err instanceof Error ? err.message : String(err)}`);
177
+ }
178
+ const outcome = await this.post(record);
179
+ processed++;
180
+ if (outcome.ok) {
181
+ this.stats.posted++;
182
+ this.stats.recorded++;
183
+ markRecorded(state, c.sessionId);
184
+ this.attempts.delete(c.sessionId);
185
+ dirty = true;
186
+ logger.info(TAG, `Heartbeat run recorded: ${c.sessionId.slice(0, 8)} status=${record.status} tools=${record.toolCallCount}` +
187
+ ` (${record.toolErrorCount} err) tokens=${record.tokens.total ?? 'n/a'}/${record.tokens.source}` +
188
+ ` model=${record.model ?? '?'} dur=${record.durationMs != null ? Math.round(record.durationMs / 1000) : '?'}s`);
189
+ }
190
+ else {
191
+ this.stats.postFailed++;
192
+ const n = (this.attempts.get(c.sessionId) ?? 0) + 1;
193
+ this.attempts.set(c.sessionId, n);
194
+ if (outcome.terminal || n >= MAX_POST_ATTEMPTS) {
195
+ markRecorded(state, c.sessionId);
196
+ this.attempts.delete(c.sessionId);
197
+ dirty = true;
198
+ logger.warn(TAG, `Heartbeat run ${c.sessionId.slice(0, 8)} NOT persisted (${outcome.terminal ? 'rejected' : 'gave up'}: ` +
199
+ `${outcome.status ?? 'network'} ${outcome.error ?? ''}) — shown on the dashboard, not in the journal`);
200
+ }
201
+ else {
202
+ deferred++;
203
+ logger.debug(TAG, `POST failed for ${c.sessionId.slice(0, 8)} (attempt ${n}): ${outcome.error ?? outcome.status}`);
204
+ }
205
+ }
206
+ }
207
+ // Advance the watermark only when nothing was deferred, so a beat still
208
+ // being written stays inside the window until it finishes.
209
+ if (deferred === 0) {
210
+ state.lastScanMtimeMs = now;
211
+ dirty = true;
212
+ }
213
+ if (dirty)
214
+ saveState(state, this.stateFile);
215
+ }
216
+ catch (err) {
217
+ logger.warn(TAG, `scan failed: ${err instanceof Error ? err.message : String(err)}`);
218
+ }
219
+ finally {
220
+ this.scanning = false;
221
+ }
222
+ return { processed, deferred };
223
+ }
224
+ /** Is this session a heartbeat? Head marker first (cheap), trajectory key
225
+ * second (covers a prompt without the marker), else not a heartbeat. */
226
+ classify(c) {
227
+ const marker = readCronMarker(c.file);
228
+ if (marker === undefined) {
229
+ // Inconclusive head — fall back to the trajectory key when present.
230
+ if (c.trajectoryFile) {
231
+ const parsed = readSession(c);
232
+ if (!parsed)
233
+ return 'unreadable';
234
+ if (parsed.transcript.cron)
235
+ return 'cron';
236
+ if (isCronSessionKey(parsed.trajectory?.sessionKey))
237
+ return 'cron';
238
+ return 'not-cron';
239
+ }
240
+ return c.sizeBytes === 0 ? 'unreadable' : 'not-cron';
241
+ }
242
+ return marker ? 'cron' : 'not-cron';
243
+ }
244
+ async fetchCronRunsSafe(now) {
245
+ if (now < this.cronRunsUnavailableUntil)
246
+ return null;
247
+ try {
248
+ const runs = await this.opts.source.fetchCronRuns(25);
249
+ if (runs === null) {
250
+ this.cronRunsUnavailableUntil = now + CRON_RUNS_COOLDOWN_MS;
251
+ if (!this.probeLogged) {
252
+ this.probeLogged = true;
253
+ logger.info(TAG, 'cron.runs unavailable on this gateway — token counts fall back to the trajectory/transcript');
254
+ }
255
+ return null;
256
+ }
257
+ return runs;
258
+ }
259
+ catch (err) {
260
+ this.cronRunsUnavailableUntil = now + CRON_RUNS_COOLDOWN_MS;
261
+ logger.debug(TAG, `cron.runs failed: ${err instanceof Error ? err.message : String(err)}`);
262
+ return null;
263
+ }
264
+ }
265
+ /** POST one record to the webapp. Never throws. */
266
+ async post(record) {
267
+ // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips the
268
+ // Authorization header on cross-origin redirect (verified 2026-03-18).
269
+ const base = (this.opts.webappUrl ?? process.env.REEFCLAW_API_URL ?? 'https://www.reefclaw.com').replace(/\/$/, '');
270
+ const fetchImpl = this.opts.fetchImpl ?? fetch;
271
+ try {
272
+ const res = await fetchImpl(`${base}/api/internal/heartbeat-runs`, {
273
+ method: 'POST',
274
+ headers: {
275
+ 'content-type': 'application/json',
276
+ authorization: `Bearer ${this.opts.token}`,
277
+ },
278
+ body: JSON.stringify(record),
279
+ signal: AbortSignal.timeout(15_000),
280
+ });
281
+ if (res.ok)
282
+ return { ok: true, terminal: false, status: res.status };
283
+ let text = '';
284
+ try {
285
+ text = (await res.text()).slice(0, 200);
286
+ }
287
+ catch {
288
+ /* ignore */
289
+ }
290
+ return { ok: false, terminal: res.status >= 400 && res.status < 500, status: res.status, error: text };
291
+ }
292
+ catch (err) {
293
+ return { ok: false, terminal: false, status: null, error: err instanceof Error ? err.message : String(err) };
294
+ }
295
+ }
296
+ }
297
+ /** Test seam: expose the state loader so tests can assert persistence. */
298
+ export function readRecorderState(stateFile) {
299
+ return loadState(stateFile);
300
+ }