diff --git a/apps/dashboard/src/features/accounts/accounts-page.tsx b/apps/dashboard/src/features/accounts/accounts-page.tsx index 93b633f..3e4e66f 100644 --- a/apps/dashboard/src/features/accounts/accounts-page.tsx +++ b/apps/dashboard/src/features/accounts/accounts-page.tsx @@ -98,6 +98,7 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u function AccountRow({ account }: { account: RedditAccountSummary }) { const removeMutation = useRemoveRedditAccountMutation(); const [confirmRemove, setConfirmRemove] = useState(false); + const isRemoved = account.status === 'removed'; useEffect(() => { if (!confirmRemove) return; @@ -105,24 +106,31 @@ function AccountRow({ account }: { account: RedditAccountSummary }) { return () => globalThis.clearTimeout(timer); }, [confirmRemove]); + const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row'; return ( -
+
- {account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'} + + {isRemoved + ? 'Removed — its conversations and media are kept, read-only' + : account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'} +
- {establishmentPill(account.establishment)} - - {account.isRunning ? 'Running' : 'Paused'} + {isRemoved ? null : establishmentPill(account.establishment)} + + {isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
- {confirmRemove ? ( + {isRemoved ? ( + Import a session for this account to bring it back. + ) : confirmRemove ? ( <> - Removes u/{account.redditUsername}, its stored session and all of its messages, - conversations and activity. This cannot be undone. + Stops u/{account.redditUsername} and deletes its stored session. Conversations, + messages and media it collected are kept, but become read-only.
@@ -203,8 +211,10 @@ export function AccountsPage() { ? session.username ?? null : null; const accounts = accountsQuery.data ?? []; - const pausedCount = accounts.filter((account) => !account.isRunning).length; - const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld); + // Removed accounts are not paused work — nothing about them needs attention. + const liveAccounts = accounts.filter((account) => account.status !== 'removed'); + const pausedCount = liveAccounts.filter((account) => !account.isRunning).length; + const heldAccounts = liveAccounts.filter((account) => account.establishment?.sendingHeld); return (
diff --git a/apps/dashboard/src/features/accounts/accounts.css b/apps/dashboard/src/features/accounts/accounts.css index e8d5dab..83d004f 100644 --- a/apps/dashboard/src/features/accounts/accounts.css +++ b/apps/dashboard/src/features/accounts/accounts.css @@ -5,6 +5,7 @@ .account-list { display: grid; gap: .6rem; } .account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; } .account-row.is-active { border-color: var(--accent); } +.account-row.is-removed { opacity: .72; } .account-row > div { display: grid; gap: .2rem; min-width: 0; } .account-row span { color: var(--muted); font-size: .82rem; } .account-row .pill { font-size: .7rem; } diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 82a40e0..a9580d5 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -536,7 +536,8 @@ function useAutoMarkRead(conversationId: string, conversation: ConversationDetai const mutate = markRead.mutate; useEffect(() => { - if (!conversation || newestTimestamp <= lastReadMessageTs) return; + if (!conversation || conversation.readOnly) return; + if (newestTimestamp <= lastReadMessageTs) return; const key = `${conversationId}:${newestTimestamp}`; if (markedRef.current === key) return; const timer = window.setTimeout(() => { @@ -585,11 +586,13 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
- + {conversation.readOnly ? null : ( + + )}
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
) : null} - + {conversation.readOnly ? ( +
+

Read-only

+

+ {conversation.owner?.redditUsername + ? `u/${conversation.owner.redditUsername} was removed from the app.` + : 'The account that owned this conversation was removed from the app.'} + {' '}Its history is kept here, but nothing can be sent or synced from it. +

+
+ ) : ( + + )} ); } diff --git a/apps/dashboard/src/features/conversations/conversations-page.tsx b/apps/dashboard/src/features/conversations/conversations-page.tsx index c8b5ff4..d787a97 100644 --- a/apps/dashboard/src/features/conversations/conversations-page.tsx +++ b/apps/dashboard/src/features/conversations/conversations-page.tsx @@ -127,6 +127,7 @@ function ConversationRow({ if (isSelected) itemClasses.push('is-selected'); if (unread) itemClasses.push('is-unread'); if (isChecked) itemClasses.push('is-checked'); + if (conversation.readOnly) itemClasses.push('is-read-only'); const descriptors = [ redditUsernameLabel(conversation.username), @@ -134,6 +135,7 @@ function ConversationRow({ relativeTime, ...(conversation.sellerDetected ? ['Seller'] : []), ...(conversation.hasReceivedImages ? ['Image received'] : []), + ...(conversation.readOnly ? ['Removed account, read-only'] : []), ]; return ( @@ -179,6 +181,11 @@ function ConversationRow({ ))} {showOwner ? : null} + {conversation.readOnly ? ( + + Read-only + + ) : null} {conversation.sellerDetected ? Seller : null} {conversation.hasReceivedImages ? Image received : null} diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css index f8a41fd..cf1b5e3 100644 --- a/apps/dashboard/src/features/conversations/conversations.css +++ b/apps/dashboard/src/features/conversations/conversations.css @@ -96,6 +96,11 @@ .conversation-shortcut-hint { margin-left: auto; opacity: .8; } .conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; } .conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; } +.conversation-read-only-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: var(--muted) !important; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .68rem !important; font-weight: 800; } +.conversation-list-item.is-read-only .conversation-list-snippet { opacity: .8; } +.conversation-read-only { padding: 1rem; margin: 0 1rem 1rem; color: var(--muted); background: var(--surface-raised); border: 1px dashed var(--border); border-radius: .65rem; } +.conversation-read-only h3 { margin: 0 0 .3rem; color: var(--text); font-size: .9rem; } +.conversation-read-only p { margin: 0; font-size: .82rem; } .conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; } .conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; } /* Column layout: history takes the slack, the composer stays reachable. */ diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index 688ab5b..1c95b45 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -71,6 +71,11 @@ export const ActiveChatSchema = z.strictObject({ automationPolicyMode: ConversationPolicyModeSchema.optional(), /** Why automation handed over, when it has. */ automationHandoffReason: z.string().nullable().optional(), + /** + * The owning account was removed. Its history is kept and readable, but the + * app can no longer send into the conversation or sync it. + */ + readOnly: z.boolean().optional(), }); export const ChatsResponseSchema = z.array(ActiveChatSchema); export const ConversationsResponseSchema = ChatsResponseSchema; diff --git a/packages/api-contracts/src/session.ts b/packages/api-contracts/src/session.ts index e54c0aa..7f28692 100644 --- a/packages/api-contracts/src/session.ts +++ b/packages/api-contracts/src/session.ts @@ -31,10 +31,12 @@ export const RedditAccountSummarySchema = z.strictObject({ redditUsername: z.string().min(1), matrixUserId: z.string().min(1), isRunning: z.boolean(), - status: z.union([z.literal('authenticated'), z.literal('login-required')]), + status: z.union([z.literal('authenticated'), z.literal('login-required'), z.literal('removed')]), createdAt: z.number(), updatedAt: z.number(), lastImportedAt: z.number(), + /** Set when the operator retired the account; its content is kept read-only. */ + removedAt: z.number().nullable().optional(), /** Absent on responses from a build that predates establishment tracking. */ establishment: AccountEstablishmentSummarySchema.nullable().optional(), }); diff --git a/src/database/account-repositories.ts b/src/database/account-repositories.ts index bd190e1..b070d8a 100644 --- a/src/database/account-repositories.ts +++ b/src/database/account-repositories.ts @@ -1,6 +1,12 @@ import type Database from 'better-sqlite3'; -export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned'; +export type RedditAccountStatus = + | 'verified' + | 'login-required' + | 'disabled' + | 'legacy-unassigned' + /** Retired by the operator: content is kept and readable, credentials are gone. */ + | 'removed'; export interface RedditAccountPublicRecord { accountId: string; @@ -12,6 +18,7 @@ export interface RedditAccountPublicRecord { updatedAt: number; lastVerifiedAt: number | null; lastImportedAt: number | null; + removedAt: number | null; } /** Private credential-bearing record. Never return this from a web API. */ @@ -41,29 +48,16 @@ type AccountRow = { updated_at: number; last_verified_at: number | null; last_imported_at: number | null; + removed_at: number | null; }; -/** - * Tables whose foreign key to `reddit_accounts` predates cascading deletes, - * ordered children first so each delete leaves no dangling composite reference. - */ -const ACCOUNT_OWNED_TABLES = [ - 'conversation_outcomes', - 'successful_messages', - 'delivery_outbox', - 'sent_messages', - 'conversation_states', - 'activity_log', - 'processed_posts', -] as const; - export class RedditAccountsRepository { constructor(private readonly db: Database.Database) {} listPublic(): RedditAccountPublicRecord[] { const rows = this.db.prepare(` SELECT account_id, reddit_username, matrix_user_id, status, is_active, - created_at, updated_at, last_verified_at, last_imported_at + created_at, updated_at, last_verified_at, last_imported_at, removed_at FROM reddit_accounts ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id `).all() as AccountRow[]; @@ -80,7 +74,7 @@ export class RedditAccountsRepository { getActive(): RedditAccountPublicRecord[] { const rows = this.db.prepare(` SELECT account_id, reddit_username, matrix_user_id, status, is_active, - created_at, updated_at, last_verified_at, last_imported_at + created_at, updated_at, last_verified_at, last_imported_at, removed_at FROM reddit_accounts WHERE is_active = 1 AND status != 'legacy-unassigned' ORDER BY reddit_username COLLATE NOCASE, account_id @@ -113,7 +107,8 @@ export class RedditAccountsRepository { matrix_credentials_json = excluded.matrix_credentials_json, updated_at = excluded.updated_at, last_verified_at = excluded.last_verified_at, - last_imported_at = excluded.last_imported_at + last_imported_at = excluded.last_imported_at, + removed_at = NULL `).run({ ...input, now }); return publicFromPrivate(this.getRequired(input.accountId)); } @@ -129,7 +124,7 @@ export class RedditAccountsRepository { return this.db.prepare(` UPDATE reddit_accounts SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ? - WHERE account_id = ? AND status != 'legacy-unassigned' + WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed') `).run(matrixCredentialsJson, now, now, accountId).changes === 1; } @@ -138,32 +133,31 @@ export class RedditAccountsRepository { return this.db.prepare(` UPDATE reddit_accounts SET is_active = ?, status = ?, updated_at = ? - WHERE account_id = ? AND status != 'legacy-unassigned' + WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed') `).run(active ? 1 : 0, status, now, accountId).changes === 1; } /** - * Remove the account and everything it owns. Account-scoped tables added - * after the ownership migration cascade on their own, but the tables that - * migration created reference `reddit_accounts` with no delete action, so - * their rows are cleared explicitly — children before parents — or SQLite - * would refuse the delete. + * Retire the account. Everything it collected stays — the row survives so + * conversations and media keep an owner — but the stored credentials are + * destroyed, which is what makes a later send or sync impossible rather + * than merely disabled. */ - delete(accountId: string): boolean { + retire(accountId: string, now: number = Date.now()): boolean { requireText(accountId, 'accountId'); - return this.db.transaction((id: string) => { - for (const table of ACCOUNT_OWNED_TABLES) { - this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id); - } - return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1; - })(accountId); + return this.db.prepare(` + UPDATE reddit_accounts + SET status = 'removed', is_active = 0, cookies_json = '[]', matrix_credentials_json = '{}', + removed_at = ?, updated_at = ? + WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed') + `).run(now, now, accountId).changes === 1; } markLoginRequired(accountId: string, now: number = Date.now()): boolean { return this.db.prepare(` UPDATE reddit_accounts SET status = 'login-required', updated_at = ? - WHERE account_id = ? AND status != 'legacy-unassigned' + WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed') `).run(now, accountId).changes === 1; } @@ -185,6 +179,7 @@ function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord { updatedAt: row.updated_at, lastVerifiedAt: row.last_verified_at, lastImportedAt: row.last_imported_at, + removedAt: row.removed_at, }; } diff --git a/src/database/client.ts b/src/database/client.ts index d682404..36f77ee 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -5,6 +5,9 @@ import fs from 'fs'; export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__'; +/** Migrations that replace a table other tables reference by foreign key. */ +const FOREIGN_KEY_REBUILD_VERSIONS = new Set([11, 32]); + export function createDatabase(config: Pick): Database.Database { const dbPath = config.database.path === ':memory:' ? ':memory:' @@ -772,6 +775,40 @@ export function runMigrations(db: Database.Database): void { ON learning_evaluation_runs(created_at DESC); `); }, + // Removing an account retires it rather than deleting it: the content it + // collected stays readable and attributed, so the row has to survive with + // a status that keeps it out of every runtime and sending path. + () => db.exec(` + CREATE TABLE reddit_accounts_with_removed ( + account_id TEXT PRIMARY KEY, + reddit_username TEXT NOT NULL, + matrix_user_id TEXT UNIQUE, + status TEXT NOT NULL CHECK(status IN ( + 'verified', 'login-required', 'disabled', 'legacy-unassigned', 'removed' + )), + is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)), + cookies_json TEXT NOT NULL, + matrix_credentials_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_verified_at INTEGER, + last_imported_at INTEGER, + removed_at INTEGER + ); + INSERT INTO reddit_accounts_with_removed ( + account_id, reddit_username, matrix_user_id, status, is_active, + cookies_json, matrix_credentials_json, created_at, updated_at, + last_verified_at, last_imported_at, removed_at + ) SELECT + account_id, reddit_username, matrix_user_id, status, is_active, + cookies_json, matrix_credentials_json, created_at, updated_at, + last_verified_at, last_imported_at, NULL + FROM reddit_accounts; + DROP TABLE reddit_accounts; + ALTER TABLE reddit_accounts_with_removed RENAME TO reddit_accounts; + CREATE INDEX idx_reddit_accounts_username ON reddit_accounts(reddit_username COLLATE NOCASE); + CREATE INDEX idx_reddit_accounts_active ON reddit_accounts(is_active, status); + `), ]; let currentVersion = db.pragma('user_version', { simple: true }) as number; @@ -782,9 +819,10 @@ export function runMigrations(db: Database.Database): void { for (let index = currentVersion; index < migrations.length; index += 1) { const version = index + 1; // SQLite cannot rebuild a referenced table while FK enforcement is on. - // Migration 11 replaces the account-produced table graph atomically, then - // validates it before foreign-key enforcement is restored. - const rebuildsForeignKeyGraph = version === 11; + // Migration 11 replaces the account-produced table graph atomically and + // migration 32 rebuilds `reddit_accounts` itself; both validate the graph + // before foreign-key enforcement is restored. + const rebuildsForeignKeyGraph = FOREIGN_KEY_REBUILD_VERSIONS.has(version); if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF'); try { db.transaction(() => { diff --git a/src/services/reddit-accounts.ts b/src/services/reddit-accounts.ts index 8bbfde8..4765782 100644 --- a/src/services/reddit-accounts.ts +++ b/src/services/reddit-accounts.ts @@ -50,10 +50,13 @@ export interface RedditAccountSummary { redditUsername: string; matrixUserId: string; isRunning: boolean; - status: 'authenticated' | 'login-required'; + /** `removed` is terminal until a fresh session is imported for the account. */ + status: 'authenticated' | 'login-required' | 'removed'; createdAt: number; updatedAt: number; lastImportedAt: number; + /** When the operator retired the account. Null while it is in use. */ + removedAt: number | null; /** Null until the establishment check has run for this account. */ establishment: RedditAccountEstablishmentSummary | null; } @@ -78,6 +81,11 @@ export class RedditAccountsService { return this.list().find((account) => account.accountId === accountId) ?? null; } + /** Retired accounts, whose collected content is still served read-only. */ + listRemoved(): RedditAccountSummary[] { + return this.list().filter((account) => account.status === 'removed'); + } + async activate(accountId: string): Promise { const stored = this.accounts.get(accountId); if (!stored || stored.status === 'legacy-unassigned') { @@ -91,19 +99,23 @@ export class RedditAccountsService { } /** - * Stop the account's loop and drop it, along with the content it owns, from - * the database. Downloaded media files stay on disk: the media library can - * hand an image to another account, so the files are not ours alone to bin. + * Retire the account: stop its loop and destroy its credentials, but keep + * every conversation, message and media item it collected. The content stays + * readable and attributed; with no session left there is nothing to sync and + * nothing to send with. */ async remove(accountId: string): Promise { const account = this.get(accountId); if (!account) throw new Error('Reddit account not found'); + if (account.status === 'removed') throw new Error('Reddit account was already removed'); await this.runtimes?.stop(accountId); - if (!this.accounts.delete(accountId)) throw new Error('Reddit account not found'); + if (!this.accounts.retire(accountId)) throw new Error('Reddit account not found'); if (this.auth.getSnapshot().accountId === accountId) { this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`); } - return { ...account, isRunning: false, status: 'login-required' }; + const retired = this.get(accountId); + if (!retired) throw new Error('Removed Reddit account was not persisted'); + return retired; } async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise { @@ -124,18 +136,23 @@ export class RedditAccountsService { private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary { const runtime = this.runtimes?.get(account.accountId); - const running = runtime?.snapshot().state === 'running'; + const running = account.status === 'removed' ? false : runtime?.snapshot().state === 'running'; return { accountId: account.accountId, redditUsername: account.redditUsername, matrixUserId: account.matrixUserId ?? account.accountId, - isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId), - status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId) - ? 'authenticated' - : 'login-required', + isRunning: account.status === 'removed' + ? false + : running ?? (account.accountId === this.auth.getSnapshot().accountId), + status: account.status === 'removed' + ? 'removed' + : account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId) + ? 'authenticated' + : 'login-required', createdAt: account.createdAt, updatedAt: account.updatedAt, lastImportedAt: account.lastImportedAt ?? account.updatedAt, + removedAt: account.removedAt, establishment: this.toEstablishmentSummary(account.accountId), }; } diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index fe4ae65..df37b8e 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -39,11 +39,16 @@ const BulkMarkReadRequestSchema = z.strictObject({ conversationIds: z.array(z.string().min(1)).min(1).max(500), }); +const READ_ONLY_MESSAGE = + 'This conversation belongs to a removed account. Its history is kept, but nothing can be sent or synced from it.'; + type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string }; automationStatus?: ConversationAutomationStatus; automationPolicyMode?: ConversationPolicyMode; automationHandoffReason?: string | null; + /** Owned by a retired account: readable, but nothing can be sent into it. */ + readOnly?: boolean; }; /** An owned conversation with operator triage state projected onto it. */ @@ -119,12 +124,48 @@ export function createConversationsRouter(ctx: WebContext): Router { } catch { return null; } } + /** + * Conversations collected by retired accounts. There is no runtime to read + * them from, so they come straight from the account content table — the same + * payloads a running account serves out of its own cache. + */ + function retiredConversations(accountId: string): ActiveChat[] { + const store = ctx.repos.accountContent; + if (!store) return []; + return store.listConversations(accountId).flatMap((row) => { + try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; } + }); + } + + function retiredAccount(accountId: string) { + return ctx.services.redditAccounts?.listRemoved().find((account) => account.accountId === accountId); + } + async function findConversation(id: string): Promise<{ conversation: ActiveChat; runtime: NonNullable['get']>> | null; + /** The owning account was retired: serve the content, refuse every write. */ + readOnly?: { accountId: string; redditUsername: string }; } | undefined> { const owned = parseOwnedId(id); if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined; + // A retired account has no runtime by design, so it is resolved from the + // archive before the runtime requirement below would reject it. + const retired = owned ? retiredAccount(owned.accountId) : undefined; + if (owned && retired) { + const archived = retiredConversations(owned.accountId).find( + (chat) => chat.roomId === owned.conversationId + || chat.username === owned.conversationId + || chat.roomName === owned.conversationId, + ); + return archived + ? { + conversation: archived, + runtime: null, + readOnly: { accountId: retired.accountId, redditUsername: retired.redditUsername }, + } + : undefined; + } const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null; if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined; const chat = runtime?.resources.chatService ?? ctx.services.chat; @@ -137,6 +178,21 @@ export function createConversationsRouter(ctx: WebContext): Router { return conversation ? { conversation, runtime } : undefined; } + /** + * Conversation state derived purely from the stored transcript. A retired + * account owns no state repository, and borrowing another account's would + * both read and write the wrong rows. + */ + function archivedIntelligence(conversation: ActiveChat) { + const messages = (conversation.messages ?? []).map((message) => ({ + sender: message.sender, + text: message.text, + isSelf: message.isSelf, + })); + const state = buildConversationState(messages); + return { messages, state, intent: chooseConversationIntent(messages, state) }; + } + function getConversationIntelligence( conversation: ActiveChat, stateRepo = ctx.services.conversationState, @@ -231,6 +287,28 @@ export function createConversationsRouter(ctx: WebContext): Router { conversation.roomId ? readState.get(conversation.roomId) : undefined, )); })(); + // Retired accounts keep everything they collected. Their rows are marked + // read-only and never flagged as needing a reply — the operator has no + // way to answer them, so they must not sit in the actionable queue. + for (const account of ctx.services.redditAccounts?.listRemoved() ?? []) { + for (const archived of retiredConversations(account.accountId)) { + // Built without the shared state repositories on purpose: those are + // scoped to a live account, and a retired one owns none of them. + conversations.push({ + ...archived, + owner: { accountId: account.accountId, redditUsername: account.redditUsername }, + readOnly: true, + hasUnreadInbound: false, + needsReply: false, + // Deliberately left open rather than resolved: the default inbox + // filter hides resolved conversations, and archived history is + // meant to stay browsable there. + triageStatus: 'open', + snoozedUntil: null, + lastReadMessageTs: archived.lastMessageTime ?? 0, + }); + } + } if (req.query.includeImageMetadata !== 'true') { res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() }))); return; @@ -299,8 +377,11 @@ export function createConversationsRouter(ctx: WebContext): Router { ? conversation.messages[conversation.messages.length - 1].timestamp : 0; const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs; - const needsLazyLoad = - !conversation.messages || conversation.messages.length === 0 || hasNewerActivity; + // A retired conversation is whatever was captured before removal. There + // is no session left to fetch more with, and the fallback chat service + // belongs to a different account, so lazy-loading is off the table. + const needsLazyLoad = !resolved.readOnly + && (!conversation.messages || conversation.messages.length === 0 || hasNewerActivity); if (needsLazyLoad && !syncInFlight) { try { await chatService.loadConversationDetails(conversation); @@ -346,8 +427,12 @@ export function createConversationsRouter(ctx: WebContext): Router { ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err); } - const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState; - const intelligence = getConversationIntelligence(conversation, stateRepo); + const stateRepo = resolved.readOnly + ? undefined + : resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState; + const intelligence = resolved.readOnly + ? archivedIntelligence(conversation) + : getConversationIntelligence(conversation, stateRepo); const receivedImage = (conversation.messages ?? []).some( (message) => !message.isSelf && (message.media?.length ?? 0) > 0, ); @@ -375,7 +460,9 @@ export function createConversationsRouter(ctx: WebContext): Router { return { ...message, isAi: true }; }); - const readStateRepo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState; + const readStateRepo = resolved.readOnly + ? undefined + : resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState; readStateRepo?.expireSnoozes(); const triage = annotateTriage( conversation, @@ -394,9 +481,11 @@ export function createConversationsRouter(ctx: WebContext): Router { recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt, sellerDetected: intelligence.state.sellerDetected, ...(annotatedMessages ? { messages: annotatedMessages } : {}), - owner: resolved.runtime - ? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername } - : currentOwner(), + owner: resolved.readOnly + ?? (resolved.runtime + ? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername } + : currentOwner()), + ...(resolved.readOnly ? { readOnly: true } : {}), triggeringPost, automation: { turnsUsed: intelligence.state.automation.turnsUsed, @@ -433,6 +522,10 @@ export function createConversationsRouter(ctx: WebContext): Router { res.status(404).json({ error: 'Conversation not found' }); return; } + if (resolved.readOnly) { + res.status(409).json({ error: READ_ONLY_MESSAGE }); + return; + } const conversation = resolved.conversation; if (!conversation.messages || conversation.messages.length === 0) { res.status(400).json({ error: 'No messages in conversation to generate a reply from' }); @@ -472,6 +565,10 @@ export function createConversationsRouter(ctx: WebContext): Router { res.status(404).json({ error: 'Conversation not found' }); return; } + if (resolved.readOnly) { + res.status(409).json({ error: READ_ONLY_MESSAGE }); + return; + } const conversation = resolved.conversation; if (!conversation.messages || conversation.messages.length === 0) { res.status(400).json({ error: 'No messages in conversation to generate from' }); @@ -517,6 +614,10 @@ export function createConversationsRouter(ctx: WebContext): Router { res.status(404).json({ error: 'Conversation not found' }); return; } + if (resolved.readOnly) { + res.status(409).json({ error: READ_ONLY_MESSAGE }); + return; + } const conversation = resolved.conversation; const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat; const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState; @@ -605,6 +706,7 @@ export function createConversationsRouter(ctx: WebContext): Router { > { const resolved = await findConversation(decodeURIComponent(id)); if (!resolved) return { error: { status: 404, message: 'Conversation not found' } }; + if (resolved.readOnly) return { error: { status: 409, message: READ_ONLY_MESSAGE } }; const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState; if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } }; if (!resolved.conversation.roomId) { @@ -828,6 +930,10 @@ export function createConversationsRouter(ctx: WebContext): Router { if (!conversation.roomId) { return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null }); } + if (resolved.readOnly) { + res.status(409).json({ error: READ_ONLY_MESSAGE }); + return; + } // Full manual control: every enabled, unused library image is offered // regardless of what (if anything) the latest message asked for. Safety // flags surface as a non-blocking warning, never a gate. @@ -853,6 +959,7 @@ export function createConversationsRouter(ctx: WebContext): Router { try { const resolved = await findConversation(decodeURIComponent(req.params.id)); if (!resolved) return res.status(404).json({ error: 'Conversation not found' }); + if (resolved.readOnly) return res.status(409).json({ error: READ_ONLY_MESSAGE }); const runtime = resolved.runtime; const exchange = runtime?.resources.mediaExchange; if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); diff --git a/tests/account-browsing-simulation.test.ts b/tests/account-browsing-simulation.test.ts index 625ff2f..e36b9c8 100644 --- a/tests/account-browsing-simulation.test.ts +++ b/tests/account-browsing-simulation.test.ts @@ -334,10 +334,10 @@ describe('browsing migration', () => { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); - const version = db.pragma('user_version', { simple: true }) as number; db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;'); - // Five browsing migrations plus the later conversation-learning migration. - db.pragma(`user_version = ${version - 6}`); + // Absolute, not counted back from the head: browsing's first migration is + // 26, and a relative rewind breaks whenever a migration is appended. + db.pragma('user_version = 25'); runMigrations(db); insertAccount(db, ACCOUNT, 'owner'); diff --git a/tests/account-repositories.test.ts b/tests/account-repositories.test.ts index bf79cc0..a8a8305 100644 --- a/tests/account-repositories.test.ts +++ b/tests/account-repositories.test.ts @@ -134,7 +134,7 @@ describe('multi-account database ownership', () => { expect(db.pragma('foreign_key_check')).toEqual([]); }); - it('removes an account with everything it owns and leaves other accounts intact', () => { + it('retires an account while keeping every row it collected', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); @@ -174,28 +174,51 @@ describe('multi-account database ownership', () => { }); } - expect(accounts.delete('t2_alice')).toBe(true); + expect(accounts.retire('t2_alice', 500)).toBe(true); - expect(accounts.get('t2_alice')).toBeNull(); - expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob'); + // The content the account collected is the point of retiring rather than + // deleting: every row it produced must survive untouched. for (const table of [ 'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox', 'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state', ]) { expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object }) - .toEqual({ table, count: 0 }); - expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object }) .toEqual({ table, count: 1 }); } + expect(accounts.get('t2_alice')).toMatchObject({ + status: 'removed', isActive: false, removedAt: 500, + }); + // Credentials are destroyed, so no session survives to sync or send with. + expect(accounts.get('t2_alice')).toMatchObject({ cookiesJson: '[]', matrixCredentialsJson: '{}' }); + expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_bob']); expect(db.pragma('foreign_key_check')).toEqual([]); }); - it('reports an unknown account as not removed and rejects a blank id', () => { + it('refuses to reactivate or refresh a retired account', () => { + addAccount('t2_alice', 'alice-bot', 100); + expect(accounts.retire('t2_alice', 500)).toBe(true); + + expect(accounts.setActive('t2_alice', true, 600)).toBe(false); + expect(accounts.markLoginRequired('t2_alice', 600)).toBe(false); + expect(accounts.updateMatrixCredentials('t2_alice', JSON.stringify({ accessToken: 'new' }), 600)).toBe(false); + expect(accounts.get('t2_alice')).toMatchObject({ status: 'removed', matrixCredentialsJson: '{}' }); + }); + + it('revives a retired account when a fresh session is imported for it', () => { + addAccount('t2_alice', 'alice-bot', 100); + accounts.retire('t2_alice', 500); + + const revived = addAccount('t2_alice', 'alice-bot', 700); + + expect(revived).toMatchObject({ status: 'verified', isActive: true, removedAt: null }); + }); + + it('reports an unknown account as not retired and rejects a blank id', () => { addAccount('t2_alice', 'alice-bot', 100); - expect(accounts.delete('t2_missing')).toBe(false); - expect(() => accounts.delete(' ')).toThrow(/accountId/); - expect(accounts.get('t2_alice')).not.toBeNull(); + expect(accounts.retire('t2_missing')).toBe(false); + expect(() => accounts.retire(' ')).toThrow(/accountId/); + expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' }); }); it('scopes legacy account-produced table uniqueness by account', () => { diff --git a/tests/growth-repository.test.ts b/tests/growth-repository.test.ts index bb838cd..bf5e090 100644 --- a/tests/growth-repository.test.ts +++ b/tests/growth-repository.test.ts @@ -13,6 +13,8 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com'; // Browsing migrations depend on the growth tables, so a pre-growth fixture // must rewind past both the growth sequence and the later browsing additions. const GROWTH_MIGRATION_COUNT = 7; +/** Schema version that first creates the growth tables. */ +const FIRST_GROWTH_MIGRATION = 24; function thread(id: string, overrides: Partial = {}): RedditPost { return { @@ -507,14 +509,16 @@ describe('AccountGrowthRepository', () => { }); describe('growth migration', () => { - /** Rewinds past every growth migration so they replay from a pre-growth state. */ + /** + * Rewinds past every growth migration so they replay from a pre-growth state. + * The target is absolute: growth's first migration is 24, and counting back + * from the head silently breaks every time a migration is appended. + */ function rewindPastGrowth(db: Database.Database): void { - const version = db.pragma('user_version', { simple: true }) as number; db.exec('DROP TABLE account_growth_comments'); db.exec('DROP TABLE account_growth_subreddits'); db.exec('DROP TABLE account_growth_settings'); - // Also rewind the later conversation-learning migration. - db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT - 1}`); + db.pragma(`user_version = ${FIRST_GROWTH_MIGRATION - 1}`); } it('upgrades a database created before growth existed', () => { diff --git a/tests/reddit-accounts-service.test.ts b/tests/reddit-accounts-service.test.ts index aa14c49..4cedb48 100644 --- a/tests/reddit-accounts-service.test.ts +++ b/tests/reddit-accounts-service.test.ts @@ -48,7 +48,7 @@ describe('removing a Reddit account', () => { afterEach(() => db.close()); - it('stops the runtime before dropping the account and its stored credentials', async () => { + it('stops the runtime and destroys the session while keeping the account', async () => { const removed = await service.remove('t2_alice'); expect(stopped).toEqual(['t2_alice']); @@ -56,10 +56,22 @@ describe('removing a Reddit account', () => { accountId: 't2_alice', redditUsername: 'alice-bot', isRunning: false, - status: 'login-required', + status: 'removed', }); - expect(accounts.get('t2_alice')).toBeNull(); - expect(service.list()).toEqual([]); + // Retired, not deleted: the row survives so its content keeps an owner. + expect(accounts.get('t2_alice')).toMatchObject({ + status: 'removed', cookiesJson: '[]', matrixCredentialsJson: '{}', + }); + expect(service.list().map((account) => account.accountId)).toEqual(['t2_alice']); + expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']); + }); + + it('refuses to remove an account twice', async () => { + await service.remove('t2_alice'); + stopped = []; + + await expect(service.remove('t2_alice')).rejects.toThrow('Reddit account was already removed'); + expect(stopped).toEqual([]); }); it('drops the authenticated session when the removed account held it', async () => { @@ -93,12 +105,15 @@ describe('removing a Reddit account', () => { await service.remove('t2_alice'); expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' }); - expect(service.list().map((account) => account.accountId)).toEqual(['t2_bob']); + expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']); + // Bob is untouched by Alice's removal — still listed, still not retired. + expect(service.list().find((account) => account.accountId === 't2_bob')?.status) + .not.toBe('removed'); }); it('refuses to remove an account it does not know, without stopping anything', async () => { await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found'); expect(stopped).toEqual([]); - expect(accounts.get('t2_alice')).not.toBeNull(); + expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' }); }); }); diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index e86e412..d87385d 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -880,4 +880,96 @@ describe('conversations route', () => { }); }); }); + describe('removed accounts', () => { + const ARCHIVED = { + username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500, + lastMessageSnippet: 'thanks!', isFromUs: false, unread: false, + messages: [{ sender: 'alice', text: 'thanks!', timestamp: 500, isSelf: false }], + }; + const OWNED_ID = `${encodeURIComponent('@t2_gone:reddit.com')}~${encodeURIComponent('room-alice')}`; + + /** A retired account: listed by the service, but with no runtime at all. */ + function retiredContext() { + return { + logger: { error: vi.fn(), warn: vi.fn() }, + repos: { + accountContent: { + listConversations: vi.fn((accountId: string) => accountId === '@t2_gone:reddit.com' + ? [{ conversationId: 'room-alice', payloadJson: JSON.stringify(ARCHIVED) }] + : []), + }, + messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() }, + }, + services: { + chat: { + getCachedConversations: vi.fn().mockResolvedValue([]), + getSyncStatus: () => ({ syncing: false }), + loadConversationDetails: vi.fn(), + saveCacheExternal: vi.fn(), + }, + // Present so suggest-reply reaches the read-only guard rather than + // bailing out earlier on a missing AI client. + ai: { generateReply: vi.fn() }, + accountRuntimes: { list: () => [], get: () => null }, + redditAccounts: { + listRemoved: () => [{ accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }], + }, + }, + } as unknown as WebContext; + } + + it('keeps a retired account\'s conversations listed, read-only and out of the reply queue', async () => { + const app = express().use('/api', createConversationsRouter(retiredContext())); + + const body = await (await request(app, '/api/conversations')).json(); + + expect(body).toEqual([expect.objectContaining({ + roomId: 'room-alice', + readOnly: true, + needsReply: false, + hasUnreadInbound: false, + // Open, not resolved: the dashboard's default filter hides resolved + // conversations, and archived history has to stay browsable. + triageStatus: 'open', + owner: { accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }, + })]); + }); + + it('serves the stored transcript without trying to sync more of it', async () => { + const context = retiredContext(); + const app = express().use('/api', createConversationsRouter(context)); + + const body = await (await request(app, `/api/conversations/${encodeURIComponent(OWNED_ID)}`)).json(); + + expect(body).toMatchObject({ roomId: 'room-alice', readOnly: true }); + expect(body.messages).toHaveLength(1); + // The fallback chat service belongs to another account: touching it would + // both sync the wrong session and mutate the wrong cache. + expect(context.services.chat!.loadConversationDetails).not.toHaveBeenCalled(); + expect(context.services.chat!.saveCacheExternal).not.toHaveBeenCalled(); + }); + + it('refuses to send, suggest or mark anything in a retired conversation', async () => { + const app = express().use(express.json()).use('/api', createConversationsRouter(retiredContext())); + const path = `/api/conversations/${encodeURIComponent(OWNED_ID)}`; + + const responses = await Promise.all([ + request(app, `${path}/send-reply`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'hello again' }), + }), + request(app, `${path}/suggest-reply`, { method: 'POST' }), + request(app, `${path}/read`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }), + request(app, `${path}/share-media`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assetId: '00000000-0000-4000-8000-000000000000' }), + }), + ]); + + expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409]); + expect((await responses[0].json()).error).toMatch(/removed account/i); + }); + }); });