From c0ba9b84db3c44c641634012c21861e923917a23 Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:03:10 +0800 Subject: [PATCH 1/2] feat(frontend): add owner-scoped reminder store This file implements a FrontendReminderStore for managing reminders with persistence, including methods for creating, retrieving, listing, and managing reminder statuses. --- .../src/conversation/frontend-reminders.mjs | 694 ++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 server/src/conversation/frontend-reminders.mjs diff --git a/server/src/conversation/frontend-reminders.mjs b/server/src/conversation/frontend-reminders.mjs new file mode 100644 index 00000000..a030a9ac --- /dev/null +++ b/server/src/conversation/frontend-reminders.mjs @@ -0,0 +1,694 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname } from 'node:path' +import { + replaceFileSync, + withFileTransaction, +} from '../../../shared/file-transaction-lock.mjs' + +export const REMINDER_STORE_VERSION = 1 +export const MAX_REMINDERS_PER_OWNER = 100 +export const MAX_REMINDER_TEXT_CHARS = 500 +export const MAX_REMINDER_OWNER_ID_CHARS = 200 +export const MAX_REMINDER_TIMEZONE_CHARS = 100 + +export const ReminderKind = Object.freeze({ + REMINDER: 'reminder', + TASK: 'task', +}) + +export const ReminderRecurrence = Object.freeze({ + ONCE: 'once', + DAILY: 'daily', + WEEKLY: 'weekly', + WEEKDAYS: 'weekdays', +}) + +export const ReminderStatus = Object.freeze({ + ACTIVE: 'active', + FIRING: 'firing', + COMPLETED: 'completed', + CANCELLED: 'cancelled', + FAILED: 'failed', +}) + +const KINDS = new Set(Object.values(ReminderKind)) +const RECURRENCES = new Set(Object.values(ReminderRecurrence)) +const STATUSES = new Set(Object.values(ReminderStatus)) +const TERMINAL_STATUSES = new Set([ + ReminderStatus.COMPLETED, + ReminderStatus.CANCELLED, + ReminderStatus.FAILED, +]) + +function clean(value, maxChars) { + return [...String(value ?? '').replace(/\s+/g, ' ').trim()] + .slice(0, maxChars) + .join('') +} + +function plainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function timestamp(value, code = 'invalid_execute_at') { + const number = Number(value) + if (!Number.isSafeInteger(number) || number < 0) { + throw new ReminderStoreError(code, 'reminder time must be a non-negative integer') + } + return number +} + +function ownerId(value) { + const normalized = clean(value, MAX_REMINDER_OWNER_ID_CHARS) + if (!normalized) { + throw new ReminderStoreError('invalid_owner', 'reminder owner is required') + } + if (String(value ?? '').trim().length > MAX_REMINDER_OWNER_ID_CHARS) { + throw new ReminderStoreError('invalid_owner', 'reminder owner is too long') + } + return normalized +} + +function reminderId(value) { + const source = String(value ?? '').trim() + const normalized = clean(source, 120) + if (!normalized || [...source].length > 120) { + throw new ReminderStoreError('invalid_id', 'reminder id is invalid') + } + return normalized +} + +function reminderText(value) { + const source = String(value ?? '').trim() + const normalized = clean(source, MAX_REMINDER_TEXT_CHARS) + if (!normalized) { + throw new ReminderStoreError('invalid_text', 'reminder text is required') + } + if ([...source].length > MAX_REMINDER_TEXT_CHARS) { + throw new ReminderStoreError('invalid_text', 'reminder text is too long') + } + return normalized +} + +function reminderKind(value) { + const normalized = String(value || ReminderKind.REMINDER).trim().toLowerCase() + if (!KINDS.has(normalized)) { + throw new ReminderStoreError('invalid_kind', `unsupported reminder kind: ${normalized}`) + } + return normalized +} + +function recurrence(value) { + const normalized = String(value || ReminderRecurrence.ONCE).trim().toLowerCase() + if (!RECURRENCES.has(normalized)) { + throw new ReminderStoreError( + 'invalid_recurrence', + `unsupported reminder recurrence: ${normalized}`, + ) + } + return normalized +} + +function timezone(value) { + const source = String(value || 'UTC').trim() + if (!source || source.length > MAX_REMINDER_TIMEZONE_CHARS) { + throw new ReminderStoreError('invalid_timezone', 'reminder timezone is invalid') + } + try { + new Intl.DateTimeFormat('en-US', { timeZone: source }).format(0) + } catch { + throw new ReminderStoreError('invalid_timezone', `unknown reminder timezone: ${source}`) + } + return source +} + +function cloneReminder(reminder) { + return reminder ? { ...reminder } : null +} + +export class ReminderStoreError extends Error { + constructor(code, message) { + super(message) + this.name = 'ReminderStoreError' + this.code = code + } +} + +/** + * Durable, owner-scoped storage for frontend reminders. + * + * This store deliberately does not know about the Realtime provider, the + * TaskManager, or any backend Agent. A later runtime can use claimDue() and + * complete()/fail() to connect the store to announcements or WorkSubmissionPort + * without putting scheduling policy into persistence. + */ +export class FrontendReminderStore { + constructor({ + filePath = null, + maxOwners = 1000, + maxRemindersPerOwner = MAX_REMINDERS_PER_OWNER, + ownerTtlMs = 0, + now = () => Date.now(), + idFactory = () => `rem_${randomUUID()}`, + onWarning = warning => console.warn(warning.message), + } = {}) { + this.filePath = filePath + this.maxOwners = Math.max(1, Number(maxOwners) || 1000) + this.maxRemindersPerOwner = Math.max( + 1, + Number(maxRemindersPerOwner) || MAX_REMINDERS_PER_OWNER, + ) + this.ownerTtlMs = Math.max(0, Number(ownerTtlMs) || 0) + this.now = now + this.idFactory = idFactory + this.onWarning = onWarning + this.reminders = new Map() + this.ownerAccess = new Map() + this.warning = null + this.persistenceDisabled = false + this.loadedMtimeMs = 0 + this.loadedContentHash = '' + if (filePath) this.load() + } + + fileMtimeMs() { + try { + return statSync(this.filePath).mtimeMs + } catch (error) { + if (error.code === 'ENOENT') return 0 + throw error + } + } + + fileContentHash() { + try { + return createHash('sha1').update(readFileSync(this.filePath)).digest('hex') + } catch { + return '' + } + } + + refreshIfChanged() { + if (!this.filePath || this.persistenceDisabled) return + const mtimeMs = this.fileMtimeMs() + if (mtimeMs === this.loadedMtimeMs + && this.fileContentHash() === this.loadedContentHash) return + this.reminders = new Map() + this.ownerAccess = new Map() + this.load() + } + + writeTransaction(action) { + return withFileTransaction(this.filePath, () => { + // Reload after acquiring the lock. This prevents a second Gateway from + // having its write silently overwritten by this instance's stale cache. + if (this.filePath && !this.persistenceDisabled) { + this.reminders = new Map() + this.ownerAccess = new Map() + this.load() + } + return action() + }) + } + + setWarning(message, quarantinePath = null) { + this.warning = { message, quarantinePath, at: this.now() } + try { + this.onWarning?.(this.warning) + } catch { + // Diagnostics must never prevent the voice service from starting. + } + } + + disablePersistence(message) { + this.persistenceDisabled = true + this.setWarning(`${message};已禁用提醒持久化,服务将继续运行。`) + } + + quarantine(reason) { + const quarantinePath = `${this.filePath}.corrupt-${this.now()}` + try { + renameSync(this.filePath, quarantinePath) + this.setWarning( + `${reason};原文件已隔离为 ${quarantinePath},服务将使用空提醒继续运行。`, + quarantinePath, + ) + } catch (error) { + this.persistenceDisabled = true + this.setWarning( + `${reason};隔离失败(${error.message}),已禁用提醒持久化以保护原文件。`, + ) + } + } + + normalizePersisted(raw, storedOwnerId) { + if (!plainObject(raw)) return null + let id + let text + let kind + let repeat + let zone + let nextFireAt + try { + id = reminderId(raw.id) + text = reminderText(raw.text) + kind = reminderKind(raw.kind) + repeat = recurrence(raw.recurrence) + zone = timezone(raw.timezone) + nextFireAt = timestamp(raw.nextFireAt) + } catch { + return null + } + const status = STATUSES.has(raw.status) + ? raw.status + : ReminderStatus.ACTIVE + const createdAt = Number.isSafeInteger(raw.createdAt) && raw.createdAt >= 0 + ? raw.createdAt + : this.now() + const updatedAt = Number.isSafeInteger(raw.updatedAt) && raw.updatedAt >= 0 + ? raw.updatedAt + : createdAt + const lastFiredAt = Number.isSafeInteger(raw.lastFiredAt) && raw.lastFiredAt >= 0 + ? raw.lastFiredAt + : null + const fireCount = Number.isSafeInteger(raw.fireCount) && raw.fireCount >= 0 + ? raw.fireCount + : 0 + const lastError = raw.lastError + ? clean(raw.lastError, 500) + : null + let safeOwnerId + try { + safeOwnerId = ownerId(storedOwnerId) + } catch { + return null + } + return { + id, + ownerId: safeOwnerId, + text, + kind, + timezone: zone, + recurrence: repeat, + nextFireAt, + // A process can die after claiming but before delivering. Re-opening a + // firing reminder as active makes it retryable instead of losing it. + status: status === ReminderStatus.FIRING + ? ReminderStatus.ACTIVE + : status, + createdAt, + updatedAt, + lastFiredAt, + fireCount, + lastError, + } + } + + load() { + if (!this.filePath || this.persistenceDisabled) return + let raw + try { + raw = readFileSync(this.filePath, 'utf8') + } catch (error) { + if (error.code === 'ENOENT') { + this.loadedMtimeMs = 0 + this.loadedContentHash = '' + return + } + this.disablePersistence(`无法读取提醒文件:${error.message}`) + return + } + this.loadedMtimeMs = this.fileMtimeMs() + this.loadedContentHash = createHash('sha1').update(raw).digest('hex') + let parsed + try { + parsed = JSON.parse(raw) + } catch (error) { + this.quarantine(`提醒文件不是有效的 JSON:${error.message}`) + return + } + if ( + !plainObject(parsed) + || parsed.version !== REMINDER_STORE_VERSION + || !plainObject(parsed.owners) + || !plainObject(parsed.ownerAccess) + ) { + this.quarantine('提醒文件格式或版本无效') + return + } + Object.entries(parsed.owners).slice(0, this.maxOwners).forEach(([storedOwnerId, entries]) => { + if (!plainObject(entries)) return + const ownerReminders = new Map() + Object.entries(entries) + .slice(0, this.maxRemindersPerOwner) + .forEach(([storedId, rawReminder]) => { + const reminder = this.normalizePersisted( + { ...rawReminder, id: rawReminder?.id || storedId }, + storedOwnerId, + ) + if (!reminder || ownerReminders.has(reminder.id)) return + ownerReminders.set(reminder.id, reminder) + }) + if (!ownerReminders.size) return + let safeOwnerId + try { + safeOwnerId = ownerId(storedOwnerId) + } catch { + return + } + this.reminders.set(safeOwnerId, ownerReminders) + const access = Number(parsed.ownerAccess[storedOwnerId]) + this.ownerAccess.set( + safeOwnerId, + Number.isSafeInteger(access) && access >= 0 ? access : this.now(), + ) + }) + this.pruneOwners({ persist: false }) + } + + persist() { + if (!this.filePath) return true + if (this.persistenceDisabled) return false + try { + const owners = Object.create(null) + this.reminders.forEach((entries, safeOwnerId) => { + owners[safeOwnerId] = Object.create(null) + entries.forEach((reminder, id) => { + owners[safeOwnerId][id] = reminder + }) + }) + mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 }) + const temporary = `${this.filePath}.${process.pid}.tmp` + const body = `${JSON.stringify({ + version: REMINDER_STORE_VERSION, + owners, + ownerAccess: Object.fromEntries(this.ownerAccess), + }, null, 2)}\n` + writeFileSync(temporary, body, { encoding: 'utf8', mode: 0o600 }) + replaceFileSync(temporary, this.filePath) + chmodSync(this.filePath, 0o600) + this.loadedMtimeMs = this.fileMtimeMs() + this.loadedContentHash = createHash('sha1').update(body).digest('hex') + return true + } catch (error) { + this.disablePersistence(`无法保存提醒文件:${error.message}`) + return false + } + } + + pruneOwners({ persist = true } = {}) { + const now = this.now() + let changed = false + this.ownerAccess.forEach((lastAccessedAt, safeOwnerId) => { + if (!(this.ownerTtlMs > 0) || now - lastAccessedAt < this.ownerTtlMs) return + this.ownerAccess.delete(safeOwnerId) + changed = this.reminders.delete(safeOwnerId) || changed + }) + while (this.reminders.size > this.maxOwners) { + const oldest = [...this.reminders.keys()] + .sort((left, right) => ( + Number(this.ownerAccess.get(left) || 0) + - Number(this.ownerAccess.get(right) || 0) + ))[0] + if (!oldest) break + this.reminders.delete(oldest) + this.ownerAccess.delete(oldest) + changed = true + } + if (changed && persist) this.persist() + return changed + } + + touch(safeOwnerId) { + if (this.reminders.has(safeOwnerId)) this.ownerAccess.set(safeOwnerId, this.now()) + } + + health() { + let reminderCount = 0 + this.reminders.forEach(entries => { reminderCount += entries.size }) + return { + ok: !this.warning, + persistenceEnabled: Boolean(this.filePath) && !this.persistenceDisabled, + warning: this.warning, + owners: this.reminders.size, + reminders: reminderCount, + } + } + + create(safeOwnerId, { + text, + executeAt, + execute_at: legacyExecuteAt, + timezone: zone = 'UTC', + recurrence: repeat = ReminderRecurrence.ONCE, + kind = ReminderKind.REMINDER, + } = {}) { + return this.writeTransaction(() => { + const owner = ownerId(safeOwnerId) + const reminderTextValue = reminderText(text) + const nextFireAt = timestamp(executeAt ?? legacyExecuteAt) + const reminderKindValue = reminderKind(kind) + const recurrenceValue = recurrence(repeat) + const timezoneValue = timezone(zone) + this.pruneOwners({ persist: false }) + const entries = this.reminders.get(owner) || new Map() + if (entries.size >= this.maxRemindersPerOwner) { + throw new ReminderStoreError( + 'owner_limit', + `owner has reached the ${this.maxRemindersPerOwner} reminder limit`, + ) + } + const now = this.now() + let id + for (let attempts = 0; attempts < 10; attempts += 1) { + id = reminderId(this.idFactory()) + if (!entries.has(id)) break + } + if (!id || entries.has(id)) { + throw new ReminderStoreError('id_exhausted', 'unable to allocate a reminder id') + } + const reminder = { + id, + ownerId: owner, + text: reminderTextValue, + kind: reminderKindValue, + timezone: timezoneValue, + recurrence: recurrenceValue, + nextFireAt, + status: ReminderStatus.ACTIVE, + createdAt: now, + updatedAt: now, + lastFiredAt: null, + fireCount: 0, + lastError: null, + } + const previousEntries = this.reminders.get(owner) + const previousAccess = this.ownerAccess.get(owner) + entries.set(id, reminder) + this.reminders.set(owner, entries) + this.ownerAccess.set(owner, now) + this.pruneOwners({ persist: false }) + if (!this.persist()) { + if (previousEntries) this.reminders.set(owner, previousEntries) + else this.reminders.delete(owner) + if (previousAccess === undefined) this.ownerAccess.delete(owner) + else this.ownerAccess.set(owner, previousAccess) + throw new ReminderStoreError( + 'persistence_unavailable', + 'reminder persistence is unavailable', + ) + } + return cloneReminder(reminder) + }) + } + + get(safeOwnerId, id) { + this.refreshIfChanged() + const owner = ownerId(safeOwnerId) + this.pruneOwners({ persist: false }) + this.touch(owner) + return cloneReminder(this.reminders.get(owner)?.get(String(id))) + } + + list(safeOwnerId, { statuses = null } = {}) { + this.refreshIfChanged() + const owner = ownerId(safeOwnerId) + this.pruneOwners({ persist: false }) + this.touch(owner) + const allowed = statuses == null + ? null + : new Set(statuses.map(value => String(value))) + return [...(this.reminders.get(owner)?.values() || [])] + .filter(reminder => !allowed || allowed.has(reminder.status)) + .sort((left, right) => ( + left.nextFireAt - right.nextFireAt + || left.createdAt - right.createdAt + || left.id.localeCompare(right.id) + )) + .map(cloneReminder) + } + + due({ at = this.now(), ownerId: requestedOwner = null, limit = Infinity } = {}) { + const timestampNow = timestamp(at, 'invalid_now') + const owner = requestedOwner == null ? null : ownerId(requestedOwner) + const max = Number.isFinite(Number(limit)) + ? Math.max(0, Math.floor(Number(limit))) + : Infinity + this.refreshIfChanged() + this.pruneOwners({ persist: false }) + const result = [] + const owners = owner + ? [[owner, this.reminders.get(owner)]] + : [...this.reminders.entries()] + for (const [, entries] of owners) { + for (const reminder of entries?.values() || []) { + if ( + reminder.status === ReminderStatus.ACTIVE + && reminder.nextFireAt <= timestampNow + ) { + result.push(cloneReminder(reminder)) + } + } + } + return result + .sort((left, right) => left.nextFireAt - right.nextFireAt || left.id.localeCompare(right.id)) + .slice(0, max) + } + + claimDue({ at = this.now(), ownerId: requestedOwner = null, limit = Infinity } = {}) { + const timestampNow = timestamp(at, 'invalid_now') + const owner = requestedOwner == null ? null : ownerId(requestedOwner) + const max = Number.isFinite(Number(limit)) + ? Math.max(0, Math.floor(Number(limit))) + : Infinity + return this.writeTransaction(() => { + this.pruneOwners({ persist: false }) + const owners = owner + ? [[owner, this.reminders.get(owner)]] + : [...this.reminders.entries()] + const due = [] + for (const [, entries] of owners) { + for (const reminder of entries?.values() || []) { + if ( + reminder.status === ReminderStatus.ACTIVE + && reminder.nextFireAt <= timestampNow + ) due.push(reminder) + } + } + due.sort((left, right) => left.nextFireAt - right.nextFireAt || left.id.localeCompare(right.id)) + const claimed = due.slice(0, max) + const previous = claimed.map(reminder => ({ ...reminder })) + claimed.forEach(reminder => { + reminder.status = ReminderStatus.FIRING + reminder.lastFiredAt = timestampNow + reminder.fireCount += 1 + reminder.updatedAt = timestampNow + }) + if (claimed.length && !this.persist()) { + claimed.forEach((reminder, index) => Object.assign(reminder, previous[index])) + throw new ReminderStoreError( + 'persistence_unavailable', + 'reminder persistence is unavailable', + ) + } + return claimed.map(cloneReminder) + }) + } + + cancel(safeOwnerId, id) { + return this.writeTransaction(() => { + const owner = ownerId(safeOwnerId) + const entries = this.reminders.get(owner) + const reminder = entries?.get(String(id)) + if (!reminder) return null + if (TERMINAL_STATUSES.has(reminder.status)) return cloneReminder(reminder) + const previous = { ...reminder } + reminder.status = ReminderStatus.CANCELLED + reminder.updatedAt = this.now() + reminder.lastError = null + if (!this.persist()) { + Object.assign(reminder, previous) + throw new ReminderStoreError( + 'persistence_unavailable', + 'reminder persistence is unavailable', + ) + } + return cloneReminder(reminder) + }) + } + + complete(safeOwnerId, id, { nextFireAt = null, at = this.now() } = {}) { + return this.writeTransaction(() => { + const owner = ownerId(safeOwnerId) + const reminder = this.reminders.get(owner)?.get(String(id)) + if (!reminder) return null + if (reminder.status !== ReminderStatus.FIRING) { + throw new ReminderStoreError( + 'invalid_transition', + `cannot complete reminder in ${reminder.status} state`, + ) + } + const timestampNow = timestamp(at, 'invalid_now') + const next = nextFireAt == null ? null : timestamp(nextFireAt) + if (reminder.recurrence !== ReminderRecurrence.ONCE && next == null) { + throw new ReminderStoreError( + 'next_fire_required', + 'recurring reminders require the next fire time', + ) + } + if (reminder.recurrence === ReminderRecurrence.ONCE && next != null) { + throw new ReminderStoreError( + 'unexpected_next_fire', + 'one-time reminders cannot be rescheduled', + ) + } + const previous = { ...reminder } + reminder.status = next == null ? ReminderStatus.COMPLETED : ReminderStatus.ACTIVE + reminder.nextFireAt = next == null ? reminder.nextFireAt : next + reminder.updatedAt = timestampNow + reminder.lastError = null + if (!this.persist()) { + Object.assign(reminder, previous) + throw new ReminderStoreError( + 'persistence_unavailable', + 'reminder persistence is unavailable', + ) + } + return cloneReminder(reminder) + }) + } + + fail(safeOwnerId, id, error, { at = this.now() } = {}) { + return this.writeTransaction(() => { + const owner = ownerId(safeOwnerId) + const reminder = this.reminders.get(owner)?.get(String(id)) + if (!reminder) return null + if (reminder.status !== ReminderStatus.FIRING) { + throw new ReminderStoreError( + 'invalid_transition', + `cannot fail reminder in ${reminder.status} state`, + ) + } + const previous = { ...reminder } + reminder.status = ReminderStatus.FAILED + reminder.updatedAt = timestamp(at, 'invalid_now') + reminder.lastError = clean(error, 500) || 'reminder failed' + if (!this.persist()) { + Object.assign(reminder, previous) + throw new ReminderStoreError( + 'persistence_unavailable', + 'reminder persistence is unavailable', + ) + } + return cloneReminder(reminder) + }) + } +} From e8e45a5b920a6519284aa965f2d279a3b59c0ab2 Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:12:11 +0800 Subject: [PATCH 2/2] test(frontend): cover reminder store persistence and lifecycle This test suite verifies the functionality of the FrontendReminderStore, including creating, validating, persisting, and managing reminders. It covers various scenarios such as owner limits, data persistence, error handling, and concurrent reminder creation. --- server/test/frontend-reminders.test.mjs | 261 ++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 server/test/frontend-reminders.test.mjs diff --git a/server/test/frontend-reminders.test.mjs b/server/test/frontend-reminders.test.mjs new file mode 100644 index 00000000..de36709b --- /dev/null +++ b/server/test/frontend-reminders.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + FrontendReminderStore, + MAX_REMINDER_TEXT_CHARS, + ReminderKind, + ReminderStatus, + ReminderStoreError, +} from '../src/conversation/frontend-reminders.mjs' + +function tempFile(prefix = 'qwen-audio-agent-reminders-') { + const directory = mkdtempSync(join(tmpdir(), prefix)) + return { + directory, + filePath: join(directory, 'frontend-reminders.json'), + } +} + +test('creates reminders and isolates owners', () => { + let now = 1_000 + const store = new FrontendReminderStore({ now: () => now }) + const reminder = store.create('owner-a', { + text: ' 明天 开会 ', + executeAt: 2_000, + timezone: 'Asia/Shanghai', + recurrence: 'daily', + }) + + assert.equal(reminder.ownerId, 'owner-a') + assert.equal(reminder.text, '明天 开会') + assert.equal(reminder.kind, ReminderKind.REMINDER) + assert.equal(reminder.status, ReminderStatus.ACTIVE) + assert.equal(reminder.nextFireAt, 2_000) + assert.equal(store.get('owner-a', reminder.id).id, reminder.id) + assert.equal(store.get('owner-b', reminder.id), null) + assert.equal(store.list('owner-b').length, 0) + + now = 1_100 + assert.equal(store.list('owner-a')[0].updatedAt, 1_000) +}) + +test('validates reminder fields and enforces the owner limit', () => { + const store = new FrontendReminderStore({ maxRemindersPerOwner: 1 }) + assert.throws( + () => store.create('', { text: 'x', executeAt: 1 }), + error => error instanceof ReminderStoreError && error.code === 'invalid_owner', + ) + assert.throws( + () => store.create('owner', { text: ' ', executeAt: 1 }), + error => error.code === 'invalid_text', + ) + assert.throws( + () => store.create('owner', { + text: 'x'.repeat(MAX_REMINDER_TEXT_CHARS + 1), + executeAt: 1, + }), + error => error.code === 'invalid_text', + ) + assert.throws( + () => store.create('owner', { text: 'x', executeAt: -1 }), + error => error.code === 'invalid_execute_at', + ) + assert.throws( + () => store.create('owner', { text: 'x', executeAt: 1, kind: 'other' }), + error => error.code === 'invalid_kind', + ) + assert.throws( + () => store.create('owner', { text: 'x', executeAt: 1, recurrence: 'hourly' }), + error => error.code === 'invalid_recurrence', + ) + assert.throws( + () => store.create('owner', { text: 'x', executeAt: 1, timezone: 'Mars/Olympus' }), + error => error.code === 'invalid_timezone', + ) + + store.create('owner', { text: 'first', executeAt: 1 }) + assert.throws( + () => store.create('owner', { text: 'second', executeAt: 2 }), + error => error.code === 'owner_limit', + ) +}) + +test('persists reminders atomically and restores them with private permissions', t => { + const { directory, filePath } = tempFile() + t.after(() => rmSync(directory, { recursive: true, force: true })) + const first = new FrontendReminderStore({ filePath, now: () => 100 }) + const created = first.create('owner-a', { + text: '提交报告', + executeAt: 200, + kind: ReminderKind.TASK, + recurrence: 'weekly', + timezone: 'Asia/Shanghai', + }) + const restored = new FrontendReminderStore({ filePath, now: () => 300 }) + + assert.deepEqual(restored.get('owner-a', created.id), created) + assert.equal(JSON.parse(readFileSync(filePath, 'utf8')).version, 1) + if (process.platform !== 'win32') { + assert.equal(statSync(filePath).mode & 0o777, 0o600) + } +}) + +test('quarantines corrupt data and remains usable after the warning', t => { + const { directory, filePath } = tempFile('qwen-audio-agent-reminders-corrupt-') + t.after(() => rmSync(directory, { recursive: true, force: true })) + writeFileSync(filePath, '{not-json') + const warnings = [] + const store = new FrontendReminderStore({ + filePath, + now: () => 12345, + onWarning: warning => warnings.push(warning), + }) + + assert.deepEqual(store.list('owner-a'), []) + assert.equal(warnings.length, 1) + assert.equal(existsSync(`${filePath}.corrupt-12345`), true) + const reminder = store.create('owner-a', { text: '恢复后可写', executeAt: 2 }) + assert.equal(store.get('owner-a', reminder.id).text, '恢复后可写') + assert.equal(store.health().ok, false) + assert.equal(store.health().persistenceEnabled, true) +}) + +test('rolls back an in-memory create when persistence is unavailable', t => { + const { directory } = tempFile('qwen-audio-agent-reminders-write-failure-') + t.after(() => rmSync(directory, { recursive: true, force: true })) + const warnings = [] + const store = new FrontendReminderStore({ + filePath: directory, + onWarning: warning => warnings.push(warning), + }) + + assert.throws( + () => store.create('owner-a', { text: '不能写入', executeAt: 2 }), + error => error.code === 'persistence_unavailable', + ) + assert.deepEqual(store.list('owner-a'), []) + assert.equal(store.health().persistenceEnabled, false) + assert.equal(warnings.length > 0, true) +}) + +test('claims due reminders once and completes one-time or recurring lifecycles', () => { + const ids = ['rem_one', 'rem_recurring', 'rem_future'] + const store = new FrontendReminderStore({ + now: () => 1_000, + idFactory: () => ids.shift(), + }) + const oneTime = store.create('owner-a', { text: '一次', executeAt: 900 }) + const recurring = store.create('owner-a', { + text: '每天', + executeAt: 900, + recurrence: 'daily', + }) + const future = store.create('owner-a', { text: '未来', executeAt: 2_000 }) + + assert.deepEqual(store.due({ at: 1_000 }).map(item => item.id), [oneTime.id, recurring.id]) + const claimed = store.claimDue({ at: 1_000 }) + assert.deepEqual(claimed.map(item => item.id), [oneTime.id, recurring.id]) + assert.equal(store.claimDue({ at: 1_000 }).length, 0) + assert.equal(store.get('owner-a', oneTime.id).fireCount, 1) + assert.equal(store.get('owner-a', future.id).status, ReminderStatus.ACTIVE) + + assert.equal(store.complete('owner-a', oneTime.id, { at: 1_100 }).status, ReminderStatus.COMPLETED) + const next = store.complete('owner-a', recurring.id, { + at: 1_100, + nextFireAt: 2_000, + }) + assert.equal(next.status, ReminderStatus.ACTIVE) + assert.equal(next.nextFireAt, 2_000) +}) + +test('requires explicit next fire times for recurring reminders', () => { + const store = new FrontendReminderStore({ now: () => 1_000 }) + const reminder = store.create('owner-a', { + text: '重复', + executeAt: 900, + recurrence: 'daily', + }) + store.claimDue({ at: 1_000 }) + + assert.throws( + () => store.complete('owner-a', reminder.id, { at: 1_100 }), + error => error.code === 'next_fire_required', + ) + assert.equal(store.get('owner-a', reminder.id).status, ReminderStatus.FIRING) +}) + +test('cancels only the owner-owned reminder and is idempotent', () => { + const store = new FrontendReminderStore({ now: () => 1_000 }) + const reminder = store.create('owner-a', { text: '取消我', executeAt: 900 }) + + assert.equal(store.cancel('owner-b', reminder.id), null) + assert.equal(store.cancel('owner-a', reminder.id).status, ReminderStatus.CANCELLED) + assert.equal(store.cancel('owner-a', reminder.id).status, ReminderStatus.CANCELLED) + assert.equal(store.claimDue({ at: 2_000 }).length, 0) +}) + +test('reopens an interrupted firing reminder after restart', t => { + const { directory, filePath } = tempFile('qwen-audio-agent-reminders-recovery-') + t.after(() => rmSync(directory, { recursive: true, force: true })) + const first = new FrontendReminderStore({ filePath, now: () => 1_000 }) + const reminder = first.create('owner-a', { text: '恢复', executeAt: 900 }) + first.claimDue({ at: 1_000 }) + + const restored = new FrontendReminderStore({ filePath, now: () => 2_000 }) + assert.equal(restored.get('owner-a', reminder.id).status, ReminderStatus.ACTIVE) + assert.equal(restored.due({ at: 2_000 }).length, 1) +}) + +test('merges writes from two Gateway instances sharing one file', t => { + const { directory, filePath } = tempFile('qwen-audio-agent-reminders-shared-') + t.after(() => rmSync(directory, { recursive: true, force: true })) + const first = new FrontendReminderStore({ filePath, now: () => 1_000 }) + const second = new FrontendReminderStore({ filePath, now: () => 1_100 }) + first.create('owner-a', { text: '来自 CLI', executeAt: 2_000 }) + second.create('owner-a', { text: '来自桌面', executeAt: 3_000 }) + + assert.deepEqual(first.list('owner-a').map(item => item.text), ['来自 CLI', '来自桌面']) +}) + +test('serializes concurrent reminder creation from independent processes', async t => { + const { directory, filePath } = tempFile('qwen-audio-agent-reminders-processes-') + t.after(() => rmSync(directory, { recursive: true, force: true })) + const moduleUrl = new URL('../src/conversation/frontend-reminders.mjs', import.meta.url).href + const texts = Array.from({ length: 8 }, (_, index) => `process-${index}`) + const script = ` + import { FrontendReminderStore } from ${JSON.stringify(moduleUrl)} + const store = new FrontendReminderStore({ filePath: process.argv[1] }) + store.create('owner-a', { text: process.argv[2], executeAt: 1000 }) + ` + await Promise.all(texts.map(text => new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [ + '--input-type=module', + '-e', + script, + filePath, + text, + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', chunk => { stderr += chunk }) + child.once('error', rejectPromise) + child.once('exit', code => { + if (code === 0) resolvePromise() + else rejectPromise(new Error(`child exited ${code}: ${stderr}`)) + }) + }))) + + const store = new FrontendReminderStore({ filePath }) + assert.deepEqual(store.list('owner-a').map(item => item.text).sort(), texts.sort()) +})