- {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'}
+
- {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.
+ {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);
+ });
+ });
});