Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9cea7ed18 | ||
|
|
3f60f7f665 | ||
|
|
142378df33 |
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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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') {
|
||||
@@ -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
@@ -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']),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user