Author SHA1 Message Date
Jamie BruntonandClaude Opus 5 a9cea7ed18 Retire removed accounts instead of deleting them
Removing an account had no path at all, so a dead session could only be
left running or hand-edited out of the database. The first cut deleted the
account and everything it owned, which threw away conversations and media
that are worth keeping.

Retire instead: keep the row and every row it produced, destroy the stored
cookies and Matrix token. Wiping the credentials is what makes sending and
syncing impossible rather than merely disabled — isEnabledAccount requires
a verified status, so no runtime ever starts, and no session survives for
anything to use. setActive, markLoginRequired and the background credential
refresh now skip retired accounts so none of them quietly revives one;
re-importing a session deliberately does.

Keeping the content visible needed more than keeping the rows. Conversations
and media are only served from running runtimes, so a retired account's
history would have disappeared from the dashboard regardless. Media already
reads the account content table for every listed account. Conversations now
have an archive path that reads the stored ActiveChat payloads directly.

Every write path refuses a retired conversation with a 409: send-reply,
both suggest endpoints, share-media, shareable-media and all read-state
writes. Lazy-loading detail is skipped too — the fallback chat service
belongs to a different account, so it would sync the wrong session and
write the wrong cache. Archived detail derives conversation state from the
transcript alone for the same reason.

Migration 32 rebuilds reddit_accounts to admit the new status, with foreign
keys off and a foreign_key_check before they are restored, as migration 11
does. Two migration tests rewound user_version by counting back from the
head, which silently breaks whenever a migration is appended; they now
target absolute versions.

Conversations never opened before removal keep only their summary. Message
history is lazy-loaded on first open and there is no session left to fetch
the rest with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:22 +01:00
Jamie BruntonandClaude Opus 5 3f60f7f665 Link learning trajectories to their conversation
The trajectory detail panel showed the conversation id as plain text, so
reviewing an example meant copying the room id and hunting for it in the
inbox — with the evidence panel open on one screen and the conversation on
another.

Render it as a link instead. The id has to travel as an owned id
(accountId~conversationId): once account runtimes exist, findConversation
rejects a bare room id outright, so a plain link would 404 for every
multi-account install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:04 +01:00
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while
only seeing metadata — credit class, confidence, status. The decisions
and their redacted context turns were already captured and stored, but
no endpoint returned them, so the evidence was unreachable from the UI.

Add GET /api/learning/trajectories/:id and an expandable row on the
learning page that fetches on demand.

Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit
and imageOutcomeAt. The learning loop added these to SentMessageRecord
and they flow out of /api/messages, but the strict contract rejected
them, breaking the dashboard.

Enable generation with shadow mode off so eligible examples reach reply
prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:22 +01:00
34 changed files with 1233 additions and 58 deletions

No files matched your search

