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 { 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 { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import {
|
import {
|
||||||
useImportRedditSessionMutation,
|
useImportRedditSessionMutation,
|
||||||
useLoginStatusQuery,
|
useLoginStatusQuery,
|
||||||
useRedditAccountsQuery,
|
useRedditAccountsQuery,
|
||||||
|
useRemoveRedditAccountMutation,
|
||||||
} from '../settings/settings-queries.js';
|
} from '../settings/settings-queries.js';
|
||||||
import './accounts.css';
|
import './accounts.css';
|
||||||
|
|
||||||
|
const CONFIRM_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
}
|
}
|
||||||
@@ -85,6 +91,81 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
|
|||||||
return <span className="pill success">Established</span>;
|
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 {
|
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
||||||
if (!status) return 'Unknown';
|
if (!status) return 'Unknown';
|
||||||
if (!status.ready) return 'Browser unavailable';
|
if (!status.ready) return 'Browser unavailable';
|
||||||
@@ -130,8 +211,10 @@ export function AccountsPage() {
|
|||||||
? session.username ?? null
|
? session.username ?? null
|
||||||
: null;
|
: null;
|
||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
// Removed accounts are not paused work — nothing about them needs attention.
|
||||||
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
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 (
|
return (
|
||||||
<div className="page accounts-page">
|
<div className="page accounts-page">
|
||||||
@@ -211,21 +294,7 @@ export function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}>
|
<AccountRow account={account} 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>
|
|
||||||
))}
|
))}
|
||||||
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
.account-list { display: grid; gap: .6rem; }
|
.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 { 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-active { border-color: var(--accent); }
|
||||||
|
.account-row.is-removed { opacity: .72; }
|
||||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
.account-row .pill { font-size: .7rem; }
|
.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-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. */
|
/* 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); }
|
.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;
|
const mutate = markRead.mutate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
|
if (!conversation || conversation.readOnly) return;
|
||||||
|
if (newestTimestamp <= lastReadMessageTs) return;
|
||||||
const key = `${conversationId}:${newestTimestamp}`;
|
const key = `${conversationId}:${newestTimestamp}`;
|
||||||
if (markedRef.current === key) return;
|
if (markedRef.current === key) return;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -585,11 +586,13 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-detail-controls">
|
<div className="conversation-detail-controls">
|
||||||
<ConversationTriageActions
|
{conversation.readOnly ? null : (
|
||||||
conversation={conversation}
|
<ConversationTriageActions
|
||||||
conversationId={conversationId}
|
conversation={conversation}
|
||||||
onResolved={onClose}
|
conversationId={conversationId}
|
||||||
/>
|
onResolved={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} now={now} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
{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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,7 @@ function ConversationRow({
|
|||||||
if (isSelected) itemClasses.push('is-selected');
|
if (isSelected) itemClasses.push('is-selected');
|
||||||
if (unread) itemClasses.push('is-unread');
|
if (unread) itemClasses.push('is-unread');
|
||||||
if (isChecked) itemClasses.push('is-checked');
|
if (isChecked) itemClasses.push('is-checked');
|
||||||
|
if (conversation.readOnly) itemClasses.push('is-read-only');
|
||||||
|
|
||||||
const descriptors = [
|
const descriptors = [
|
||||||
redditUsernameLabel(conversation.username),
|
redditUsernameLabel(conversation.username),
|
||||||
@@ -134,6 +135,7 @@ function ConversationRow({
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
...(conversation.sellerDetected ? ['Seller'] : []),
|
...(conversation.sellerDetected ? ['Seller'] : []),
|
||||||
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||||
|
...(conversation.readOnly ? ['Removed account, read-only'] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +181,11 @@ function ConversationRow({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
{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.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||||
|
|||||||
@@ -96,6 +96,11 @@
|
|||||||
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
.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-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-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, .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; }
|
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||||
/* Column layout: history takes the slack, the composer stays reachable. */
|
/* 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';
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
|
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
|
||||||
@@ -12,6 +18,10 @@ export const learningApi = {
|
|||||||
getOverview(): Promise<LearningOverviewResponse> {
|
getOverview(): Promise<LearningOverviewResponse> {
|
||||||
return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema));
|
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 }> {
|
review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> {
|
||||||
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
|
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }),
|
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 { 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 { learningApi } from './learning-api.js';
|
||||||
import './learning.css';
|
import './learning.css';
|
||||||
|
|
||||||
const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']);
|
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() {
|
export function LearningPage() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
|
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
|
||||||
const review = useMutation({
|
const review = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status),
|
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">
|
<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>
|
<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> : (
|
{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) => {
|
||||||
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
|
const isOpen = expanded === item.id;
|
||||||
<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>
|
return [
|
||||||
</tr>
|
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
|
||||||
))}</tbody></table></div>
|
<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>,
|
||||||
|
...(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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -8,5 +8,25 @@
|
|||||||
.learning-table tr.human-led th:first-child { color: var(--accent); }
|
.learning-table tr.human-led th:first-child { color: var(--accent); }
|
||||||
.review-actions { display: flex; gap: .4rem; }
|
.review-actions { display: flex; gap: .4rem; }
|
||||||
.review-actions .button { padding: .4rem .6rem; }
|
.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: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
|
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ErrorResponseSchema,
|
ErrorResponseSchema,
|
||||||
LoginStatusResponseSchema,
|
LoginStatusResponseSchema,
|
||||||
RedditAccountsResponseSchema,
|
RedditAccountsResponseSchema,
|
||||||
|
RedditAccountRemovalResponseSchema,
|
||||||
RedditCookieImportRequestSchema,
|
RedditCookieImportRequestSchema,
|
||||||
RedditCookieImportResponseSchema,
|
RedditCookieImportResponseSchema,
|
||||||
type ConfigResponse,
|
type ConfigResponse,
|
||||||
@@ -52,6 +53,16 @@ export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
|
|||||||
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
|
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> {
|
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
|
||||||
const body = RedditCookieImportRequestSchema.parse(exported);
|
const body = RedditCookieImportRequestSchema.parse(exported);
|
||||||
const response = await parseResponse(
|
const response = await parseResponse(
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
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 {
|
import {
|
||||||
fetchPushNotifications,
|
fetchPushNotifications,
|
||||||
savePushNotifications,
|
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:
|
learning:
|
||||||
captureEnabled: true # Build an auditable dataset without changing messages
|
captureEnabled: true # Build an auditable dataset without changing messages
|
||||||
generationEnabled: false # Enable only after the candidate policy passes offline evaluation
|
generationEnabled: true # Retrieve eligible examples for reply generation
|
||||||
shadowMode: true # Record which examples would be used without injecting them
|
shadowMode: false # Inject the retrieved examples into prompts (true = audit only)
|
||||||
maturityHours: 12
|
maturityHours: 12
|
||||||
maxContextExamples: 3
|
maxContextExamples: 3
|
||||||
maxTurnsPerExample: 8
|
maxTurnsPerExample: 8
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ an administrator credential.
|
|||||||
| `GET` | `/api/stats` | Get bot statistics | read | no |
|
| `GET` | `/api/stats` | Get bot statistics | read | no |
|
||||||
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | 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` | 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 |
|
| `PATCH` | `/api/learning/trajectories/{id}` | Include or exclude a learning trajectory | admin | yes |
|
||||||
| `GET` | `/api/activity` | Get recent activity | read | no |
|
| `GET` | `/api/activity` | Get recent activity | read | no |
|
||||||
| `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | 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 |
|
| `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes |
|
||||||
| `GET` | `/api/accounts` | List Reddit accounts | read | no |
|
| `GET` | `/api/accounts` | List Reddit accounts | read | no |
|
||||||
| `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes |
|
| `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/logs/stream` | Stream legacy log events | read | no |
|
||||||
| `GET` | `/api/events/stream` | Stream typed application events | read | no |
|
| `GET` | `/api/events/stream` | Stream typed application events | read | no |
|
||||||
| `GET` | `/api/logs` | Get recent structured logs | 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.
|
1. Deploy with capture enabled, generation disabled, and shadow mode enabled.
|
||||||
2. Run a full chat sync to backfill authoritative histories.
|
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.
|
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.
|
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.
|
6. Enable generation while retaining shadow mode to exercise selection/auditing.
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const ActiveChatSchema = z.strictObject({
|
|||||||
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
||||||
/** Why automation handed over, when it has. */
|
/** Why automation handed over, when it has. */
|
||||||
automationHandoffReason: z.string().nullable().optional(),
|
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 ChatsResponseSchema = z.array(ActiveChatSchema);
|
||||||
export const ConversationsResponseSchema = ChatsResponseSchema;
|
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 LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
|
||||||
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) });
|
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 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(),
|
postImageAnalysis: z.string().optional(),
|
||||||
postCreatedAt: z.number().optional(),
|
postCreatedAt: z.number().optional(),
|
||||||
owner: RedditAccountOwnerSchema.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);
|
export const MessagesResponseSchema = z.array(SentMessageSchema);
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export const RedditAccountSummarySchema = z.strictObject({
|
|||||||
redditUsername: z.string().min(1),
|
redditUsername: z.string().min(1),
|
||||||
matrixUserId: z.string().min(1),
|
matrixUserId: z.string().min(1),
|
||||||
isRunning: z.boolean(),
|
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(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
lastImportedAt: 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. */
|
/** Absent on responses from a build that predates establishment tracking. */
|
||||||
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
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(true), account: RedditAccountSummarySchema }),
|
||||||
z.strictObject({ ok: z.literal(false), error: z.string() }),
|
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 RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
|
||||||
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
|
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 AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>;
|
||||||
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
|
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
|
||||||
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
|
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
|
||||||
|
export type RedditAccountRemovalResponse = z.infer<typeof RedditAccountRemovalResponseSchema>;
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import type Database from 'better-sqlite3';
|
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 {
|
export interface RedditAccountPublicRecord {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -12,6 +18,7 @@ export interface RedditAccountPublicRecord {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastVerifiedAt: number | null;
|
lastVerifiedAt: number | null;
|
||||||
lastImportedAt: number | null;
|
lastImportedAt: number | null;
|
||||||
|
removedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Private credential-bearing record. Never return this from a web API. */
|
/** Private credential-bearing record. Never return this from a web API. */
|
||||||
@@ -41,6 +48,7 @@ type AccountRow = {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
last_verified_at: number | null;
|
last_verified_at: number | null;
|
||||||
last_imported_at: number | null;
|
last_imported_at: number | null;
|
||||||
|
removed_at: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RedditAccountsRepository {
|
export class RedditAccountsRepository {
|
||||||
@@ -49,7 +57,7 @@ export class RedditAccountsRepository {
|
|||||||
listPublic(): RedditAccountPublicRecord[] {
|
listPublic(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
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
|
FROM reddit_accounts
|
||||||
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
||||||
`).all() as AccountRow[];
|
`).all() as AccountRow[];
|
||||||
@@ -66,7 +74,7 @@ export class RedditAccountsRepository {
|
|||||||
getActive(): RedditAccountPublicRecord[] {
|
getActive(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
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
|
FROM reddit_accounts
|
||||||
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
||||||
ORDER BY reddit_username COLLATE NOCASE, account_id
|
ORDER BY reddit_username COLLATE NOCASE, account_id
|
||||||
@@ -99,7 +107,8 @@ export class RedditAccountsRepository {
|
|||||||
matrix_credentials_json = excluded.matrix_credentials_json,
|
matrix_credentials_json = excluded.matrix_credentials_json,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
last_verified_at = excluded.last_verified_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 });
|
`).run({ ...input, now });
|
||||||
return publicFromPrivate(this.getRequired(input.accountId));
|
return publicFromPrivate(this.getRequired(input.accountId));
|
||||||
}
|
}
|
||||||
@@ -115,7 +124,7 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
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;
|
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,15 +133,31 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET is_active = ?, status = ?, updated_at = ?
|
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;
|
`).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 {
|
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET status = 'login-required', updated_at = ?
|
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;
|
`).run(now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +179,7 @@ function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
|
|||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
lastVerifiedAt: row.last_verified_at,
|
lastVerifiedAt: row.last_verified_at,
|
||||||
lastImportedAt: row.last_imported_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__';
|
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 {
|
export function createDatabase(config: Pick<Config, 'database'>): Database.Database {
|
||||||
const dbPath = config.database.path === ':memory:'
|
const dbPath = config.database.path === ':memory:'
|
||||||
? ':memory:'
|
? ':memory:'
|
||||||
@@ -772,6 +775,40 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
ON learning_evaluation_runs(created_at DESC);
|
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;
|
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) {
|
for (let index = currentVersion; index < migrations.length; index += 1) {
|
||||||
const version = index + 1;
|
const version = index + 1;
|
||||||
// SQLite cannot rebuild a referenced table while FK enforcement is on.
|
// SQLite cannot rebuild a referenced table while FK enforcement is on.
|
||||||
// Migration 11 replaces the account-produced table graph atomically, then
|
// Migration 11 replaces the account-produced table graph atomically and
|
||||||
// validates it before foreign-key enforcement is restored.
|
// migration 32 rebuilds `reddit_accounts` itself; both validate the graph
|
||||||
const rebuildsForeignKeyGraph = version === 11;
|
// before foreign-key enforcement is restored.
|
||||||
|
const rebuildsForeignKeyGraph = FOREIGN_KEY_REBUILD_VERSIONS.has(version);
|
||||||
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ export interface RedditAccountSummary {
|
|||||||
redditUsername: string;
|
redditUsername: string;
|
||||||
matrixUserId: string;
|
matrixUserId: string;
|
||||||
isRunning: boolean;
|
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;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastImportedAt: 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. */
|
/** Null until the establishment check has run for this account. */
|
||||||
establishment: RedditAccountEstablishmentSummary | null;
|
establishment: RedditAccountEstablishmentSummary | null;
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,11 @@ export class RedditAccountsService {
|
|||||||
return this.list().find((account) => account.accountId === accountId) ?? null;
|
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> {
|
async activate(accountId: string): Promise<RedditAccountSummary> {
|
||||||
const stored = this.accounts.get(accountId);
|
const stored = this.accounts.get(accountId);
|
||||||
if (!stored || stored.status === 'legacy-unassigned') {
|
if (!stored || stored.status === 'legacy-unassigned') {
|
||||||
@@ -90,6 +98,26 @@ export class RedditAccountsService {
|
|||||||
return this.importSession(cookies, credentials);
|
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> {
|
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
||||||
try {
|
try {
|
||||||
const verified = await this.importer.import(cookies, credentials);
|
const verified = await this.importer.import(cookies, credentials);
|
||||||
@@ -108,18 +136,23 @@ export class RedditAccountsService {
|
|||||||
|
|
||||||
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
||||||
const runtime = this.runtimes?.get(account.accountId);
|
const runtime = this.runtimes?.get(account.accountId);
|
||||||
const running = runtime?.snapshot().state === 'running';
|
const running = account.status === 'removed' ? false : runtime?.snapshot().state === 'running';
|
||||||
return {
|
return {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
redditUsername: account.redditUsername,
|
redditUsername: account.redditUsername,
|
||||||
matrixUserId: account.matrixUserId ?? account.accountId,
|
matrixUserId: account.matrixUserId ?? account.accountId,
|
||||||
isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
isRunning: account.status === 'removed'
|
||||||
status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
? false
|
||||||
? 'authenticated'
|
: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
||||||
: 'login-required',
|
status: account.status === 'removed'
|
||||||
|
? 'removed'
|
||||||
|
: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
||||||
|
? 'authenticated'
|
||||||
|
: 'login-required',
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
||||||
|
removedAt: account.removedAt,
|
||||||
establishment: this.toEstablishmentSummary(account.accountId),
|
establishment: this.toEstablishmentSummary(account.accountId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -20,7 +20,12 @@ import {
|
|||||||
GrowthSubredditCreateRequestSchema,
|
GrowthSubredditCreateRequestSchema,
|
||||||
GrowthSubredditUpdateRequestSchema,
|
GrowthSubredditUpdateRequestSchema,
|
||||||
} from './growth-contracts.js';
|
} 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 ApiAccess = 'read' | 'control' | 'admin';
|
||||||
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
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('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
|
||||||
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
|
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('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('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('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']),
|
||||||
operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['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('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']),
|
||||||
operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']),
|
operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']),
|
||||||
operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['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('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('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }),
|
||||||
operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']),
|
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 LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) });
|
||||||
export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) });
|
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),
|
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 & {
|
type OwnedActiveChat = ActiveChat & {
|
||||||
owner?: { accountId: string; redditUsername: string };
|
owner?: { accountId: string; redditUsername: string };
|
||||||
automationStatus?: ConversationAutomationStatus;
|
automationStatus?: ConversationAutomationStatus;
|
||||||
automationPolicyMode?: ConversationPolicyMode;
|
automationPolicyMode?: ConversationPolicyMode;
|
||||||
automationHandoffReason?: string | null;
|
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. */
|
/** An owned conversation with operator triage state projected onto it. */
|
||||||
@@ -119,12 +124,48 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
} catch { return null; }
|
} 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<{
|
async function findConversation(id: string): Promise<{
|
||||||
conversation: ActiveChat;
|
conversation: ActiveChat;
|
||||||
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
|
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> {
|
} | undefined> {
|
||||||
const owned = parseOwnedId(id);
|
const owned = parseOwnedId(id);
|
||||||
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
|
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;
|
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
|
||||||
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
||||||
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
@@ -137,6 +178,21 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return conversation ? { conversation, runtime } : undefined;
|
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(
|
function getConversationIntelligence(
|
||||||
conversation: ActiveChat,
|
conversation: ActiveChat,
|
||||||
stateRepo = ctx.services.conversationState,
|
stateRepo = ctx.services.conversationState,
|
||||||
@@ -231,6 +287,28 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
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') {
|
if (req.query.includeImageMetadata !== 'true') {
|
||||||
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
||||||
return;
|
return;
|
||||||
@@ -299,8 +377,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
? conversation.messages[conversation.messages.length - 1].timestamp
|
? conversation.messages[conversation.messages.length - 1].timestamp
|
||||||
: 0;
|
: 0;
|
||||||
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
||||||
const needsLazyLoad =
|
// A retired conversation is whatever was captured before removal. There
|
||||||
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
|
// 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) {
|
if (needsLazyLoad && !syncInFlight) {
|
||||||
try {
|
try {
|
||||||
await chatService.loadConversationDetails(conversation);
|
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);
|
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
const stateRepo = resolved.readOnly
|
||||||
const intelligence = getConversationIntelligence(conversation, stateRepo);
|
? undefined
|
||||||
|
: resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||||
|
const intelligence = resolved.readOnly
|
||||||
|
? archivedIntelligence(conversation)
|
||||||
|
: getConversationIntelligence(conversation, stateRepo);
|
||||||
const receivedImage = (conversation.messages ?? []).some(
|
const receivedImage = (conversation.messages ?? []).some(
|
||||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
@@ -375,7 +460,9 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return { ...message, isAi: true };
|
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();
|
readStateRepo?.expireSnoozes();
|
||||||
const triage = annotateTriage(
|
const triage = annotateTriage(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -394,9 +481,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
||||||
sellerDetected: intelligence.state.sellerDetected,
|
sellerDetected: intelligence.state.sellerDetected,
|
||||||
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||||
owner: resolved.runtime
|
owner: resolved.readOnly
|
||||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
?? (resolved.runtime
|
||||||
: currentOwner(),
|
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||||
|
: currentOwner()),
|
||||||
|
...(resolved.readOnly ? { readOnly: true } : {}),
|
||||||
triggeringPost,
|
triggeringPost,
|
||||||
automation: {
|
automation: {
|
||||||
turnsUsed: intelligence.state.automation.turnsUsed,
|
turnsUsed: intelligence.state.automation.turnsUsed,
|
||||||
@@ -433,6 +522,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
if (!conversation.messages || conversation.messages.length === 0) {
|
||||||
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
|
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' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
if (!conversation.messages || conversation.messages.length === 0) {
|
||||||
res.status(400).json({ error: 'No messages in conversation to generate from' });
|
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' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
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));
|
const resolved = await findConversation(decodeURIComponent(id));
|
||||||
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
|
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;
|
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
||||||
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
||||||
if (!resolved.conversation.roomId) {
|
if (!resolved.conversation.roomId) {
|
||||||
@@ -828,6 +930,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
if (!conversation.roomId) {
|
if (!conversation.roomId) {
|
||||||
return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
|
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
|
// Full manual control: every enabled, unused library image is offered
|
||||||
// regardless of what (if anything) the latest message asked for. Safety
|
// regardless of what (if anything) the latest message asked for. Safety
|
||||||
// flags surface as a non-blocking warning, never a gate.
|
// flags surface as a non-blocking warning, never a gate.
|
||||||
@@ -853,6 +959,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
try {
|
try {
|
||||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||||
if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
|
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 runtime = resolved.runtime;
|
||||||
const exchange = runtime?.resources.mediaExchange;
|
const exchange = runtime?.resources.mediaExchange;
|
||||||
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
|
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) => {
|
router.patch('/learning/trajectories/:id', (req, res) => {
|
||||||
const parsed = LearningReviewRequestSchema.safeParse(req.body);
|
const parsed = LearningReviewRequestSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -334,10 +334,10 @@ describe('browsing migration', () => {
|
|||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||||
// Five browsing migrations plus the later conversation-learning migration.
|
// Absolute, not counted back from the head: browsing's first migration is
|
||||||
db.pragma(`user_version = ${version - 6}`);
|
// 26, and a relative rewind breaks whenever a migration is appended.
|
||||||
|
db.pragma('user_version = 25');
|
||||||
|
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
insertAccount(db, ACCOUNT, 'owner');
|
insertAccount(db, ACCOUNT, 'owner');
|
||||||
|
|||||||
@@ -134,6 +134,93 @@ describe('multi-account database ownership', () => {
|
|||||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
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', () => {
|
it('scopes legacy account-produced table uniqueness by account', () => {
|
||||||
addAccount('t2_alice', 'alice-bot', 100);
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
addAccount('t2_bob', 'bob-bot', 110);
|
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
|
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||||||
// must rewind past both the growth sequence and the later browsing additions.
|
// must rewind past both the growth sequence and the later browsing additions.
|
||||||
const GROWTH_MIGRATION_COUNT = 7;
|
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 {
|
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||||
return {
|
return {
|
||||||
@@ -507,14 +509,16 @@ describe('AccountGrowthRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('growth migration', () => {
|
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 {
|
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_comments');
|
||||||
db.exec('DROP TABLE account_growth_subreddits');
|
db.exec('DROP TABLE account_growth_subreddits');
|
||||||
db.exec('DROP TABLE account_growth_settings');
|
db.exec('DROP TABLE account_growth_settings');
|
||||||
// Also rewind the later conversation-learning migration.
|
db.pragma(`user_version = ${FIRST_GROWTH_MIGRATION - 1}`);
|
||||||
db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT - 1}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('upgrades a database created before growth existed', () => {
|
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);
|
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', () => {
|
it('classifies Reddit navigation failures as unreachable', () => {
|
||||||
expect(
|
expect(
|
||||||
classifyRedditSessionError(
|
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 express from 'express';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
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 type { WebContext } from '../../src/web/context.js';
|
||||||
import { createStatsRouter } from '../../src/web/routes/stats.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');
|
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