/** * state.ts — The channel-state mirror: what survives one pi session. * * `~/.pi/wechat/channel-state.json` is a *mirror* of current state, not an * append log — a file that is only ever written and never cleared replays * messages that were already answered. Three things have to cross the gap * between instances: messages not yet answered, the dedup set (without it a * restored message has no second line of defence against an iLink * redelivery), and a reply that was generated but never delivered. * * Everything here is pure. The file I/O, the clock and the liveness probe * stay in index.ts and are passed in — which is what makes the awkward * cases (a live writer, a recycled pid, an interrupted turn) testable at * all. Type-only imports keep the module free of runtime relative imports, * as node's type stripper requires. */ import type { MediaInfo, QuotedInfo } from './ipc.js'; export declare const STATE_VERSION = 1; /** * How often the owning session rewrites the file purely to say "still * here", and how long a reader waits before deciding nobody is. * * This is a lease, and it exists because `process.kill(pid, 0)` cannot * tell a live pi from an unrelated process that inherited its pid. Without * it, one SIGKILL plus a pid rollover permanently convinces every later * session that someone else owns the channel — so it refuses the queue * *and* disables its own persistence, forever, with the explanation buried * in a log file. */ export declare const STATE_HEARTBEAT_MS = 30000; export declare const STATE_LEASE_MS: number; export interface QueuedMessage { userId: string; text: string; msgId: string; timestamp: string; hasMedia: boolean; media: MediaInfo | null; voiceText: string; quoted: QuotedInfo | null; /** * Attachments the SDK reported, of which only the first was downloaded. * Optional so a queue restored from a pre-0.3 state file still parses — * the note it drives is a courtesy, and losing it costs nothing. */ attachmentCount?: number; } export interface PendingDeliveryRecord { userId: string; text: string; attempts: number; /** Set when we were retired mid-send: the outcome is unknown. */ maybeDelivered?: boolean; /** When the reply started waiting for a channel that was down. */ parkedSince?: number | null; } export interface ParsedState { version: number; pid: number; /** `savedAt` as epoch ms; 0 when absent or unparseable. */ savedAtMs: number; queue: QueuedMessage[]; inFlight: QueuedMessage[]; dedupIds: string[]; delivery: PendingDeliveryRecord | null; /** Set only for the pre-0.2 file, so the caller knows what to remove. */ migratedFrom?: string; } /** * Read the current file format. Every field is defended individually: * a state file that fails to parse takes the queue and the undelivered * reply with it, so half a file is worth more than none. */ export declare function parseStateFile(text: string): ParsedState; /** * Read the pre-0.2 layout: a bare array of queued messages, one directory * above the extension. It carries no pid, so it is always adoptable. */ export declare function parseLegacyQueueFile(text: string, path: string): ParsedState; export interface AdoptionContext { nowMs: number; selfPid: number; /** Whether `parsed.pid` names a process that currently exists. */ writerAlive: boolean; } export type AdoptionDecision = { adopt: true; } | { adopt: false; reason: string; /** * The pid still holding the channel, or 0 when the refusal is not * about a live holder (a state file from a newer version). The caller * needs it to say *who* to close, and to tell the two cases apart: * one clears when that process exits, the other never does. */ holderPid: number; }; /** * May this instance take over the state in the file? * * Only if nobody else holds it. Two pi sessions sharing one state file * both answer the same queued messages, both deliver the same pending * reply, and overwrite each other's view on every change — one iLink * account supports one poller. A refusal is not just "don't read": the * caller must also disable its own persistence, or it stamps its pid over * the live owner's and the next reader adopts from under both of them. * * The writer counts as live only while its lease is fresh. A pid that is * alive but has not renewed in `STATE_LEASE_MS` is a pid that got recycled * after a SIGKILL, and treating that as an owner locks the channel out of * its own state permanently. */ export declare function shouldAdoptState(parsed: ParsedState, ctx: AdoptionContext): AdoptionDecision; /** * Is the claim in the file still ours? * * Two sessions starting in the same second can both find the file * unowned — or its lease expired — and both write their claim. Reading * back afterwards is what makes the loser notice: the file holds exactly * one pid, so at most one of them sees itself. Not airtight (writes and * reads can interleave), which is why the caller jitters the read; the * residual window is milliseconds wide, against a lease that is otherwise * decisive for sessions started seconds apart or more. */ export declare function claimHeldBy(parsed: ParsedState | null, selfPid: number): boolean; /** * Fold the restored messages in front of whatever this instance already * has. The interrupted turn's batch was dispatched before anything still * queued behind it, so it goes first, in that order. * * Restored messages are deliberately *not* filtered against the restored * dedup ids: a restored message's own id is in that set by construction, * so filtering would discard the entire queue. The only dedup that applies * here is against ids we are already holding — an instance that received a * message before it got round to loading the file. */ export declare function mergeRestored(inFlight: readonly T[], queue: readonly T[], current: readonly T[]): { queue: T[]; restoredCount: number; }; export type RestoredDelivery = /** Nothing was pending. */ { kind: 'none'; } /** Hold the queue behind it and retry, exactly as in-session delivery would. */ | { kind: 'retry'; delivery: PendingDeliveryRecord; } /** The predecessor died mid-send; WeChat may already have this text. */ | { kind: 'unknown'; userId: string; }; /** * What to do with a reply the previous instance never got out. * * A record marked `maybeDelivered` is dropped rather than resent — * duplicating an answer the owner has already read is the most jarring * failure this channel has, so the drop is recorded and surfaced through * `/wechat history` instead. The park clock restarts on restore: * inheriting the predecessor's would expire the moment we looked at it. */ export declare function planRestoredDelivery(delivery: PendingDeliveryRecord | null): RestoredDelivery; //# sourceMappingURL=state.d.ts.map