@@ -1,13 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js';
import type {
AccountEstablishmentSummary,
RedditAccountSummary,
} from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
useRemoveRedditAccountMutation,
} from '../settings/settings-queries.js';
import './accounts.css';
const CONFIRM_TIMEOUT_MS = 8_000;
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
@@ -85,6 +91,81 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
return <span className="pill success">Established</span>;
}
/**
* Removal is destructive — the account's messages, activity and sync state go
* with it — so the button arms a confirmation that disarms itself if ignored.
*/
function AccountRow({ account }: { account: RedditAccountSummary }) {
const removeMutation = useRemoveRedditAccountMutation();
const [confirmRemove, setConfirmRemove] = useState(false);
const isRemoved = account.status === 'removed';
useEffect(() => {
if (!confirmRemove) return;
const timer = globalThis.setTimeout(() => setConfirmRemove(false), CONFIRM_TIMEOUT_MS);
return () => globalThis.clearTimeout(timer);
}, [confirmRemove]);
const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row';
return (
<div className={rowClass}>
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<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">
{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">
{isRemoved ? (
<span className="field-hint">Import a session for this account to bring it back.</span>
) : confirmRemove ? (
<>
<span className="account-remove-warning">
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
</button>
<button
aria-busy={removeMutation.isPending || undefined}
className="button danger"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate(account.accountId)}
type="button"
>
{removeMutation.isPending ? 'Removing…' : 'Confirm removal'}
</button>
</>
) : (
<button
aria-label={`Remove u/${account.redditUsername}`}
className="button secondary"
onClick={() => setConfirmRemove(true)}
type="button"
>
Remove
</button>
)}
</div>
{removeMutation.isError ? (
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
) : null}
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
</div>
);
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
@@ -130,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">
@@ -211,21 +294,7 @@ export function AccountsPage() {
</div>
) : null}
{accounts.map((account) => (
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}>
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{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'}
</span>
</div>
{account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
</div>
<AccountRow account={account} key={account.accountId} />
))}
{accountsQuery.isSuccess && accounts.length === 0 ? (
<div className="empty-state">
@@ -5,10 +5,13 @@
.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; }
.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.account-row > .account-row-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .4rem; margin-left: auto; }
.account-row .account-remove-warning { flex-basis: 100%; text-align: right; color: var(--danger-text, var(--muted)); font-size: .78rem; }
/* Establishment evidence sits on its own row beneath the account summary. */
.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); }
@@ -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,4 +1,10 @@
import { LearningOverviewResponseSchema, LearningReviewResponseSchema, type LearningOverviewResponse } from '../../../../../packages/api-contracts/src/index.js';
import {
LearningOverviewResponseSchema,
LearningReviewResponseSchema,
LearningTrajectoryDetailResponseSchema,
type LearningOverviewResponse,
type LearningTrajectoryDetailResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
@@ -12,6 +18,10 @@ export const learningApi = {
getOverview(): Promise<LearningOverviewResponse> {
return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema));
},
getTrajectory(id: string): Promise<LearningTrajectoryDetailResponse> {
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`)
.then((response) => parse(response, LearningTrajectoryDetailResponseSchema));
},
review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> {
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }),
@@ -1,11 +1,71 @@
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';
const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']);
function TrajectoryDetail({ id }: { id: string }) {
const detail = useQuery({ queryKey: ['learning', 'trajectory', id], queryFn: () => learningApi.getTrajectory(id) });
if (detail.isPending) return <div className="panel-loading" role="status"><span className="spinner" />Loading decisions…</div>;
if (detail.isError) return <div className="error-panel" role="alert">{detail.error.message}</div>;
const { trajectory, decisions } = detail.data;
return (
<div className="trajectory-detail">
<dl className="trajectory-facts">
<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>
{trajectory.invalidationReason ? <div><dt>Invalidated</dt><dd>{trajectory.invalidationReason}</dd></div> : null}
</dl>
{decisions.length === 0 ? <p className="feature-empty">No manual decisions were captured for this trajectory.</p> : (
<ol className="decision-list">
{decisions.map((decision) => (
<li key={decision.id} className="decision-card">
<div className="decision-heading">
<span className="decision-stage">{decision.stage.replaceAll('-', ' ')}</span>
{decision.isDirectMediaInvitation ? <span className="decision-flag">Direct media ask</span> : null}
<span className={`trajectory-status status-${decision.qualityStatus}`}>{decision.qualityStatus}</span>
<span className="decision-turns">{decision.turnsUntilOutcome} turn(s) to outcome</span>
</div>
{decision.context.length > 0 ? (
<div className="decision-context">
{decision.context.map((turn) => (
<p key={turn.eventId} className={`turn turn-${turn.role}`}>
<span className="turn-actor">{turn.role === 'recipient' ? 'Them' : turn.actor === 'human' ? 'You' : 'AI'}</span>
<span className="turn-text">{turn.text}</span>
</p>
))}
</div>
) : null}
<p className="decision-chosen"><span className="turn-actor">Your message</span><span className="turn-text">{decision.chosenResponse}</span></p>
</li>
))}
</ol>
)}
<p className="decision-note">Identifiers are redacted before storage. Text shown here is what generation would receive.</p>
</div>
);
}
export function LearningPage() {
const client = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
const review = useMutation({
mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status),
@@ -27,11 +87,16 @@ export function LearningPage() {
<section className="panel learning-panel" aria-labelledby="trajectory-heading">
<div className="panel-heading"><div><span className="eyebrow">Policy {overview.data.policyVersion}</span><h2 id="trajectory-heading">Attributed trajectories</h2></div><span className="sample-size">n = {summary.total}</span></div>
{trajectories.length === 0 ? <p className="feature-empty">No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.</p> : (
<div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.map((item) => (
<div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th><span className="sr-only">Expand</span></th><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.flatMap((item) => {
const isOpen = expanded === item.id;
return [
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
<td><button type="button" className="button secondary expand-toggle" aria-expanded={isOpen} aria-controls={`detail-${item.id}`} onClick={() => setExpanded(isOpen ? null : item.id)}>{isOpen ? '▾' : '▸'}<span className="sr-only"> Show conversation evidence</span></button></td>
<th scope="row">{item.creditClass.replaceAll('-', ' ')}</th><td>{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}</td><td>{item.attributionConfidence}</td><td><span className={`trajectory-status status-${item.status}`}>{item.status}</span></td><td>{new Date(item.updatedAt).toLocaleString()}</td><td><div className="review-actions"><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'eligible' })}>Include</button><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'excluded' })}>Exclude</button></div></td>
</tr>
))}</tbody></table></div>
</tr>,
...(isOpen ? [<tr key={`${item.id}-detail`} id={`detail-${item.id}`} className="detail-row"><td colSpan={7}><TrajectoryDetail id={item.id} /></td></tr>] : []),
];
})}</tbody></table></div>
)}
</section>
</main>
@@ -8,5 +8,25 @@
.learning-table tr.human-led th:first-child { color: var(--accent); }
.review-actions { display: flex; gap: .4rem; }
.review-actions .button { padding: .4rem .6rem; }
.expand-toggle { padding: .3rem .5rem; line-height: 1; }
.detail-row > td { background: var(--surface-strong); padding: .85rem 1rem; }
.trajectory-detail { display: grid; gap: .85rem; }
.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; }
.decision-stage { color: var(--accent); font-size: .78rem; text-transform: capitalize; }
.decision-flag { border-radius: 999px; background: var(--surface); color: var(--warning); font-size: .72rem; padding: .2rem .5rem; }
.decision-turns { color: var(--muted); font-size: .72rem; margin-left: auto; }
.decision-context { display: grid; gap: .3rem; }
.turn { display: grid; gap: .1rem; margin: 0; }
.turn-actor { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
.turn-text { font-size: .85rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.turn-recipient .turn-text { color: var(--muted); }
.decision-chosen { border-left: 2px solid var(--accent); display: grid; gap: .1rem; margin: 0; padding-left: .6rem; }
.decision-note { color: var(--muted); font-size: .72rem; margin: 0; }
@media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
@@ -6,6 +6,7 @@ import {
ErrorResponseSchema,
LoginStatusResponseSchema,
RedditAccountsResponseSchema,
RedditAccountRemovalResponseSchema,
RedditCookieImportRequestSchema,
RedditCookieImportResponseSchema,
type ConfigResponse,
@@ -52,6 +53,16 @@ export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
}
export async function removeRedditAccount(accountId: string): Promise<RedditAccountSummary> {
const response = await request(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
// A refused removal carries its reason in the same envelope as a successful
// one, so parse the body before falling back to the bare status code.
const parsed = RedditAccountRemovalResponseSchema.safeParse(await response.json() as unknown);
if (!parsed.success) throw new Error(`Request failed (${response.status})`);
if (!parsed.data.ok) throw new Error(parsed.data.error);
return parsed.data.account;
}
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
const body = RedditCookieImportRequestSchema.parse(exported);
const response = await parseResponse(
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
import {
fetchLoginStatus,
fetchRedditAccounts,
fetchSettings,
importRedditSession,
removeRedditAccount,
saveSettings,
} from './settings-api.js';
import {
fetchPushNotifications,
savePushNotifications,
@@ -75,3 +82,20 @@ export function useImportRedditSessionMutation() {
},
});
}
export function useRemoveRedditAccountMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: removeRedditAccount,
onSuccess() {
invalidateAccountContent(queryClient);
// Removal takes the account's outreach history with it, so every
// account-derived view on the dashboard is now stale.
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'growth'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
void queryClient.invalidateQueries({ queryKey: ['performance'] });
void queryClient.invalidateQueries({ queryKey: ['outreach'] });
void queryClient.invalidateQueries({ queryKey: ['attention'] });
},
});
}
+2 -2
View File
@@ -65,8 +65,8 @@ conversations:
learning:
captureEnabled: true # Build an auditable dataset without changing messages
generationEnabled: false # Enable only after the candidate policy passes offline evaluation
shadowMode: true # Record which examples would be used without injecting them
generationEnabled: true # Retrieve eligible examples for reply generation
shadowMode: false # Inject the retrieved examples into prompts (true = audit only)
maturityHours: 12
maxContextExamples: 3
maxTurnsPerExample: 8
+2
View File
@@ -64,6 +64,7 @@ an administrator credential.
| `GET` | `/api/stats` | Get bot statistics | read | no |
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no |
| `GET` | `/api/learning` | Inspect attributed learning trajectories and credit | read | no |
| `GET` | `/api/learning/trajectories/{id}` | Inspect the redacted decisions behind a learning trajectory | read | no |
| `PATCH` | `/api/learning/trajectories/{id}` | Include or exclude a learning trajectory | admin | yes |
| `GET` | `/api/activity` | Get recent activity | read | no |
| `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | read | no |
@@ -127,6 +128,7 @@ an administrator credential.
| `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes |
| `GET` | `/api/accounts` | List Reddit accounts | read | no |
| `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes |
| `DELETE` | `/api/accounts/{accountId}` | Remove a Reddit account and the content it owns | admin | yes |
| `GET` | `/api/logs/stream` | Stream legacy log events | read | no |
| `GET` | `/api/events/stream` | Stream typed application events | read | no |
| `GET` | `/api/logs` | Get recent structured logs | read | no |
+1 -1
View File
@@ -50,7 +50,7 @@ The repository/provider/evaluation interfaces are the framework seams. A hosted
1. Deploy with capture enabled, generation disabled, and shadow mode enabled.
2. Run a full chat sync to backfill authoritative histories.
3. Review inferred and fingerprint-attributed trajectories in `/learning`.
3. Review inferred and fingerprint-attributed trajectories in `/learning`. Expanding a row calls `GET /api/learning/trajectories/{id}` and shows the redacted context turns and chosen response behind each decision, so include/exclude is a judgement on visible evidence rather than on metadata alone.
4. Let examples mature and run the offline evaluation against a backup/non-production copy.
5. Compare human, autonomous, and recipient-initiated cohorts; do not blend them into the opener strategy metric.
6. Enable generation while retaining shadow mode to exercise selection/auditing.
+5
View File
@@ -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;
+24
View File
@@ -32,4 +32,28 @@ export const LearningOverviewResponseSchema = z.strictObject({
export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) });
export const LearningStageSchema = z.enum([
'rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown',
]);
export const LearningDecisionStatusSchema = z.enum(['eligible', 'excluded', 'invalidated', 'needs-review']);
export const LearningTurnSchema = z.strictObject({
eventId: z.string(), timestamp: z.number(), role: z.enum(['recipient', 'operator']),
actor: z.enum(['recipient', 'ai', 'human']),
kind: z.enum(['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal']),
text: z.string(),
});
export const LearningDecisionSchema = z.strictObject({
id: z.string(), trajectoryId: z.string(), accountId: z.string(), decisionEventId: z.string(),
stage: LearningStageSchema, context: z.array(LearningTurnSchema), chosenResponse: z.string(),
turnsUntilOutcome: z.number().int(), isDirectMediaInvitation: z.boolean(),
qualityStatus: LearningDecisionStatusSchema, createdAt: z.number(), updatedAt: z.number(),
});
export const LearningTrajectoryDetailResponseSchema = z.strictObject({
trajectory: LearningTrajectorySchema,
decisions: z.array(LearningDecisionSchema),
});
export type LearningOverviewResponse = z.infer<typeof LearningOverviewResponseSchema>;
export type LearningTrajectoryDetailResponse = z.infer<typeof LearningTrajectoryDetailResponseSchema>;
export type LearningDecision = z.infer<typeof LearningDecisionSchema>;
export type LearningTurn = z.infer<typeof LearningTurnSchema>;
+4
View File
@@ -25,6 +25,10 @@ export const SentMessageSchema = z.strictObject({
postImageAnalysis: z.string().optional(),
postCreatedAt: z.number().optional(),
owner: RedditAccountOwnerSchema.optional(),
// Attribution/learning fields added by the conversation learning loop.
matrixEventId: z.string().nullable().optional(),
imageOutcomeCredit: z.string().nullable().optional(),
imageOutcomeAt: z.number().nullable().optional(),
});
export const MessagesResponseSchema = z.array(SentMessageSchema);
+9 -1
View File
@@ -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(),
});
@@ -88,6 +90,11 @@ export const RedditAccountActivationResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
z.strictObject({ ok: z.literal(false), error: z.string() }),
]);
/** The removed account is echoed back so the caller can name what it deleted. */
export const RedditAccountRemovalResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
z.strictObject({ ok: z.literal(false), error: z.string() }),
]);
export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
@@ -96,3 +103,4 @@ export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
export type AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>;
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
export type RedditAccountRemovalResponse = z.infer<typeof RedditAccountRemovalResponseSchema>;
+33 -7
View File
@@ -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,6 +48,7 @@ type AccountRow = {
updated_at: number;
last_verified_at: number | null;
last_imported_at: number | null;
removed_at: number | null;
};
export class RedditAccountsRepository {
@@ -49,7 +57,7 @@ export class RedditAccountsRepository {
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[];
@@ -66,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
@@ -99,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));
}
@@ -115,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;
}
@@ -124,15 +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;
}
/**
* 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.
*/
retire(accountId: string, now: number = Date.now()): boolean {
requireText(accountId, '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;
}
@@ -154,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
View File
@@ -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(() => {
+37 -4
View File
@@ -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') {
@@ -90,6 +98,26 @@ export class RedditAccountsService {
return this.importSession(cookies, credentials);
}
/**
* 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.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`);
}
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> {
try {
const verified = await this.importer.import(cookies, credentials);
@@ -108,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),
};
}
+10 -1
View File
@@ -20,7 +20,12 @@ import {
GrowthSubredditCreateRequestSchema,
GrowthSubredditUpdateRequestSchema,
} from './growth-contracts.js';
import { LearningOverviewResponseSchema, LearningReviewRequestSchema, LearningReviewResponseSchema } from './learning-contracts.js';
import {
LearningOverviewResponseSchema,
LearningReviewRequestSchema,
LearningReviewResponseSchema,
LearningTrajectoryDetailResponseSchema,
} from './learning-contracts.js';
export type ApiAccess = 'read' | 'control' | 'admin';
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -84,6 +89,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
operation('getLearningOverview', 'GET', '/api/learning', 'Inspect attributed learning trajectories and credit', ['Learning'], { responseSchema: LearningOverviewResponseSchema }),
operation('getLearningTrajectory', 'GET', '/api/learning/trajectories/{id}', 'Inspect the redacted decisions behind a learning trajectory', ['Learning'], { responseSchema: LearningTrajectoryDetailResponseSchema }),
operation('reviewLearningTrajectory', 'PATCH', '/api/learning/trajectories/{id}', 'Include or exclude a learning trajectory', ['Learning'], { requestSchema: LearningReviewRequestSchema, responseSchema: LearningReviewResponseSchema }),
operation('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']),
operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['Dashboard']),
@@ -229,6 +235,9 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']),
operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']),
operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['Accounts']),
operation('removeAccount', 'DELETE', '/api/accounts/{accountId}', 'Remove a Reddit account and the content it owns', ['Accounts'], {
access: 'admin',
}),
operation('streamLogs', 'GET', '/api/logs/stream', 'Stream legacy log events', ['Events'], { streaming: true }),
operation('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }),
operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']),
+20
View File
@@ -26,3 +26,23 @@ export const LearningOverviewResponseSchema = z.strictObject({
});
export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) });
export const LearningStageSchema = z.enum([
'rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown',
]);
export const LearningDecisionStatusSchema = z.enum(['eligible', 'excluded', 'invalidated', 'needs-review']);
export const LearningTurnSchema = z.strictObject({
eventId: z.string(), timestamp: z.number(), role: z.enum(['recipient', 'operator']),
actor: z.enum(['recipient', 'ai', 'human']),
kind: z.enum(['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal']),
text: z.string(),
});
export const LearningDecisionSchema = z.strictObject({
id: z.string(), trajectoryId: z.string(), accountId: z.string(), decisionEventId: z.string(),
stage: LearningStageSchema, context: z.array(LearningTurnSchema), chosenResponse: z.string(),
turnsUntilOutcome: z.number().int(), isDirectMediaInvitation: z.boolean(),
qualityStatus: LearningDecisionStatusSchema, createdAt: z.number(), updatedAt: z.number(),
});
export const LearningTrajectoryDetailResponseSchema = z.strictObject({
trajectory: LearningTrajectorySchema,
decisions: z.array(LearningDecisionSchema),
});
+114 -7
View File
@@ -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' });
+11
View File
@@ -35,6 +35,17 @@ export function createLearningRouter(ctx: WebContext): Router {
});
});
router.get('/learning/trajectories/:id', (req, res) => {
for (const runtime of runtimes()) {
const detail = runtime.resources.conversationLearningRepo.getTrajectory(req.params.id);
if (!detail) continue;
const { decisions, ...trajectory } = detail;
res.json({ trajectory, decisions });
return;
}
res.status(404).json({ error: 'Learning trajectory not found' });
});
router.patch('/learning/trajectories/:id', (req, res) => {
const parsed = LearningReviewRequestSchema.safeParse(req.body);
if (!parsed.success) {
+22
View File
@@ -158,5 +158,27 @@ export function createLoginRouter(ctx: WebContext): Router {
}
});
router.delete('/accounts/:accountId', async (req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ ok: false, error: 'Reddit account service not initialised' });
return;
}
const accountId = decodeURIComponent(req.params.accountId);
try {
const account = await accounts.remove(accountId);
ctx.logger.info(`Removed Reddit account u/${account.redditUsername} and the content it owned`);
res.json({ ok: true, account });
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not remove Reddit account';
if (message === 'Reddit account not found') {
res.status(404).json({ ok: false, error: message });
return;
}
ctx.logger.error('Failed to remove Reddit account:', error);
res.status(500).json({ ok: false, error: message });
}
});
return router;
}
+3 -3
View File
@@ -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');
+87
View File
@@ -134,6 +134,93 @@ describe('multi-account database ownership', () => {
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('retires an account while keeping every row it collected', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
for (const accountId of ['t2_alice', 't2_bob']) {
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'post-one', 'test', 'recipient', 'title', 1)
`).run(accountId);
db.prepare(`
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at)
VALUES (?, 'post-one', 'recipient', 'subject', 'message', 2)
`).run(accountId);
db.prepare(`
INSERT INTO successful_messages (account_id, original_message, created_at)
VALUES (?, 'message', 3)
`).run(accountId);
db.prepare(`
INSERT INTO delivery_outbox (
account_id, post_id, recipient, subject, message, idempotency_key,
status, created_at, updated_at
) VALUES (?, 'post-one', 'recipient', 'subject', 'message', 'key', 'prepared', 1, 1)
`).run(accountId);
db.prepare(`
INSERT INTO activity_log (account_id, timestamp, type, message) VALUES (?, 4, 'sent', 'activity')
`).run(accountId);
db.prepare(`
INSERT INTO conversation_states (account_id, conversation_id, state_json, updated_at)
VALUES (?, 'room', '{}', 5)
`).run(accountId);
content.upsertConversation({
accountId, conversationId: 'room', username: 'recipient', roomName: 'Recipient',
payloadJson: '{}', updatedAt: 6,
});
content.saveSyncState({
accountId, nextBatch: 'cursor', lastFullSyncAt: 6, lastDeltaSyncAt: 6,
cooldownJson: null, updatedAt: 6,
});
}
expect(accounts.retire('t2_alice', 500)).toBe(true);
// 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: 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('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.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', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
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';
const TRAJECTORY_ID = 'traj-1';
let detailCalls: string[] = [];
function summary() {
return {
total: 1, provisional: 0, eligible: 1, excluded: 0, invalidated: 0, needsReview: 0,
humanCreated: 1, humanImproved: 0, humanCompletedAi: 0, autonomous: 0,
recipientInitiated: 0, uncertain: 0, exact: 1, fingerprint: 0, inferred: 0,
};
}
function trajectory() {
return {
id: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com', conversationId: '!room:reddit.com',
sentMessageId: 7, subreddit: 'cats', sourceType: 'chat' as const, policyMode: 'r4r-media' as const,
firstHumanEventId: '$human1', outcomeEventId: '$image1', outcomeType: 'image-received' as const,
creditClass: 'human-created-success' as const, attributionConfidence: 'exact' as const,
status: 'eligible' as const, maturityAt: 1_700_000_100_000, invalidatedAt: null,
invalidationReason: null, reviewedAt: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
function decision() {
return {
id: 'dec-1', trajectoryId: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com',
decisionEventId: '$human1', stage: 'media-invitation' as const,
context: [{
eventId: '$in1', timestamp: 1_700_000_010_000, role: 'recipient' as const,
actor: 'recipient' as const, kind: 'inbound-text' as const, text: 'hey how was your weekend',
}],
chosenResponse: 'no pressure, only if you want to',
turnsUntilOutcome: 2, isDirectMediaInvitation: true, qualityStatus: 'eligible' as const,
createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
const server = setupServer(
http.get('/api/learning', () => HttpResponse.json({
summary: summary(), trajectories: [trajectory()],
captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1',
})),
http.get('/api/learning/trajectories/:id', ({ params }) => {
detailCalls.push(String(params.id));
return HttpResponse.json({ trajectory: trajectory(), decisions: [decision()] });
}),
);
function renderLearning() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<LearningPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
beforeEach(() => {
detailCalls = [];
});
describe('learning page trajectory detail', () => {
it('does not fetch decisions until a row is expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
expect(detailCalls).toEqual([]);
});
it('reveals the redacted conversation evidence when expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
expect(await screen.findByText('no pressure, only if you want to')).toBeInTheDocument();
expect(screen.getByText('hey how was your weekend')).toBeInTheDocument();
expect(screen.getByText('!room:reddit.com')).toBeInTheDocument();
expect(screen.getByText(/direct media ask/i)).toBeInTheDocument();
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);
const toggle = screen.getByRole('button', { name: /show conversation evidence/i });
await userEvent.click(toggle);
await screen.findByText('no pressure, only if you want to');
await userEvent.click(toggle);
await waitFor(() => {
expect(screen.queryByText('no pressure, only if you want to')).not.toBeInTheDocument();
});
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
});
+8 -4
View File
@@ -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
View File
@@ -149,6 +149,54 @@ describe('Reddit session status', () => {
expect((await request(app, '/login/start', { method: 'POST' })).status).toBe(404);
});
it('removes an account and reports the account it deleted', async () => {
const remove = vi.fn().mockResolvedValue({ accountId: 't2_alice', redditUsername: 'alice-bot' });
const app = express();
app.use(createLoginRouter({
services: { redditAccounts: { remove } },
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
ok: true,
account: { accountId: 't2_alice', redditUsername: 'alice-bot' },
});
expect(remove).toHaveBeenCalledWith('t2_alice');
});
it('answers 404 when the account to remove does not exist', async () => {
const app = express();
app.use(createLoginRouter({
services: {
redditAccounts: { remove: async () => { throw new Error('Reddit account not found'); } },
},
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_missing', { method: 'DELETE' });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: 'Reddit account not found' });
});
it('reports a failed removal as a server error rather than a silent success', async () => {
const app = express();
app.use(createLoginRouter({
services: {
redditAccounts: { remove: async () => { throw new Error('database is locked'); } },
},
logger: { error: () => undefined, info: () => undefined },
} as never));
const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' });
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ ok: false, error: 'database is locked' });
});
it('classifies Reddit navigation failures as unreachable', () => {
expect(
classifyRedditSessionError(
+119
View File
@@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RedditAccountsRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import { AccountAuthContext } from '../src/services/account-auth-context.js';
import { RedditAccountsService } from '../src/services/reddit-accounts.js';
const silentLogger = {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
} as never;
describe('removing a Reddit account', () => {
let db: Database.Database;
let accounts: RedditAccountsRepository;
let auth: AccountAuthContext;
let stopped: string[];
let service: RedditAccountsService;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
accounts = new RedditAccountsRepository(db);
auth = new AccountAuthContext(silentLogger);
stopped = [];
const runtimes = {
get: () => null,
stop: async (accountId: string) => { stopped.push(accountId); },
};
service = new RedditAccountsService(
accounts,
{ import: async () => { throw new Error('not used'); } } as never,
auth,
runtimes as never,
);
accounts.upsertVerified({
accountId: 't2_alice',
redditUsername: 'alice-bot',
matrixUserId: '@t2_alice:reddit.com',
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'secret' }]),
matrixCredentialsJson: JSON.stringify({ accessToken: 'matrix-secret' }),
now: 100,
});
});
afterEach(() => db.close());
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']);
expect(removed).toMatchObject({
accountId: 't2_alice',
redditUsername: 'alice-bot',
isRunning: false,
status: 'removed',
});
// 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 () => {
auth.markAuthenticated({
accountId: 't2_alice',
redditUsername: 'alice-bot',
matrixUserId: '@t2_alice:reddit.com',
});
expect(auth.getSnapshot().accountId).toBe('t2_alice');
await service.remove('t2_alice');
expect(auth.getSnapshot()).toMatchObject({ status: 'login-required', accountId: null });
});
it('leaves another account\'s session alone', async () => {
accounts.upsertVerified({
accountId: 't2_bob',
redditUsername: 'bob-bot',
matrixUserId: '@t2_bob:reddit.com',
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'bob-secret' }]),
matrixCredentialsJson: JSON.stringify({ accessToken: 'bob-matrix-secret' }),
now: 110,
});
auth.markAuthenticated({
accountId: 't2_bob',
redditUsername: 'bob-bot',
matrixUserId: '@t2_bob:reddit.com',
});
await service.remove('t2_alice');
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: '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')).toMatchObject({ status: 'verified' });
});
});
+92
View File
@@ -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);
});
});
});
+118
View File
@@ -0,0 +1,118 @@
import express from 'express';
import Database from 'better-sqlite3';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LearningTrajectoryDetailResponseSchema } from '../../packages/api-contracts/src/index.js';
import { runMigrations } from '../../src/database/client.js';
import { ConversationLearningRepository } from '../../src/database/conversation-learning-repository.js';
import type { WebContext } from '../../src/web/context.js';
import { createLearningRouter } from '../../src/web/routes/learning.js';
const ACCOUNT = '@t2_learner:reddit.com';
const CONVERSATION = '!room:reddit.com';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function call(app: express.Express, path: string): Promise<{ status: number; json: any }> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}${path}`);
const text = await response.text();
return { status: response.status, json: text ? JSON.parse(text) : null };
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => (
new Promise<void>((resolve) => server.close(() => resolve()))
)));
});
describe('GET /api/learning/trajectories/:id', () => {
let db: Database.Database;
let repo: ConversationLearningRepository;
let app: express.Express;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
db.prepare(`
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (?, 'learner', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(ACCOUNT, ACCOUNT);
repo = new ConversationLearningRepository(db, ACCOUNT);
repo.upsertTrajectory({
id: 'traj-1',
conversationId: CONVERSATION,
sentMessageId: null,
subreddit: 'cats',
sourceType: 'chat',
policyMode: 'r4r-media',
firstHumanEventId: '$human1',
outcomeEventId: '$image1',
outcomeType: 'image-received',
creditClass: 'human-created-success',
attributionConfidence: 'exact',
status: 'eligible',
maturityAt: 1_000,
invalidatedAt: null,
invalidationReason: null,
now: 900,
});
repo.upsertDecision({
id: 'dec-1',
trajectoryId: 'traj-1',
decisionEventId: '$human1',
stage: 'media-invitation',
context: [{
eventId: '$in1', timestamp: 500, role: 'recipient', actor: 'recipient',
kind: 'inbound-text', text: 'hey there',
}],
chosenResponse: 'no pressure, only if you want to',
turnsUntilOutcome: 2,
isDirectMediaInvitation: true,
qualityStatus: 'eligible',
now: 900,
});
const ctx = {
config: { learning: { captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1' } },
services: {
accountRuntimes: {
list: () => [{ accountId: ACCOUNT }],
get: () => ({ resources: { conversationLearningRepo: repo } }),
},
},
} as unknown as WebContext;
app = express();
app.use(express.json());
app.use('/api', createLearningRouter(ctx));
});
it('returns the redacted decisions behind a trajectory', async () => {
const { status, json } = await call(app, '/api/learning/trajectories/traj-1');
expect(status).toBe(200);
const parsed = LearningTrajectoryDetailResponseSchema.parse(json);
expect(parsed.trajectory.conversationId).toBe(CONVERSATION);
expect(parsed.trajectory.creditClass).toBe('human-created-success');
expect(parsed.decisions).toHaveLength(1);
expect(parsed.decisions[0]?.chosenResponse).toBe('no pressure, only if you want to');
expect(parsed.decisions[0]?.context[0]?.text).toBe('hey there');
expect(parsed.decisions[0]?.isDirectMediaInvitation).toBe(true);
});
it('does not nest decisions inside the trajectory object', async () => {
const { json } = await call(app, '/api/learning/trajectories/traj-1');
expect(json.trajectory.decisions).toBeUndefined();
});
it('404s for an unknown trajectory', async () => {
const { status, json } = await call(app, '/api/learning/trajectories/missing');
expect(status).toBe(404);
expect(json.error).toBe('Learning trajectory not found');
});
});
+42 -1
View File
@@ -1,7 +1,7 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
import { MessagesResponseSchema, StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
import type { WebContext } from '../../src/web/context.js';
import { createStatsRouter } from '../../src/web/routes/stats.js';
@@ -95,3 +95,44 @@ describe('stats route account status', () => {
expect(stats.accounts[1]?.invitationCooldown).not.toHaveProperty('setAt');
});
});
describe('messages route contract', () => {
it('accepts the attribution fields the learning loop adds to sent messages', async () => {
const context = {
services: {},
repos: {
messages: {
getRecent: () => [{
id: 1,
postId: 'p1',
recipient: 'someone',
subject: 'hey',
message: 'hello there',
sentAt: 1_700_000_000_000,
gotResponse: true,
gotImage: true,
responseCheckedAt: 1_700_000_100_000,
strategyId: null,
chatRoomId: '!room:reddit.com',
// Added by the conversation learning loop; must not break the dashboard.
matrixEventId: '$opener1',
imageOutcomeCredit: 'human-created-success',
imageOutcomeAt: 1_700_000_200_000,
}],
},
},
activity: { recent: () => [], totalProcessed: () => 0 },
getBotRunning: () => true,
getBotMode: () => 'running',
getMessagesThisHour: () => 0,
} as unknown as WebContext;
const app = express().use('/api', createStatsRouter(context));
const response = await request(app, '/api/messages');
const messages = MessagesResponseSchema.parse(await response.json());
expect(messages[0]?.matrixEventId).toBe('$opener1');
expect(messages[0]?.imageOutcomeCredit).toBe('human-created-success');
expect(messages[0]?.imageOutcomeAt).toBe(1_700_000_200_000);
});
});