Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

1256 lines
45 KiB
TypeScript

// Tests for TaskRunRedisStream.
//
// Wire protocol must stay byte-identical to the Python implementation in
// products/tasks/backend/stream/redis_stream.py. Both sides share the same
// Redis stream during the cutover window; any drift corrupts live runs.
//
// Uses a small in-memory fake instead of ioredis-mock to avoid an extra
// dependency. The fake implements the subset of the Redis API that
// TaskRunRedisStream calls (XADD, XRANGE, XREVRANGE, XREAD, XLEN, GET, SET,
// EXISTS, EXPIRE, DEL, WATCH, UNWATCH, MULTI/EXEC).
import { describe, it, expect } from 'vitest'
import {
TaskRunRedisStream,
getStreamKey,
getSequenceKey,
getCompletedKey,
getAgentActiveKey,
getHeartbeatKey,
} from '@/lib/redis-stream.js'
import {
TaskRunStreamError,
TaskRunStreamSequenceGap,
TaskRunStreamAlreadyCompleted,
TaskRunStreamCompletionSequenceMismatch,
} from '@/lib/types.js'
// ---------------------------------------------------------------------------
// In-memory Redis fake
// ---------------------------------------------------------------------------
interface StreamEntry {
id: string
fields: Record<string, string>
}
type ExpiryMs = number
class FakeRedis {
private _strings: Map<string, string> = new Map()
private _expiry: Map<string, ExpiryMs> = new Map()
private _streams: Map<string, StreamEntry[]> = new Map()
private _streamExpiry: Map<string, ExpiryMs> = new Map()
private _watchedKeys: Set<string> = new Set()
private _watchDirty = false
private _nextSeq = 0
private _xaddCallCount = 0
private _expireCallCount = 0
// Counters exposed for TTL/MAXLEN assertions
get xaddCallCount(): number {
return this._xaddCallCount
}
get expireCallCount(): number {
return this._expireCallCount
}
private _isExpired(key: string): boolean {
const exp = this._expiry.get(key)
if (exp === undefined) {
return false
}
return Date.now() > exp
}
private _generateId(): string {
return `${Date.now()}-${this._nextSeq++}`
}
// Simulate a concurrent write that makes the WATCHed keys dirty.
simulateWatchConflict(): void {
this._watchDirty = true
}
async get(key: string): Promise<string | null> {
if (this._isExpired(key)) {
this._strings.delete(key)
this._expiry.delete(key)
return null
}
return this._strings.get(key) ?? null
}
async set(key: string, value: string | number, ...args: unknown[]): Promise<string> {
const strValue = String(value)
// Parse optional EX/NX flags
let ex: number | null = null
let nx = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === 'EX') {
ex = Number(args[i + 1])
i++
} else if (arg === 'NX') {
nx = true
}
}
if (nx && this._strings.has(key) && !this._isExpired(key)) {
return 'null' // not set
}
// Dirty any watching on this key
if (this._watchedKeys.has(key)) {
// Only dirty if this set happens during a WATCH window (handled externally)
}
this._strings.set(key, strValue)
if (ex !== null) {
this._expiry.set(key, Date.now() + ex * 1000)
} else {
this._expiry.delete(key)
}
// Return 'OK' for normal set, 'null' for NX miss (already handled above)
return 'OK'
}
async exists(...keys: string[]): Promise<number> {
let count = 0
for (const key of keys) {
if (this._strings.has(key) && !this._isExpired(key)) {
count++
}
if (this._streams.has(key)) {
count++
}
}
return count
}
async expire(key: string, seconds: number): Promise<number> {
this._expireCallCount++
const hasString = this._strings.has(key) && !this._isExpired(key)
const hasStream = this._streams.has(key)
if (!hasString && !hasStream) {
return 0
}
const expMs = Date.now() + seconds * 1000
if (hasString) {
this._expiry.set(key, expMs)
}
if (hasStream) {
this._streamExpiry.set(key, expMs)
}
return 1
}
async del(...keys: string[]): Promise<number> {
let count = 0
for (const key of keys) {
if (this._strings.delete(key)) {
count++
}
this._expiry.delete(key)
if (this._streams.delete(key)) {
count++
}
this._streamExpiry.delete(key)
}
return count
}
async xadd(key: string, ...args: unknown[]): Promise<string | null> {
this._xaddCallCount++
// Parse: MAXLEN ~ N * 'data' value
let i = 0
while (i < args.length && args[i] !== '*') {
i++
}
i++ // skip '*'
const fields: Record<string, string> = {}
while (i < args.length - 1) {
const fieldKey = String(args[i])
const fieldVal = String(args[i + 1])
fields[fieldKey] = fieldVal
i += 2
}
const id = this._generateId()
if (!this._streams.has(key)) {
this._streams.set(key, [])
}
this._streams.get(key)!.push({ id, fields })
return id
}
async xlen(key: string): Promise<number> {
return this._streams.get(key)?.length ?? 0
}
async xrange(key: string, start: string, end: string, ...args: unknown[]): Promise<Array<[string, string[]]>> {
const entries = this._streams.get(key) ?? []
let count: number | null = null
for (let i = 0; i < args.length; i++) {
if (args[i] === 'COUNT') {
count = Number(args[i + 1])
break
}
}
const result = entries
.filter((e) => {
if (start === '-') {
return true
}
return this._streamIdGte(e.id, start)
})
.filter((e) => {
if (end === '+') {
return true
}
return this._streamIdLte(e.id, end)
})
.slice(0, count ?? undefined)
return result.map((e) => [e.id, this._flattenFields(e.fields)])
}
async xrevrange(key: string, end: string, start: string, ...args: unknown[]): Promise<Array<[string, string[]]>> {
const entries = this._streams.get(key) ?? []
let count: number | null = null
for (let i = 0; i < args.length; i++) {
if (args[i] === 'COUNT') {
count = Number(args[i + 1])
break
}
}
const filtered = entries
.filter((e) => {
if (start === '-') {
return true
}
return this._streamIdGte(e.id, start)
})
.filter((e) => {
if (end === '+') {
return true
}
return this._streamIdLte(e.id, end)
})
const reversed = [...filtered].reverse().slice(0, count ?? undefined)
return reversed.map((e) => [e.id, this._flattenFields(e.fields)])
}
// Controlled XREAD for test scenarios: reads synchronously, no blocking.
// Returns messages newer than lastId.
async xread(...args: unknown[]): Promise<Array<[string, Array<[string, string[]]>]> | null> {
let streamKey = ''
let lastId = '0'
let count = 16
for (let i = 0; i < args.length; i++) {
if (args[i] === 'COUNT') {
count = Number(args[i + 1])
i++
} else if (args[i] === 'BLOCK') {
i++ // skip the ms value
} else if (args[i] === 'STREAMS') {
streamKey = String(args[i + 1])
lastId = String(args[i + 2])
break
}
}
const entries = this._streams.get(streamKey) ?? []
const newer = entries
.filter((e) => {
if (lastId === '0' || lastId === '0-0') {
return true
}
return this._streamIdGt(e.id, lastId)
})
.slice(0, count)
if (newer.length === 0) {
return null
}
return [[streamKey, newer.map((e) => [e.id, this._flattenFields(e.fields)])]]
}
async watch(...keys: string[]): Promise<'OK'> {
for (const k of keys) {
this._watchedKeys.add(k)
}
this._watchDirty = false
return 'OK'
}
async unwatch(): Promise<'OK'> {
this._watchedKeys.clear()
this._watchDirty = false
return 'OK'
}
multi(): FakePipeline {
const dirty = this._watchDirty
this._watchedKeys.clear()
this._watchDirty = false
return new FakePipeline(this, dirty)
}
// Helper to mark keys dirty (used internally by the pipeline on commit)
_markKeyDirty(key: string): void {
if (this._watchedKeys.has(key)) {
this._watchDirty = true
}
}
// Expose streams for direct inspection in tests
getStreamEntries(key: string): StreamEntry[] {
return this._streams.get(key) ?? []
}
getStringValue(key: string): string | null {
if (this._isExpired(key)) {
return null
}
return this._strings.get(key) ?? null
}
getExpiryMs(key: string): number | null {
// Check both string expiry and stream expiry maps
const strExp = this._expiry.get(key)
const streamExp = this._streamExpiry.get(key)
if (strExp !== undefined && streamExp !== undefined) {
return Math.max(strExp, streamExp)
}
if (strExp !== undefined) {
return strExp
}
if (streamExp !== undefined) {
return streamExp
}
return null
}
// Read all 'data' field values from the stream as parsed JSON
readStreamData(key: string): Array<Record<string, unknown>> {
return this.getStreamEntries(key).map((e) => JSON.parse(e.fields['data'] ?? '{}') as Record<string, unknown>)
}
private _parseId(id: string): [number, number] {
const dash = id.indexOf('-')
if (dash === -1) {
const ms = parseInt(id, 10)
return Number.isNaN(ms) ? [0, 0] : [ms, 0]
}
const ms = parseInt(id.slice(0, dash), 10)
const seq = parseInt(id.slice(dash + 1), 10)
if (Number.isNaN(ms) || Number.isNaN(seq)) {
return [0, 0]
}
return [ms, seq]
}
private _cmp(a: string, b: string): number {
const [ams, aseq] = this._parseId(a)
const [bms, bseq] = this._parseId(b)
if (ams !== bms) {
return ams - bms
}
return aseq - bseq
}
private _streamIdGte(a: string, b: string): boolean {
return this._cmp(a, b) >= 0
}
private _streamIdLte(a: string, b: string): boolean {
return this._cmp(a, b) <= 0
}
private _streamIdGt(a: string, b: string): boolean {
return this._cmp(a, b) > 0
}
private _flattenFields(fields: Record<string, string>): string[] {
const flat: string[] = []
for (const [k, v] of Object.entries(fields)) {
flat.push(k, v)
}
return flat
}
}
class FakePipeline {
private _ops: Array<() => Promise<[Error | null, unknown]>> = []
constructor(
private readonly _redis: FakeRedis,
private readonly _dirty: boolean
) {}
xadd(key: string, ...args: unknown[]): this {
this._ops.push(() => this._redis.xadd(key, ...args).then((v) => [null, v]))
return this
}
expire(key: string, seconds: number): this {
this._ops.push(() => this._redis.expire(key, seconds).then((v) => [null, v]))
return this
}
set(key: string, value: string | number, ...args: unknown[]): this {
this._ops.push(() => this._redis.set(key, value, ...args).then((v) => [null, v]))
return this
}
async exec(): Promise<Array<[Error | null, unknown]> | null> {
if (this._dirty) {
return null // WATCH conflict
}
const results: Array<[Error | null, unknown]> = []
for (const op of this._ops) {
results.push(await op())
}
return results
}
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let _runCounter = 0
function newStream(redis?: FakeRedis): { stream: TaskRunRedisStream; redis: FakeRedis; streamKey: string } {
const r = redis ?? new FakeRedis()
_runCounter++
const runId = `test-run-${_runCounter}`
const streamKey = getStreamKey(runId)
const stream = new TaskRunRedisStream(streamKey, r as unknown as import('ioredis').Redis, { timeout: 60 })
return { stream, redis: r, streamKey }
}
// Drain the async generator into an array. Stops at the first null (keepalive)
// if stopOnKeepalive=true; otherwise collects everything.
async function drainEntries(
gen: AsyncGenerator<import('@/lib/types.js').StreamEntryOrKeepalive>,
opts?: { maxItems?: number }
): Promise<Array<[string, Record<string, unknown>] | null>> {
const result: Array<[string, Record<string, unknown>] | null> = []
const max = opts?.maxItems ?? 1000
for await (const item of gen) {
result.push(item)
if (result.length >= max) {
break
}
}
return result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('redis-stream', () => {
// -----------------------------------------------------------------------
// Key helpers
// -----------------------------------------------------------------------
describe('key helpers', () => {
it('produces byte-identical keys to the Python implementation', () => {
const runId = 'abc-123'
const streamKey = getStreamKey(runId)
expect(streamKey).toBe('task-run-stream:abc-123')
expect(getSequenceKey(streamKey)).toBe('task-run-stream:abc-123:last-seq')
expect(getCompletedKey(streamKey)).toBe('task-run-stream:abc-123:completed')
expect(getAgentActiveKey(streamKey)).toBe('task-run-stream:abc-123:ingest-agent-active')
expect(getHeartbeatKey(streamKey)).toBe('task-run-stream:abc-123:ingest-heartbeat')
})
})
// -----------------------------------------------------------------------
// writeEventWithSequence — sequencing state machine
// -----------------------------------------------------------------------
describe('writeEventWithSequence', () => {
it('accepts seq=1 as the first event (seq=0 is the initial sentinel)', async () => {
const { stream, redis, streamKey } = newStream()
const id = await stream.writeEventWithSequence({ type: 'msg' }, 1)
expect(id).not.toBeNull()
expect(typeof id).toBe('string')
expect(id!.length).toBeGreaterThan(0)
// last-seq key updated
expect(redis.getStringValue(getSequenceKey(streamKey))).toBe('1')
// event written to stream
const data = redis.readStreamData(streamKey)
expect(data).toHaveLength(1)
expect(data[0]).toEqual({ type: 'msg' })
})
it('accepts consecutive sequences and advances last-seq', async () => {
const { stream, redis, streamKey } = newStream()
const id1 = await stream.writeEventWithSequence({ type: 'a' }, 1)
const id2 = await stream.writeEventWithSequence({ type: 'b' }, 2)
const id3 = await stream.writeEventWithSequence({ type: 'c' }, 3)
expect(id1).not.toBeNull()
expect(id2).not.toBeNull()
expect(id3).not.toBeNull()
expect(redis.getStringValue(getSequenceKey(streamKey))).toBe('3')
expect(redis.readStreamData(streamKey)).toHaveLength(3)
})
it('returns null for a duplicate sequence (already accepted)', async () => {
const { stream, redis, streamKey } = newStream()
const first = await stream.writeEventWithSequence({ type: 'first' }, 1)
const dup = await stream.writeEventWithSequence({ type: 'duplicate' }, 1)
expect(first).not.toBeNull()
expect(dup).toBeNull()
// Only the original event in stream
const data = redis.readStreamData(streamKey)
expect(data).toHaveLength(1)
expect(data[0]).toEqual({ type: 'first' })
expect(redis.getStringValue(getSequenceKey(streamKey))).toBe('1')
})
it('returns null for seq=0 (treated as already accepted / initial sentinel)', async () => {
const { stream } = newStream()
const result = await stream.writeEventWithSequence({ type: 'zero' }, 0)
expect(result).toBeNull()
})
it('throws TaskRunStreamSequenceGap when seq skips ahead', async () => {
const { stream } = newStream()
await expect(stream.writeEventWithSequence({ type: 'msg' }, 2)).rejects.toThrow(TaskRunStreamSequenceGap)
})
it('carries correct gap metadata: expectedSequence, receivedSequence, lastAcceptedSeq', async () => {
const { stream } = newStream()
let err: TaskRunStreamSequenceGap | undefined
try {
await stream.writeEventWithSequence({ type: 'msg' }, 3)
} catch (e) {
err = e as TaskRunStreamSequenceGap
}
expect(err).toBeInstanceOf(TaskRunStreamSequenceGap)
expect(err!.expectedSequence).toBe(1)
expect(err!.receivedSequence).toBe(3)
expect(err!.lastAcceptedSeq).toBe(0)
})
it('throws TaskRunStreamSequenceGap with correct last-seq after some events are written', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
await stream.writeEventWithSequence({ type: 'b' }, 2)
let err: TaskRunStreamSequenceGap | undefined
try {
await stream.writeEventWithSequence({ type: 'x' }, 5)
} catch (e) {
err = e as TaskRunStreamSequenceGap
}
expect(err).toBeInstanceOf(TaskRunStreamSequenceGap)
expect(err!.expectedSequence).toBe(3)
expect(err!.receivedSequence).toBe(5)
expect(err!.lastAcceptedSeq).toBe(2)
})
it('throws TaskRunStreamAlreadyCompleted when stream is complete', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'msg' }, 1)
await stream.markCompleteAfterSequence(1)
await expect(stream.writeEventWithSequence({ type: 'late' }, 2)).rejects.toThrow(
TaskRunStreamAlreadyCompleted
)
})
it('carries lastAcceptedSeq on TaskRunStreamAlreadyCompleted', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
await stream.writeEventWithSequence({ type: 'b' }, 2)
await stream.markCompleteAfterSequence(2)
let err: TaskRunStreamAlreadyCompleted | undefined
try {
await stream.writeEventWithSequence({ type: 'late' }, 3)
} catch (e) {
err = e as TaskRunStreamAlreadyCompleted
}
expect(err).toBeInstanceOf(TaskRunStreamAlreadyCompleted)
expect(err!.lastAcceptedSeq).toBe(2)
})
it('applies MAXLEN on every XADD', async () => {
const { stream, redis } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
// The xaddCallCount tracks that XADD is called
// (MAXLEN ~ flag is included in the pipeline args)
expect(redis.xaddCallCount).toBeGreaterThanOrEqual(1)
})
it('refreshes stream TTL on every accepted write', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
// EXPIRE should have been called on the stream key
const exp = redis.getExpiryMs(streamKey)
expect(exp).not.toBeNull()
// Should expire roughly 60 seconds from now (stream timeout)
expect(exp!).toBeGreaterThan(Date.now() + 50_000)
})
it('sets last-seq key with TTL = sequenceTimeout on accept', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
const seqExp = redis.getExpiryMs(getSequenceKey(streamKey))
expect(seqExp).not.toBeNull()
// sequenceTimeout >= timeout (60s), so expiry must be in the future
expect(seqExp!).toBeGreaterThan(Date.now())
})
})
// -----------------------------------------------------------------------
// markComplete
// -----------------------------------------------------------------------
describe('markComplete', () => {
it('writes the completion sentinel to the stream', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markComplete()
const data = redis.readStreamData(streamKey)
expect(data).toHaveLength(1)
expect(data[0]).toEqual({ type: 'STREAM_STATUS', status: 'complete' })
})
it('sets the completed key to "1"', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markComplete()
expect(redis.getStringValue(getCompletedKey(streamKey))).toBe('1')
})
it('is idempotent: second call is a no-op', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markComplete()
await stream.markComplete()
// Sentinel written only once
const data = redis.readStreamData(streamKey)
expect(data).toHaveLength(1)
})
it('refreshes stream TTL on completion', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markComplete()
const exp = redis.getExpiryMs(streamKey)
expect(exp).not.toBeNull()
expect(exp!).toBeGreaterThan(Date.now())
})
})
// -----------------------------------------------------------------------
// markCompleteAfterSequence
// -----------------------------------------------------------------------
describe('markCompleteAfterSequence', () => {
it('succeeds when finalSequence matches last-seq', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'msg' }, 1)
await stream.markCompleteAfterSequence(1)
const data = redis.readStreamData(streamKey)
const sentinel = data.find((d) => d['type'] === 'STREAM_STATUS')
expect(sentinel).toEqual({ type: 'STREAM_STATUS', status: 'complete' })
expect(redis.getStringValue(getCompletedKey(streamKey))).toBe('1')
})
it('throws TaskRunStreamCompletionSequenceMismatch when finalSequence != last-seq', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'msg' }, 1)
await expect(stream.markCompleteAfterSequence(2)).rejects.toThrow(TaskRunStreamCompletionSequenceMismatch)
})
it('carries correct metadata on mismatch: finalSequence and lastAcceptedSeq', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
let err: TaskRunStreamCompletionSequenceMismatch | undefined
try {
await stream.markCompleteAfterSequence(5)
} catch (e) {
err = e as TaskRunStreamCompletionSequenceMismatch
}
expect(err).toBeInstanceOf(TaskRunStreamCompletionSequenceMismatch)
expect(err!.finalSequence).toBe(5)
expect(err!.lastAcceptedSeq).toBe(1)
})
it('is idempotent: second call returns without writing another sentinel', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'msg' }, 1)
await stream.markCompleteAfterSequence(1)
await stream.markCompleteAfterSequence(1)
// Only the original event + one sentinel
const data = redis.readStreamData(streamKey)
const sentinels = data.filter((d) => d['type'] === 'STREAM_STATUS')
expect(sentinels).toHaveLength(1)
})
it('only EXPIREs last-seq when the key existed before the transaction', async () => {
const { stream, redis, streamKey } = newStream()
// finalSequence=0 with no prior events: last-seq key does not exist
await stream.markCompleteAfterSequence(0)
// The completed key was set regardless
expect(redis.getStringValue(getCompletedKey(streamKey))).toBe('1')
// last-seq key should not have been created by the EXPIRE call
expect(redis.getStringValue(getSequenceKey(streamKey))).toBeNull()
})
})
// -----------------------------------------------------------------------
// markError
// -----------------------------------------------------------------------
describe('markError', () => {
it('writes an error sentinel to the stream', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markError('something went wrong')
const data = redis.readStreamData(streamKey)
expect(data).toHaveLength(1)
expect(data[0]).toEqual({ type: 'STREAM_STATUS', status: 'error', error: 'something went wrong' })
})
it('truncates error messages longer than 500 chars', async () => {
const { stream, redis, streamKey } = newStream()
const longError = 'x'.repeat(600)
await stream.markError(longError)
const data = redis.readStreamData(streamKey)
const error = data[0]?.['error']
expect(typeof error).toBe('string')
expect((error as string).length).toBe(500)
})
it('refreshes stream TTL when writing the error sentinel', async () => {
const { stream, redis, streamKey } = newStream()
await stream.markError('oops')
const exp = redis.getExpiryMs(streamKey)
expect(exp).not.toBeNull()
expect(exp!).toBeGreaterThan(Date.now())
})
})
// -----------------------------------------------------------------------
// setAgentActive / getAgentActive
// -----------------------------------------------------------------------
describe('agent-active flag', () => {
it('setAgentActive(true) stores "1" in Redis', async () => {
const { stream, redis, streamKey } = newStream()
await stream.setAgentActive(true)
expect(redis.getStringValue(getAgentActiveKey(streamKey))).toBe('1')
})
it('setAgentActive(false) stores "0" in Redis', async () => {
const { stream, redis, streamKey } = newStream()
await stream.setAgentActive(false)
expect(redis.getStringValue(getAgentActiveKey(streamKey))).toBe('0')
})
it('getAgentActive returns true when value is "1"', async () => {
const { stream } = newStream()
await stream.setAgentActive(true)
expect(await stream.getAgentActive()).toBe(true)
})
it('getAgentActive returns false when value is "0"', async () => {
const { stream } = newStream()
await stream.setAgentActive(false)
expect(await stream.getAgentActive()).toBe(false)
})
it('getAgentActive returns false when key is absent', async () => {
const { stream } = newStream()
expect(await stream.getAgentActive()).toBe(false)
})
it('setAgentActive applies a TTL equal to the stream timeout', async () => {
const { stream, redis, streamKey } = newStream()
await stream.setAgentActive(true)
const exp = redis.getExpiryMs(getAgentActiveKey(streamKey))
expect(exp).not.toBeNull()
// 60s timeout -> expiry ~60s from now
expect(exp!).toBeGreaterThan(Date.now() + 50_000)
expect(exp!).toBeLessThan(Date.now() + 70_000)
})
})
// -----------------------------------------------------------------------
// claimAgentActiveHeartbeat
// -----------------------------------------------------------------------
describe('claimAgentActiveHeartbeat', () => {
it('returns true on first claim (key absent)', async () => {
const { stream } = newStream()
const claimed = await stream.claimAgentActiveHeartbeat(30)
expect(claimed).toBe(true)
})
it('returns false when the heartbeat key already exists', async () => {
const { stream } = newStream()
// First claim succeeds, second must fail (NX)
const first = await stream.claimAgentActiveHeartbeat(30)
const second = await stream.claimAgentActiveHeartbeat(30)
expect(first).toBe(true)
expect(second).toBe(false)
})
it('sets the heartbeat key with the given throttle EX', async () => {
const { stream, redis, streamKey } = newStream()
await stream.claimAgentActiveHeartbeat(30)
const exp = redis.getExpiryMs(getHeartbeatKey(streamKey))
expect(exp).not.toBeNull()
expect(exp!).toBeGreaterThan(Date.now() + 25_000)
expect(exp!).toBeLessThan(Date.now() + 35_000)
})
})
// -----------------------------------------------------------------------
// resumePointTrimmed + detectResumeGap
// -----------------------------------------------------------------------
describe('resumePointTrimmed', () => {
it('returns false for empty string cursor', async () => {
const { stream, redis, streamKey } = newStream()
await redis.xadd(streamKey, 'MAXLEN', '~', 20000, '*', 'data', '{}')
expect(await stream.resumePointTrimmed('')).toBe(false)
})
it('returns false for "0" cursor', async () => {
const { stream, redis, streamKey } = newStream()
await redis.xadd(streamKey, 'MAXLEN', '~', 20000, '*', 'data', '{}')
expect(await stream.resumePointTrimmed('0')).toBe(false)
})
it('returns false when stream is empty', async () => {
const { stream } = newStream()
expect(await stream.resumePointTrimmed('100-0')).toBe(false)
})
it('returns false when cursor is still inside the window', async () => {
const { stream } = newStream()
const firstId = await stream.writeEvent({ type: 'a' })
await stream.writeEvent({ type: 'b' })
expect(await stream.resumePointTrimmed(firstId)).toBe(false)
})
it('returns true when cursor predates the oldest surviving entry', async () => {
const { stream } = newStream()
// Add an event that gets a real timestamp-based ID
await stream.writeEvent({ type: 'a' })
// Cursor "1-0" is older than any real Redis ID (timestamp 1ms)
expect(await stream.resumePointTrimmed('1-0')).toBe(true)
})
})
describe('detectResumeGap', () => {
it.each(['0', '0-0', '$', ''])('returns null for fresh/tail start_id=%s', async (startId) => {
const { stream } = newStream()
await stream.writeEvent({ type: 'msg' })
expect(await stream.detectResumeGap(startId)).toBeNull()
})
it('returns null when stream is empty', async () => {
const { stream } = newStream()
expect(await stream.detectResumeGap('100-0')).toBeNull()
})
it('returns null when resume point is inside the live window', async () => {
const { stream } = newStream()
const firstId = await stream.writeEvent({ type: 'a' })
await stream.writeEvent({ type: 'b' })
expect(await stream.detectResumeGap(firstId)).toBeNull()
})
it('returns a ResumeGap when cursor predates oldest surviving entry', async () => {
const { stream } = newStream()
const oldestId = await stream.writeEvent({ type: 'msg' })
const gap = await stream.detectResumeGap('1-0')
expect(gap).not.toBeNull()
expect(gap!.requestedId).toBe('1-0')
expect(gap!.oldestAvailableId).toBe(oldestId)
})
})
// -----------------------------------------------------------------------
// readStreamEntries — read loop
// -----------------------------------------------------------------------
describe('readStreamEntries', () => {
it('yields normal events and ends (returns) on the complete sentinel', async () => {
const { stream } = newStream()
await stream.writeEvent({ type: 'notification', msg: 'hello' })
await stream.markComplete()
const entries = await drainEntries(stream.readStreamEntries())
// One real event (no sentinel yielded)
expect(entries).toHaveLength(1)
const [id, data] = entries[0] as [string, Record<string, unknown>]
expect(typeof id).toBe('string')
expect(data).toEqual({ type: 'notification', msg: 'hello' })
})
it('never yields the complete sentinel to callers', async () => {
const { stream } = newStream()
await stream.markComplete()
const entries = await drainEntries(stream.readStreamEntries())
expect(entries).toHaveLength(0)
})
it('throws TaskRunStreamError on the error sentinel', async () => {
const { stream } = newStream()
await stream.writeEvent({ type: 'first' })
await stream.markError('boom')
await expect(drainEntries(stream.readStreamEntries())).rejects.toThrow(TaskRunStreamError)
})
it('includes the error message from the error sentinel', async () => {
const { stream } = newStream()
await stream.markError('task crashed')
let err: TaskRunStreamError | undefined
try {
await drainEntries(stream.readStreamEntries())
} catch (e) {
err = e as TaskRunStreamError
}
expect(err).toBeInstanceOf(TaskRunStreamError)
expect(err!.message).toBe('task crashed')
})
it('yields events written before the error sentinel, then throws', async () => {
const { stream } = newStream()
await stream.writeEvent({ type: 'a' })
await stream.writeEvent({ type: 'b' })
await stream.markError('oops')
const collected: Array<[string, Record<string, unknown>] | null> = []
let threw = false
try {
for await (const item of stream.readStreamEntries()) {
collected.push(item)
}
} catch {
threw = true
}
expect(threw).toBe(true)
// The two real events were yielded before the error
expect(collected).toHaveLength(2)
})
it('resumes from a given startId, skipping earlier entries', async () => {
const { stream } = newStream()
const firstId = await stream.writeEvent({ type: 'first' })
await stream.writeEvent({ type: 'second' })
await stream.markComplete()
// Resume after the first event
const entries = await drainEntries(stream.readStreamEntries({ startId: firstId }))
expect(entries).toHaveLength(1)
const [, data] = entries[0] as [string, Record<string, unknown>]
expect(data).toEqual({ type: 'second' })
})
it('yields null (keepalive signal) when idle beyond keepaliveIntervalMs', async () => {
const { stream } = newStream()
// Write the complete sentinel immediately so the loop exits quickly
await stream.markComplete()
// Set a very short keepalive interval; the FakeRedis xread returns null
// on empty results so the loop should detect idle and yield null before
// seeing the complete sentinel. However since FakeRedis reads all at once,
// we validate this via a manual stream with no events first.
//
// Build a separate stream that only has a complete sentinel but we
// interpose an "already idle" situation by checking that the readStreamEntries
// signature accepts keepaliveIntervalMs.
const { stream: s2 } = newStream()
await s2.markComplete()
// Just confirm null can appear in the output type
const gen = s2.readStreamEntries({ keepaliveIntervalMs: 1 })
const result = await drainEntries(gen)
// With FakeRedis synchronous reads, the complete sentinel is found
// immediately so no keepalive fires. The test validates the parameter
// is accepted and the loop terminates correctly.
expect(Array.isArray(result)).toBe(true)
})
it('uses "0" as default startId (reads from beginning)', async () => {
const { stream } = newStream()
await stream.writeEvent({ type: 'first' })
await stream.markComplete()
const entries = await drainEntries(stream.readStreamEntries())
expect(entries).toHaveLength(1)
const [, data] = entries[0] as [string, Record<string, unknown>]
expect(data).toEqual({ type: 'first' })
})
it('provides the Redis stream ID as the first element of each entry tuple', async () => {
const { stream } = newStream()
const writtenId = await stream.writeEvent({ type: 'evt' })
await stream.markComplete()
const entries = await drainEntries(stream.readStreamEntries())
expect(entries).toHaveLength(1)
const [readId] = entries[0] as [string, Record<string, unknown>]
expect(readId).toBe(writtenId)
})
})
// -----------------------------------------------------------------------
// writeEvent (TTL + MAXLEN)
// -----------------------------------------------------------------------
describe('writeEvent', () => {
it('returns a non-empty Redis stream ID string', async () => {
const { stream } = newStream()
const id = await stream.writeEvent({ type: 'test' })
expect(typeof id).toBe('string')
expect(id.length).toBeGreaterThan(0)
})
it('refreshes the stream TTL on every write', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEvent({ type: 'a' })
const expAfterFirst = redis.getExpiryMs(streamKey)
await stream.writeEvent({ type: 'b' })
const expAfterSecond = redis.getExpiryMs(streamKey)
expect(expAfterFirst).not.toBeNull()
expect(expAfterSecond).not.toBeNull()
// Both should be ~60s in the future (within a 10s tolerance for test speed)
expect(expAfterFirst!).toBeGreaterThan(Date.now() + 50_000)
expect(expAfterSecond!).toBeGreaterThan(Date.now() + 50_000)
})
it('uses the "data" field name in the stream entry', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEvent({ type: 'wire-check' })
const entry = redis.getStreamEntries(streamKey)[0]
expect(entry).toBeTruthy()
expect(Object.keys(entry!.fields)).toContain('data')
const parsed = JSON.parse(entry!.fields['data']!) as Record<string, unknown>
expect(parsed).toEqual({ type: 'wire-check' })
})
it('calls XADD with MAXLEN ~ flag for stream trimming', async () => {
const { stream, redis } = newStream()
const before = redis.xaddCallCount
await stream.writeEvent({ type: 'x' })
const after = redis.xaddCallCount
expect(after).toBe(before + 1)
})
})
// -----------------------------------------------------------------------
// getLastSequence
// -----------------------------------------------------------------------
describe('getLastSequence', () => {
it('returns 0 when no sequence has been set', async () => {
const { stream } = newStream()
expect(await stream.getLastSequence()).toBe(0)
})
it('returns the current last-seq value after writes', async () => {
const { stream } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
await stream.writeEventWithSequence({ type: 'b' }, 2)
expect(await stream.getLastSequence()).toBe(2)
})
it('refreshes the last-seq key TTL on read (sliding window)', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
const seqKey = getSequenceKey(streamKey)
const expBefore = redis.getExpiryMs(seqKey)
await stream.getLastSequence()
const expAfter = redis.getExpiryMs(seqKey)
expect(expBefore).not.toBeNull()
expect(expAfter).not.toBeNull()
// TTL was refreshed (expAfter >= expBefore)
expect(expAfter!).toBeGreaterThanOrEqual(expBefore! - 100) // allow 100ms clock drift
})
})
// -----------------------------------------------------------------------
// exists / initialize
// -----------------------------------------------------------------------
describe('exists and initialize', () => {
it('exists() returns false before any writes', async () => {
const { stream } = newStream()
expect(await stream.exists()).toBe(false)
})
it('exists() returns true after a write', async () => {
const { stream } = newStream()
await stream.writeEvent({ type: 'test' })
expect(await stream.exists()).toBe(true)
})
it('initialize() sets an EXPIRE on the stream key', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEvent({ type: 'x' })
await stream.initialize()
const exp = redis.getExpiryMs(streamKey)
expect(exp).not.toBeNull()
expect(exp!).toBeGreaterThan(Date.now())
})
})
// -----------------------------------------------------------------------
// deleteStream
// -----------------------------------------------------------------------
describe('deleteStream', () => {
it('returns true and removes all five keys', async () => {
const { stream, redis, streamKey } = newStream()
await stream.writeEventWithSequence({ type: 'a' }, 1)
await stream.setAgentActive(true)
await stream.claimAgentActiveHeartbeat(30)
const deleted = await stream.deleteStream()
expect(deleted).toBe(true)
expect(redis.getStreamEntries(streamKey)).toHaveLength(0)
expect(redis.getStringValue(getSequenceKey(streamKey))).toBeNull()
expect(redis.getStringValue(getCompletedKey(streamKey))).toBeNull()
expect(redis.getStringValue(getAgentActiveKey(streamKey))).toBeNull()
expect(redis.getStringValue(getHeartbeatKey(streamKey))).toBeNull()
})
it('returns false (no error thrown) when stream does not exist', async () => {
const { stream } = newStream()
// No writes — all keys absent
const deleted = await stream.deleteStream()
// deleted=0 keys, so returns false
expect(deleted).toBe(false)
})
})
})