Compare commits
2
Commits
142378df33
...
a9cea7ed18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9cea7ed18 | ||
|
|
3f60f7f665 |
No files matched your search
@@ -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 (
|
||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'}>
|
||||
<div className={rowClass}>
|
||||
<div>
|
||||
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
||||
<span>
|
||||
{isRemoved
|
||||
? 'Removed — its conversations and media are kept, read-only'
|
||||
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-row-pills">
|
||||
{establishmentPill(account.establishment)}
|
||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
||||
{account.isRunning ? 'Running' : 'Paused'}
|
||||
{isRemoved ? null : establishmentPill(account.establishment)}
|
||||
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
|
||||
{isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-row-actions">
|
||||
{confirmRemove ? (
|
||||
{isRemoved ? (
|
||||
<span className="field-hint">Import a session for this account to bring it back.</span>
|
||||
) : confirmRemove ? (
|
||||
<>
|
||||
<span className="account-remove-warning">
|
||||
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.
|
||||
</span>
|
||||
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
||||
Cancel
|
||||
@@ -151,7 +159,7 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||
{removeMutation.isError ? (
|
||||
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
||||
) : null}
|
||||
{account.establishment && account.establishment.state !== 'unknown' ? (
|
||||
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||
<EstablishmentDetail establishment={account.establishment} />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="page accounts-page">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
</div>
|
||||
<div className="conversation-detail-controls">
|
||||
{conversation.readOnly ? null : (
|
||||
<ConversationTriageActions
|
||||
conversation={conversation}
|
||||
conversationId={conversationId}
|
||||
onResolved={onClose}
|
||||
/>
|
||||
)}
|
||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
</div>
|
||||
) : null}
|
||||
<MessageHistory conversation={conversation} now={now} />
|
||||
{conversation.readOnly ? (
|
||||
<section aria-labelledby="read-only-heading" className="conversation-read-only" role="status">
|
||||
<h3 id="read-only-heading">Read-only</h3>
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</span>
|
||||
))}
|
||||
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
||||
{conversation.readOnly ? (
|
||||
<span className="conversation-read-only-marker" title="The account that owned this conversation was removed. History is kept, but nothing can be sent.">
|
||||
Read-only
|
||||
</span>
|
||||
) : null}
|
||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||
import { learningApi } from './learning-api.js';
|
||||
import './learning.css';
|
||||
|
||||
@@ -13,7 +15,19 @@ function TrajectoryDetail({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="trajectory-detail">
|
||||
<dl className="trajectory-facts">
|
||||
<div><dt>Conversation</dt><dd>{trajectory.conversationId}</dd></div>
|
||||
<div>
|
||||
<dt>Conversation</dt>
|
||||
<dd>
|
||||
{/* The conversations route resolves owned ids only, so the account
|
||||
that produced the trajectory has to travel with the room id. */}
|
||||
<Link
|
||||
className="trajectory-conversation-link"
|
||||
to={`/conversations/${encodeURIComponent(createOwnedConversationId(trajectory.accountId, trajectory.conversationId))}`}
|
||||
>
|
||||
{trajectory.conversationId}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
|
||||
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
|
||||
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
|
||||
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
|
||||
.trajectory-conversation-link { color: var(--accent); text-decoration: underline; text-underline-offset: .15em; }
|
||||
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
|
||||
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+41
-3
@@ -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<Config, 'database'>): 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(() => {
|
||||
|
||||
@@ -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<RedditAccountSummary> {
|
||||
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<RedditAccountSummary> {
|
||||
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<RedditAccountSummary> {
|
||||
@@ -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)
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['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
|
||||
owner: resolved.readOnly
|
||||
?? (resolved.runtime
|
||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||
: currentOwner(),
|
||||
: 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' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
|
||||
|
||||
@@ -62,7 +63,9 @@ function renderLearning() {
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<LearningPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -96,6 +99,19 @@ describe('learning page trajectory detail', () => {
|
||||
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
|
||||
});
|
||||
|
||||
it('links the evidence to the owning account\'s conversation', async () => {
|
||||
renderLearning();
|
||||
await screen.findByText(/human created success/i);
|
||||
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
|
||||
|
||||
const link = await screen.findByRole('link', { name: '!room:reddit.com' });
|
||||
// The account id has to survive in the owned id or the route 404s.
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
`/conversations/${encodeURIComponent('%40t2_learner%3Areddit.com~!room%3Areddit.com')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses the evidence again on a second click', async () => {
|
||||
renderLearning();
|
||||
await screen.findByText(/human created success/i);
|
||||
|
||||
@@ -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> = {}): 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', () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user