Shape audit (milestone 296): tests helpers, alembic, models, MCP, routes, services, frontend consolidation #120

Merged
bvandeusen merged 8 commits from dev into main 2026-08-21 12:48:09 -04:00
119 changed files with 1396 additions and 2422 deletions
+14
View File
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path); const res = await fetch(path);
return handleResponse<T>(res, path); return handleResponse<T>(res, path);
+115
View File
@@ -0,0 +1,115 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+76
View File
@@ -221,3 +221,79 @@
is the page's main action */ is the page's main action */
font-size: var(--fs-size-body-sm); font-size: var(--fs-size-body-sm);
} }
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
-47
View File
@@ -316,53 +316,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--fs-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--fs-error);
color: var(--fs-text-on-action);
border-color: var(--fs-error);
}
/* ── Floating inline assist button (teleported to body) ── */ /* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn { .inline-assist-btn {
position: fixed; position: fixed;
+2 -9
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client"; import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue"; import DiffView from "@/components/DiffView.vue";
import type { DiffLine } from "@/composables/useAssist"; import type { DiffLine } from "@/composables/useAssist";
import { fmtStamp } from "@/utils/dateFormat";
interface NoteVersion { interface NoteVersion {
id: number; id: number;
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
return result; return result;
}); });
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
async function loadVersions() { async function loadVersions() {
loading.value = true; loading.value = true;
try { try {
@@ -212,7 +205,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label" v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label" class="history-item-label"
>{{ v.pin_label }}</div> >{{ v.pin_label }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div> <div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
</div> </div>
</div> </div>
@@ -522,29 +522,4 @@ async function confirmDelete() {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 200; z-index: 200;
} }
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
</style> </style>
+2 -8
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import type { TaskLog } from "@/types/task"; import type { TaskLog } from "@/types/task";
import { fmtStamp } from "@/utils/dateFormat";
const props = defineProps<{ taskId: number }>(); const props = defineProps<{ taskId: number }>();
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
const editContent = ref(""); const editContent = ref("");
const editDuration = ref(""); const editDuration = ref("");
function formatDate(iso: string): string {
const d = new Date(iso);
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return `${datePart}, ${timePart}`;
}
function formatDuration(minutes: number): string { function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`; if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60); const h = Math.floor(minutes / 60);
@@ -128,7 +122,7 @@ onMounted(loadLogs);
</template> </template>
<template v-else> <template v-else>
<div class="log-entry-meta"> <div class="log-entry-meta">
<span class="log-date">{{ formatDate(log.created_at) }}</span> <span class="log-date">{{ fmtStamp(log.created_at) }}</span>
<span v-if="log.duration_minutes" class="log-duration-badge"> <span v-if="log.duration_minutes" class="log-duration-badge">
{{ formatDuration(log.duration_minutes) }} {{ formatDuration(log.duration_minutes) }}
</span> </span>
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue"; import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ const props = defineProps<{
projectId: number; projectId: number;
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
} }
} }
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; }); watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); }); watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; }); watch(noteTags, () => { dirty.value = true; });
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
> >
<div class="note-row-main"> <div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span> <span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span> <span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
</div> </div>
<div v-if="note.tags?.length" class="note-row-tags"> <div v-if="note.tags?.length" class="note-row-tags">
<span <span
+3 -16
View File
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
deleteConfirmPending.value = false; deleteConfirmPending.value = false;
} }
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
onMounted(loadAll); onMounted(loadAll);
defineExpose({ reload: loadAll }); defineExpose({ reload: loadAll });
</script> </script>
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span> <span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span> <span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li> <li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul> </ul>
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span> <span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span> <span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li> <li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul> </ul>
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
return `${days}d ago`; return `${days}d ago`;
} }
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
if (days < 7) return relativeTime(iso);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+24 -57
View File
@@ -1,65 +1,32 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/** /**
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM" * Shared date/time formatting — one rule per display shape. Views import
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15" * these instead of carrying a local formatDate(): the 2026-08 shape audit
* found eight copies across views/components, three of them byte-identical.
* (The previous Calendar/Home helpers in this file had no callers left and
* were removed in the same pass.)
*
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
*/ */
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}` /** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
export function fmtDate(iso: string): string {
if (_isSameDay(d, now)) return `Today${timeStr}` return new Date(iso).toLocaleDateString(undefined, {
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}` year: "numeric", month: "short", day: "numeric",
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr });
} catch {
return dt
}
} }
/** /** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15" export function fmtStamp(iso: string): string {
*/ return new Date(iso).toLocaleString(undefined, {
export function fmtDayLabel(dt: string): string { month: "short", day: "numeric", year: "numeric",
try { hour: "2-digit", minute: "2-digit",
const d = new Date(dt) });
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
if (_isSameDay(d, now)) return "Today"
if (_isSameDay(d, tomorrow)) return "Tomorrow"
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
} catch {
return dt
}
} }
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */ /** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
export function fmtCompact(dt: string, allDay: boolean): string { export function fmtLogStamp(iso: string): string {
const d = new Date(dt) return new Date(iso).toLocaleString(undefined, {
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) month: "short", day: "numeric",
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) hour: "2-digit", minute: "2-digit", second: "2-digit",
});
} }
+4 -88
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { apiPost } from "@/api/client"; import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
const email = ref(""); const email = ref("");
@@ -15,12 +15,7 @@ async function handleSubmit() {
await apiPost("/api/auth/forgot-password", { email: email.value }); await apiPost("/api/auth/forgot-password", { email: email.value });
submitted.value = true; submitted.value = true;
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Something went wrong");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Something went wrong";
} else {
error.value = "Something went wrong";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -55,7 +50,7 @@ async function handleSubmit() {
</form> </form>
</template> </template>
<div v-else class="success-msg"> <div v-else class="auth-note">
<p>If an account exists with that email address, you will receive a password reset link shortly.</p> <p>If an account exists with that email address, you will receive a password reset link shortly.</p>
<p>Check your email and follow the instructions to reset your password.</p> <p>Check your email and follow the instructions to reset your password.</p>
</div> </div>
@@ -67,83 +62,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+3 -78
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -30,12 +31,7 @@ async function handleSubmit() {
const redirect = (route.query.redirect as string) || "/"; const redirect = (route.query.redirect as string) || "/";
router.push(redirect); router.push(redirect);
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Login failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -112,70 +108,8 @@ function loginWithOAuth() {
</main> </main>
</template> </template>
<style src="@/assets/auth-shared.css" />
<style scoped> <style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.divider { .divider {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -190,15 +124,6 @@ function loginWithOAuth() {
flex: 1; flex: 1;
border-top: 1px solid var(--fs-border-color); border-top: 1px solid var(--fs-border-color);
} }
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
.forgot-link { .forgot-link {
text-align: right; text-align: right;
margin: -0.5rem 0 0.75rem; margin: -0.5rem 0 0.75rem;
+2 -12
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import { fmtLogStamp } from "@/utils/dateFormat";
const toastStore = useToastStore(); const toastStore = useToastStore();
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id; expandedId.value = expandedId.value === id ? null : id;
} }
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDetails(details: string | null): string { function formatDetails(details: string | null): string {
if (!details) return ""; if (!details) return "";
try { try {
@@ -210,7 +200,7 @@ function clearFilters() {
:class="{ 'row-expanded': expandedId === entry.id }" :class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)" @click="toggleExpand(entry.id)"
> >
<td class="cell-time">{{ formatTime(entry.created_at) }}</td> <td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td> <td>
<span class="category-badge" :class="'cat-' + entry.category"> <span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }} {{ entry.category }}
-36
View File
@@ -570,13 +570,7 @@ function overallPct(project: Project): { total: number; pct: number } {
z-index: 200; z-index: 200;
} }
.modal-card { .modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -618,36 +612,6 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-textarea { .modal-textarea {
resize: vertical; resize: vertical;
} }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.projects-grid { .projects-grid {
-25
View File
@@ -1815,31 +1815,6 @@ async function confirmDelete() {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 200; z-index: 200;
} }
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
/* ── Skeleton ────────────────────────────────────────────────── */ /* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } } @keyframes skel-shine { to { background-position: 200% center; } }
+5 -109
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client"; import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
@@ -68,12 +68,7 @@ async function handleSubmit() {
await authStore.checkAuth(); await authStore.checkAuth();
router.push("/"); router.push("/");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Registration failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -85,9 +80,9 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div> <div v-if="validating" class="auth-loading">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block"> <div v-else-if="!token || !valid" class="auth-note">
<p>This invitation link is invalid or has expired.</p> <p>This invitation link is invalid or has expired.</p>
<p class="auth-footer"> <p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link> <router-link to="/login">Back to Sign In</router-link>
@@ -157,103 +152,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+5 -104
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -39,12 +40,7 @@ async function handleSubmit() {
await authStore.register(username.value, password.value, email.value || undefined); await authStore.register(username.value, password.value, email.value || undefined);
router.push("/"); router.push("/");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Registration failed");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -56,9 +52,9 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<div v-if="checking" class="loading-msg">Checking registration status...</div> <div v-if="checking" class="auth-loading">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="closed-msg"> <div v-else-if="!authStore.registrationOpen" class="auth-note">
<p>Registration is currently closed.</p> <p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p> <p>Contact an administrator to get an account.</p>
<p class="auth-footer"> <p class="auth-footer">
@@ -130,99 +126,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.closed-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+5 -108
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { apiPost } from "@/api/client"; import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
const route = useRoute(); const route = useRoute();
@@ -35,12 +35,7 @@ async function handleSubmit() {
}); });
success.value = true; success.value = true;
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { error.value = apiErrorMessage(e, "Failed to reset password");
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Failed to reset password";
} else {
error.value = "Failed to reset password";
}
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -52,7 +47,7 @@ async function handleSubmit() {
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div> <div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
<div v-if="!token" class="error-block"> <div v-if="!token" class="auth-note">
<p>Invalid reset link. Please request a new password reset.</p> <p>Invalid reset link. Please request a new password reset.</p>
<p class="auth-footer"> <p class="auth-footer">
<router-link to="/forgot-password">Request new link</router-link> <router-link to="/forgot-password">Request new link</router-link>
@@ -94,7 +89,7 @@ async function handleSubmit() {
</form> </form>
</template> </template>
<div v-else class="success-msg"> <div v-else class="auth-note">
<p>Your password has been reset successfully.</p> <p>Your password has been reset successfully.</p>
<p>You can now sign in with your new password.</p> <p>You can now sign in with your new password.</p>
</div> </div>
@@ -106,102 +101,4 @@ async function handleSubmit() {
</main> </main>
</template> </template>
<style scoped> <style src="@/assets/auth-shared.css" />
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
+9 -37
View File
@@ -3,10 +3,11 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
const store = useSettingsStore(); const store = useSettingsStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -624,12 +625,7 @@ async function changeEmail() {
emailPassword.value = ""; emailPassword.value = "";
toastStore.show("Email updated successfully"); toastStore.show("Email updated successfully");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
} finally { } finally {
changingEmail.value = false; changingEmail.value = false;
} }
@@ -663,12 +659,7 @@ async function changePassword() {
newPassword.value = ""; newPassword.value = "";
confirmNewPassword.value = ""; confirmNewPassword.value = "";
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
} finally { } finally {
changingPassword.value = false; changingPassword.value = false;
} }
@@ -766,12 +757,7 @@ async function sendTestEmail() {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() }); await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully"); toastStore.show("Test email sent successfully");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
} finally { } finally {
sendingTest.value = false; sendingTest.value = false;
} }
@@ -1129,14 +1115,6 @@ function toggleLogExpand(id: number) {
expandedLogId.value = expandedLogId.value === id ? null : id; expandedLogId.value = expandedLogId.value === id ? null : id;
} }
function formatLogTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function formatLogDetails(details: string | null): string { function formatLogDetails(details: string | null): string {
if (!details) return ""; if (!details) return "";
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; } try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
@@ -1224,12 +1202,6 @@ async function deleteUser(userId: number) {
deleting.value = null; deleting.value = null;
} }
} }
function formatUserDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
</script> </script>
<template> <template>
@@ -2355,8 +2327,8 @@ function formatUserDate(iso: string): string {
<tbody> <tbody>
<tr v-for="inv in invitations" :key="inv.id"> <tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td> <td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null"> <button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }} {{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
@@ -2391,7 +2363,7 @@ function formatUserDate(iso: string): string {
{{ u.role }} {{ u.role }}
</span> </span>
</td> </td>
<td class="hide-mobile cell-date">{{ formatUserDate(u.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<template v-if="u.id === authStore.user?.id"> <template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span> <span class="you-label">You</span>
@@ -2474,7 +2446,7 @@ function formatUserDate(iso: string): string {
<tbody> <tbody>
<template v-for="entry in logs" :key="entry.id"> <template v-for="entry in logs" :key="entry.id">
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)"> <tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
<td class="cell-time">{{ formatLogTime(entry.created_at) }}</td> <td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td> <td>
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span> <span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
</td> </td>
-36
View File
@@ -913,13 +913,7 @@ function usageTitle(s: SnippetListItem): string {
z-index: 200; z-index: 200;
} }
.modal-card { .modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 460px; max-width: 460px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -966,36 +960,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.snippets-grid { .snippets-grid {
+7 -24
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
import { fmtDate } from "@/utils/dateFormat";
interface Invitation { interface Invitation {
id: number; id: number;
@@ -69,12 +70,7 @@ async function sendInvite() {
inviteEmail.value = ""; inviteEmail.value = "";
await fetchInvitations(); await fetchInvitations();
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
} finally { } finally {
sendingInvite.value = false; sendingInvite.value = false;
} }
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId); users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted"); toastStore.show("User deleted");
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) { toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete user", "error");
} else {
toastStore.show("Failed to delete user", "error");
}
} finally { } finally {
deleting.value = null; deleting.value = null;
} }
} }
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script> </script>
<template> <template>
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
<tbody> <tbody>
<tr v-for="inv in invitations" :key="inv.id"> <tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td> <td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<button <button
class="btn-ghost btn-compact" class="btn-ghost btn-compact"
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
{{ u.role }} {{ u.role }}
</span> </span>
</td> </td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td> <td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions"> <td class="cell-actions">
<template v-if="u.id === authStore.user?.id"> <template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span> <span class="you-label">You</span>
-14
View File
@@ -4,20 +4,6 @@ from __future__ import annotations
from scribe.services.api_keys import lookup_key from scribe.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
Returns None if the header is missing, malformed, or the token is invalid
or revoked. The underlying lookup_key already updates last_used_at on hit.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
return api_key.user_id if api_key else None
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None: async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
"""Resolve a Bearer token to (user_id, scope). """Resolve a Bearer token to (user_id, scope).
+1 -3
View File
@@ -57,9 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
return { return {
"milestone": out, "milestone": out,
"steps": [t.to_dict() for t in steps], "steps": [t.to_dict() for t in steps],
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
} }
+3 -27
View File
@@ -62,30 +62,6 @@ async def list_notes(
return {"notes": [n.to_dict() for n in rows], "total": total} return {"notes": [n.to_dict() for n in rows], "total": total}
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Add both directions of the supersession relation to a note payload.
Both, because they answer different questions and only one of them is
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
READER needs and what the note itself cannot know — a stale record handed
over without that marker gets acted on confidently, which is worse than
never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
permanently-empty lists. A field that always says nothing trains readers to
skip fields, which is the lesson `consolidated_at` cost us (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
data["superseded_note"] = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def get_note(note_id: int) -> dict: async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Scribe note by its ID. """Fetch the full content of a single Scribe note by its ID.
@@ -113,7 +89,7 @@ async def get_note(note_id: int) -> dict:
# snippets would leave those permanently at zero pulls and make them look # snippets would leave those permanently at zero pulls and make them look
# like dead weight next to snippets that merely had a counter (#2085). # like dead weight next to snippets that merely had a counter (#2085).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note") record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
await _attach_supersession(uid, note_id, out) await supersession_svc.attach_relations(uid, note_id, out, hint=True)
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
) )
@@ -186,7 +162,7 @@ async def create_note(
raise ValueError(str(exc)) from exc raise ValueError(str(exc)) from exc
data = note.to_dict() data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await _attach_supersession(uid, note.id, data) await supersession_svc.attach_relations(uid, note.id, data, hint=True)
return data return data
@@ -237,7 +213,7 @@ async def update_note(
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
) )
await _attach_supersession(uid, note_id, data) await supersession_svc.attach_relations(uid, note_id, data, hint=True)
return data return data
+2 -12
View File
@@ -192,12 +192,7 @@ async def enter_project(project_id: int) -> dict:
], ],
"design_system": design_system, "design_system": design_system,
"milestone_summary": milestone_summary, "milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"open_tasks": [ "open_tasks": [
{ {
"id": t.id, "title": t.title, "status": t.status, "id": t.id, "title": t.title, "status": t.status,
@@ -239,12 +234,7 @@ async def get_project(project_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules( applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid, project_id=project_id, user_id=uid,
) )
data["applicable_rules"] = applicable["rules"] data.update(rulebooks_svc.rules_payload(applicable))
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data return data
+8 -20
View File
@@ -193,6 +193,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
# ── Rule CRUD ────────────────────────────────────────────────────────── # ── Rule CRUD ──────────────────────────────────────────────────────────
def _rule_summary(r) -> dict:
"""The list-row shape for a rule: what an agent needs to APPLY it. The
full record (why, how_to_apply, timestamps) is get_rule's job."""
return {"id": r.id, "title": r.title, "statement": r.statement, "topic_id": r.topic_id}
async def list_rules( async def list_rules(
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0, rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
) -> dict: ) -> dict:
@@ -213,16 +219,7 @@ async def list_rules(
topic_id=topic_id or None, topic_id=topic_id or None,
project_id=project_id or None, project_id=project_id or None,
) )
return { return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rows
],
"total": len(rows),
}
async def list_always_on_rules() -> dict: async def list_always_on_rules() -> dict:
@@ -235,16 +232,7 @@ async def list_always_on_rules() -> dict:
""" """
uid = current_user_id() uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid) rules = await rulebooks_svc.list_always_on_rules(uid)
return { return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
async def get_rule(rule_id: int) -> dict: async def get_rule(rule_id: int) -> dict:
+1 -1
View File
@@ -134,7 +134,7 @@ async def attach_systems(
tagged record shows its areas (the touching-a-System reflex needs the tagged record shows its areas (the touching-a-System reflex needs the
affiliation visible on read, not just settable on write), an untagged affiliation visible on read, not just settable on write), an untagged
project record carries the question instead. Neither field is ever project record carries the question instead. Neither field is ever
attached empty (same reasoning as notes._attach_supersession / #2483 — a attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
field that always says nothing trains readers to skip fields). The hint field that always says nothing trains readers to skip fields). The hint
goes only to the record's owner: tagging someone else's record in someone goes only to the record's owner: tagging someone else's record in someone
else's project is not the caller's call to make. Fail-open — decoration else's project is not the caller's call to make. Fail-open — decoration
+1 -6
View File
@@ -97,12 +97,7 @@ async def get_task(task_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules( applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid, project_id=note.project_id, user_id=uid,
) )
data["applicable_rules"] = applicable["rules"] data.update(rulebooks_svc.rules_payload(applicable))
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
data.update(await access_svc.describe_provenance(uid, note)) data.update(await access_svc.describe_provenance(uid, note))
# Same reasoning as get_note's record_pulled, and this is the tool where it # Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is # matters MOST: auto-inject ranks kind-blind over a corpus that is
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin):
"name": self.name, "name": self.name,
"key_prefix": self.key_prefix, "key_prefix": self.key_prefix,
"scope": self.scope, "scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, "last_used_at": iso(self.last_used_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, "revoked_at": iso(self.revoked_at),
} }
+5 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class AppLog(Base): class AppLog(Base):
@@ -20,6 +21,9 @@ class AppLog(Base):
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True) duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True) ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True) details: Mapped[str | None] = mapped_column(Text, nullable=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -44,5 +48,5 @@ class AppLog(Base):
"duration_ms": self.duration_ms, "duration_ms": self.duration_ms,
"ip_address": self.ip_address, "ip_address": self.ip_address,
"details": self.details, "details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+11 -1
View File
@@ -1,9 +1,19 @@
from datetime import datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import DateTime, Text from sqlalchemy import DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
def iso(value: datetime | date | None) -> str | None:
"""ISO-8601 for a payload, None for an unset column.
Every model's to_dict serialises timestamps through this one helper so a
row read before flush (created_at still None) and a nullable column both
come out as null instead of raising on `.isoformat()`.
"""
return value.isoformat() if value else None
class SoftDeleteMixin: class SoftDeleteMixin:
"""Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id """Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id
groups rows soft-deleted in one operation so a cascade restores as a unit.""" groups rows soft-deleted in one operation so a cascade restores as a unit."""
+7 -7
View File
@@ -13,7 +13,7 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
# The classification vocabulary (note 2786). `unclassified` is the default and # The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it. # THE todo state; every other status is a judgment, stamped with who made it.
@@ -153,18 +153,18 @@ class CodeShape(Base, TimestampMixin):
"snippet_id": self.snippet_id, "snippet_id": self.snippet_id,
"reason": self.reason, "reason": self.reason,
"classified_by": self.classified_by, "classified_by": self.classified_by,
"classified_at": self.classified_at.isoformat() if self.classified_at else None, "classified_at": iso(self.classified_at),
"first_seen_commit": self.first_seen_commit, "first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit, "last_seen_commit": self.last_seen_commit,
"vanished_at": self.vanished_at.isoformat() if self.vanished_at else None, "vanished_at": iso(self.vanished_at),
"signature": self.signature, "signature": self.signature,
"body_sha": self.body_sha, "body_sha": self.body_sha,
"proposal": self.proposal, "proposal": self.proposal,
"classified_sha": self.classified_sha, "classified_sha": self.classified_sha,
"recheck_at": self.recheck_at.isoformat() if self.recheck_at else None, "recheck_at": iso(self.recheck_at),
"diverges_from": self.diverges_from, "diverges_from": self.diverges_from,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -221,5 +221,5 @@ class CodeShapeEvent(Base):
"classified_by": self.classified_by, "classified_by": self.classified_by,
"reason": self.reason, "reason": self.reason,
"commit": self.commit, "commit": self.commit,
"at": self.at.isoformat(), "at": iso(self.at),
} }
+5 -5
View File
@@ -20,7 +20,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin): class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
@@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
"description": self.description or "", "description": self.description or "",
"guidance": self.guidance or "", "guidance": self.guidance or "",
"parent_id": self.parent_id, "parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
@@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"rationale": self.rationale, "rationale": self.rationale,
"supersedes": self.supersedes or [], "supersedes": self.supersedes or [],
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin): class ForgeConnection(Base, TimestampMixin):
@@ -40,6 +40,6 @@ class ForgeConnection(Base, TimestampMixin):
"kind": self.kind, "kind": self.kind,
"base_url": self.base_url, "base_url": self.base_url,
"host": self.host, "host": self.host,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin from scribe.models.base import CreatedAtMixin, TimestampMixin, iso
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -27,8 +27,8 @@ class Group(Base, TimestampMixin):
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin):
"group_id": self.group_id, "group_id": self.group_id,
"user_id": self.user_id, "user_id": self.user_id,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class InvitationToken(Base): class InvitationToken(Base, CreatedAtMixin):
__tablename__ = "invitation_tokens" __tablename__ = "invitation_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +16,6 @@ class InvitationToken(Base):
invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_invitation_tokens_token_hash", "token_hash"), Index("ix_invitation_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Milestone(Base, TimestampMixin, SoftDeleteMixin): class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"body": self.body, "body": self.body,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+7 -11
View File
@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"milestone_id": self.milestone_id, "milestone_id": self.milestone_id,
"status": self.status, "status": self.status,
"priority": self.priority, "priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None, "due_date": iso(self.due_date),
"started_at": self.started_at.isoformat() if self.started_at else None, "started_at": iso(self.started_at),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": iso(self.completed_at),
"recurrence_rule": self.recurrence_rule, "recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": ( "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task, "is_task": self.is_task,
"note_type": self.note_type or "note", "note_type": self.note_type or "note",
"task_kind": self.task_kind, "task_kind": self.task_kind,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class NoteDraft(Base, TimestampMixin): class NoteDraft(Base, TimestampMixin):
@@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin):
"original_body": self.original_body, "original_body": self.original_body,
"instruction": self.instruction, "instruction": self.instruction,
"scope": self.scope, "scope": self.scope,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteSupersession(Base, CreatedAtMixin): class NoteSupersession(Base, CreatedAtMixin):
@@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin):
"id": self.id, "id": self.id,
"superseder_id": self.superseder_id, "superseder_id": self.superseder_id,
"superseded_id": self.superseded_id, "superseded_id": self.superseded_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+4 -8
View File
@@ -1,15 +1,14 @@
from datetime import datetime, timezone from sqlalchemy import Index, Integer, Text
from sqlalchemy import DateTime, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced" SURFACED = "surfaced"
PULLED = "pulled" PULLED = "pulled"
class NoteUsageEvent(Base): class NoteUsageEvent(Base, CreatedAtMixin):
"""One row per time a note was SURFACED to the agent, or PULLED in full. """One row per time a note was SURFACED to the agent, or PULLED in full.
Answers the question RetrievalLog cannot: not "what did the ranker return Answers the question RetrievalLog cannot: not "what did the ranker return
@@ -44,9 +43,6 @@ class NoteUsageEvent(Base):
__tablename__ = "note_usage_events" __tablename__ = "note_usage_events"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
note_id: Mapped[int] = mapped_column(Integer, nullable=False) note_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 'surfaced' | 'pulled' # 'surfaced' | 'pulled'
@@ -81,7 +77,7 @@ class NoteUsageEvent(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"note_id": self.note_id, "note_id": self.note_id,
"event": self.event, "event": self.event,
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteVersion(Base, CreatedAtMixin): class NoteVersion(Base, CreatedAtMixin):
@@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin):
"tags": self.tags or [], "tags": self.tags or [],
"pin_kind": self.pin_kind, "pin_kind": self.pin_kind,
"pin_label": self.pin_label, "pin_label": self.pin_label,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
if include_body: if include_body:
d["body"] = self.body d["body"] = self.body
+3 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class Notification(Base, CreatedAtMixin): class Notification(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin):
"user_id": self.user_id, "user_id": self.user_id,
"type": self.type, "type": self.type,
"payload": self.payload, "payload": self.payload,
"read_at": self.read_at.isoformat() if self.read_at else None, "read_at": iso(self.read_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class PasswordResetToken(Base): class PasswordResetToken(Base, CreatedAtMixin):
__tablename__ = "password_reset_tokens" __tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,9 +15,6 @@ class PasswordResetToken(Base):
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_password_reset_tokens_token_hash", "token_hash"), Index("ix_password_reset_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -48,6 +48,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"design_system_id": self.design_system_id, "design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id, "forge_connection_id": self.forge_connection_id,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class RepoBinding(Base, TimestampMixin): class RepoBinding(Base, TimestampMixin):
@@ -35,6 +35,6 @@ class RepoBinding(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"project_id": self.project_id, "project_id": self.project_id,
"repo_key": self.repo_key, "repo_key": self.repo_key,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+5 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class RetrievalLog(Base): class RetrievalLog(Base):
@@ -23,6 +24,9 @@ class RetrievalLog(Base):
__tablename__ = "retrieval_logs" __tablename__ = "retrieval_logs"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -54,7 +58,7 @@ class RetrievalLog(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"source": self.source, "source": self.source,
"query": self.query, "query": self.query,
+10 -34
View File
@@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index,
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebooks" __tablename__ = "rulebooks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin):
always_on: Mapped[bool] = mapped_column( always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false" Boolean, default=False, nullable=False, server_default="false"
) )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"always_on": self.always_on, "always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class RulebookTopic(Base, SoftDeleteMixin): class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebook_topics" __tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a # Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title. # trashed topic doesn't block recreating/restoring the same title.
@@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin):
title: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class Rule(Base, SoftDeleteMixin): class Rule(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rules" __tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted # Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title). # rules don't block recreating/restoring the same title).
@@ -109,14 +93,6 @@ class Rule(Base, SoftDeleteMixin):
why: Mapped[str | None] = mapped_column(Text, nullable=True) why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin):
"why": self.why or "", "why": self.why or "",
"how_to_apply": self.how_to_apply or "", "how_to_apply": self.how_to_apply or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+5 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ProjectShare(Base, TimestampMixin): class ProjectShare(Base, TimestampMixin):
@@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin, SoftDeleteMixin from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
class System(Base, TimestampMixin, SoftDeleteMixin): class System(Base, TimestampMixin, SoftDeleteMixin):
@@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class TaskLog(Base, TimestampMixin): class TaskLog(Base, TimestampMixin):
@@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"content": self.content, "content": self.content,
"duration_minutes": self.duration_minutes, "duration_minutes": self.duration_minutes,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin):
"username": self.username, "username": self.username,
"email": self.email, "email": self.email,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"has_password": self.password_hash is not None, "has_password": self.password_hash is not None,
} }
+5 -7
View File
@@ -22,6 +22,7 @@ from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_conf
from scribe.services.logging import get_logs, get_log_stats, log_audit from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email from scribe.services.notifications import send_invitation_email
from scribe.services.settings import ( from scribe.services.settings import (
SECRET_MASK,
get_admin_setting, get_admin_setting,
set_admin_setting, set_admin_setting,
set_setting, set_setting,
@@ -116,7 +117,7 @@ async def get_smtp():
config = await get_smtp_config() config = await get_smtp_config()
# Mask password # Mask password
if config.get("smtp_password"): if config.get("smtp_password"):
config["smtp_password"] = "********" config["smtp_password"] = SECRET_MASK
return jsonify(config) return jsonify(config)
@@ -130,7 +131,7 @@ async def update_smtp():
for key in SMTP_SETTING_KEYS: for key in SMTP_SETTING_KEYS:
if key in data: if key in data:
# Skip password if it's the mask placeholder # Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********": if key == "smtp_password" and data[key] == SECRET_MASK:
continue continue
settings_to_save[key] = str(data[key]) settings_to_save[key] = str(data[key])
@@ -157,9 +158,6 @@ async def test_smtp():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
_TOKEN_MASK = "********"
# The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git # The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint # forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users. # is one URL per instance and authenticates deliveries, not users.
@@ -178,7 +176,7 @@ async def get_forge_webhook_settings():
return jsonify({ return jsonify({
# Secrets never leave the server — the smtp_password convention: # Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not. # masked when set, empty when not.
"webhook_secret": _TOKEN_MASK if webhook_secret else "", "webhook_secret": SECRET_MASK if webhook_secret else "",
}) })
@@ -192,7 +190,7 @@ async def update_forge_webhook_settings():
webhook_secret = data.get("webhook_secret") webhook_secret = data.get("webhook_secret")
# The mask coming back means "unchanged" — the form round-trips what GET # The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration. # showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != _TOKEN_MASK: if webhook_secret is not None and webhook_secret != SECRET_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret)) await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The secret is deliberately absent from the audit detail. # The secret is deliberately absent from the audit detail.
await log_audit( await log_audit(
+28 -33
View File
@@ -15,9 +15,10 @@ one and returns None for the other:
those two IS the intent: distinguishing them would confirm the existence of those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see. records the caller may not see.
""" """
from quart import Blueprint, g, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import design_systems as ds_svc from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import ( from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX, DEFAULT_TOKEN_PREFIX,
@@ -28,12 +29,6 @@ from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api") design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ────────────────────────────────────────────────────── # ── Design systems ──────────────────────────────────────────────────────
@@ -43,7 +38,7 @@ def _not_found(what: str = "design system"):
async def list_design_systems(): async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an """The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error.""" install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid()) rows = await ds_svc.list_design_systems(get_current_user_id())
return jsonify({"design_systems": [s.to_dict() for s in rows]}) return jsonify({"design_systems": [s.to_dict() for s in rows]})
@@ -55,7 +50,7 @@ async def create_design_system():
if not title: if not title:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system( system = await ds_svc.create_design_system(
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description") or None, description=data.get("description") or None,
guidance=data.get("guidance") or None, guidance=data.get("guidance") or None,
@@ -84,9 +79,9 @@ async def list_starter_role_groups():
@design_systems_bp.get("/design-systems/<int:design_system_id>") @design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required @login_required
async def get_design_system(design_system_id: int): async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id) system = await ds_svc.get_design_system(get_current_user_id(), design_system_id)
if system is None: if system is None:
return _not_found() return not_found("Design system")
return jsonify(system.to_dict()) return jsonify(system.to_dict())
@@ -102,19 +97,19 @@ async def update_design_system(design_system_id: int):
if "parent_id" in data: if "parent_id" in data:
fields["parent_id"] = data["parent_id"] fields["parent_id"] = data["parent_id"]
try: try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields) system = await ds_svc.update_design_system(get_current_user_id(), design_system_id, **fields)
except DesignSystemCycle as exc: except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if system is None: if system is None:
return _not_found() return not_found("Design system")
return jsonify(system.to_dict()) return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>") @design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required @login_required
async def delete_design_system(design_system_id: int): async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id): if not await ds_svc.delete_design_system(get_current_user_id(), design_system_id):
return _not_found() return not_found("Design system")
return "", 204 return "", 204
@@ -127,9 +122,9 @@ async def resolve_design_system(design_system_id: int):
this returns what it ends up being. Both are real questions and answering this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation. only one would make the other a client-side computation.
""" """
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id) resolved = await ds_svc.resolve_design_system(get_current_user_id(), design_system_id)
if resolved is None: if resolved is None:
return _not_found() return not_found("Design system")
return jsonify({ return jsonify({
"design_system_id": design_system_id, "design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved], "tokens": [t.to_dict() for t in resolved],
@@ -149,9 +144,9 @@ async def get_design_system_stylesheet(design_system_id: int):
`:root`, so the generator takes it as a parameter. `:root`, so the generator takes it as a parameter.
""" """
root = (request.args.get("root") or ":root").strip() or ":root" root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root) result = await ds_svc.stylesheet_for_system(get_current_user_id(), design_system_id, root)
if result is None: if result is None:
return _not_found() return not_found("Design system")
if request.args.get("format") == "css": if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"} return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result) return jsonify(result)
@@ -168,10 +163,10 @@ async def check_snippets_against_system(design_system_id: int):
""" """
project_id = request.args.get("project_id", type=int) or 0 project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system( result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id get_current_user_id(), design_system_id, project_id
) )
if result is None: if result is None:
return _not_found() return not_found("Design system")
return jsonify(result) return jsonify(result)
@@ -181,9 +176,9 @@ async def check_snippets_against_system(design_system_id: int):
@login_required @login_required
async def list_design_tokens(design_system_id: int): async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set.""" """This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None: if await ds_svc.get_design_system(get_current_user_id(), design_system_id) is None:
return _not_found() return not_found("Design system")
rows = await ds_svc.list_tokens(_uid(), design_system_id) rows = await ds_svc.list_tokens(get_current_user_id(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]}) return jsonify({"tokens": [t.to_dict() for t in rows]})
@@ -195,7 +190,7 @@ async def create_design_token(design_system_id: int):
if not name: if not name:
return jsonify({"error": "name is required"}), 400 return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token( token = await ds_svc.create_token(
user_id=_uid(), user_id=get_current_user_id(),
design_system_id=design_system_id, design_system_id=design_system_id,
name=name, name=name,
value_by_mode=data.get("value_by_mode"), value_by_mode=data.get("value_by_mode"),
@@ -206,7 +201,7 @@ async def create_design_token(design_system_id: int):
order_index=data.get("order_index") or 0, order_index=data.get("order_index") or 0,
) )
if token is None: if token is None:
return _not_found() return not_found("Design system")
return jsonify(token.to_dict()), 201 return jsonify(token.to_dict()), 201
@@ -221,17 +216,17 @@ async def update_design_token(token_id: int):
"supersedes", "order_index", "supersedes", "order_index",
) )
} }
token = await ds_svc.update_token(_uid(), token_id, **fields) token = await ds_svc.update_token(get_current_user_id(), token_id, **fields)
if token is None: if token is None:
return _not_found("design token") return not_found("Design token")
return jsonify(token.to_dict()) return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>") @design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required @login_required
async def delete_design_token(token_id: int): async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id): if not await ds_svc.delete_token(get_current_user_id(), token_id):
return _not_found("design token") return not_found("Design token")
return "", 204 return "", 204
@@ -247,9 +242,9 @@ async def set_project_design_system(project_id: int):
""" """
data = await request.get_json() or {} data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system( ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id") get_current_user_id(), project_id, data.get("design_system_id")
) )
if not ok: if not ok:
return _not_found("project or design system") return not_found("Project or design system")
return jsonify({"project_id": project_id, return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")}) "design_system_id": data.get("design_system_id")})
+8 -54
View File
@@ -26,22 +26,6 @@ from scribe.services import dedup as dedup_svc
from scribe.services import supersession as supersession_svc from scribe.services import supersession as supersession_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Both directions of the supersession relation on a note payload.
Mirrors the MCP helper of the same name — the two surfaces must agree about
what a note's payload says, or the web UI and the agent would disagree about
whether a record is current.
Omitted when empty: a field that always says nothing trains readers to skip
fields, which is what `consolidated_at` cost (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
from scribe.services.note_versions import list_versions, get_version from scribe.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +126,7 @@ async def create_note_route():
# may not write the target. The note itself was created. # may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403 return jsonify({"error": str(exc), "note": note.to_dict()}), 403
out = note.to_dict() out = note.to_dict()
await _attach_supersession(uid, note.id, out) await supersession_svc.attach_relations(uid, note.id, out)
return jsonify(out), 201 return jsonify(out), 201
@@ -241,13 +225,17 @@ async def get_note_route(note_id: int):
# injected line useful?" is answered by agent pulls alone, and a human # injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on. # clicking a link would inflate exactly the number #1038 and #2085 gate on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note") record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await _attach_supersession(uid, note_id, data) await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data) return jsonify(data)
@notes_bp.route("/<int:note_id>", methods=["PUT"]) @notes_bp.route("/<int:note_id>", methods=["PUT", "PATCH"])
@login_required @login_required
async def update_note_route(note_id: int): async def update_note_route(note_id: int):
"""Partial update — only the keys present in the payload change. PUT and
PATCH are the same handler on purpose: the form sends the field set it
edited, and the two verbs used to be two near-identical copies of this
function that drifted (one carried the supersedes contract, one did not)."""
uid = get_current_user_id() uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared # Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service. # editor's save isn't rejected by the owner-scoped update service.
@@ -290,44 +278,10 @@ async def update_note_route(note_id: int):
except PermissionError as exc: except PermissionError as exc:
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403
out = note.to_dict() out = note.to_dict()
await _attach_supersession(uid, note_id, out) await supersession_svc.attach_relations(uid, note_id, out)
return jsonify(out) return jsonify(out)
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
@login_required
async def patch_note_route(note_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(owner_uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"]) @notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required @login_required
+35 -49
View File
@@ -24,6 +24,35 @@ plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
_MARKETPLACE_KEY = "plugin_marketplace_url" _MARKETPLACE_KEY = "plugin_marketplace_url"
def _int_list(raw: str | None) -> list[int]:
"""A comma-separated id list from the query string; non-ints dropped."""
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
async def _project_scope() -> tuple[int, str, str]:
"""(project_id, repo, unbound_repo) from the request's `project_id` /
`repo` query args — the one resolution every plugin endpoint shares.
An explicit project_id wins; otherwise the repo remote is resolved
through the caller's bindings, and a remote nobody bound comes back as
`unbound_repo` (normalised) so /context can say "bind this repo".
"""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
unbound_repo = ""
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
return project_id, repo, unbound_repo
@plugin_bp.get("/context") @plugin_bp.get("/context")
@login_required @login_required
async def session_context(): async def session_context():
@@ -37,20 +66,7 @@ async def session_context():
project_id (optional int) — explicit override, mainly for manual/ad-hoc project_id (optional int) — explicit override, mainly for manual/ad-hoc
curl testing; takes precedence over `repo` when set. curl testing; takes precedence over `repo` when set.
""" """
try: project_id, _repo, unbound_repo = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
unbound_repo = ""
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
result = await plugin_ctx_svc.build_session_context( result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo g.user.id, project_id, unbound_repo=unbound_repo
) )
@@ -77,22 +93,8 @@ async def autoinject_retrieve():
session; skipped so each note injects at most once. session; skipped so each note injects at most once.
""" """
q = (request.args.get("q") or "").strip() q = (request.args.get("q") or "").strip()
try: project_id, _repo, _unbound = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0) exclude_ids = _int_list(request.args.get("exclude_ids"))
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
result = await plugin_ctx_svc.build_autoinject_hint( result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
) )
@@ -139,25 +141,9 @@ async def write_path_prior_art():
""" """
path = (request.args.get("path") or "").strip() path = (request.args.get("path") or "").strip()
code = request.args.get("code") or "" code = request.args.get("code") or ""
try: project_id, repo, _unbound = await _project_scope()
project_id = int(request.args.get("project_id", 0) or 0) exclude_ids = _int_list(request.args.get("exclude_ids"))
except (TypeError, ValueError): exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
project_id = 0
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
exclude_sync_ids = [
int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",")
if p.strip().isdigit()
]
shapes = _parse_shapes(request.args.get("shapes") or "") shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None) api_key = getattr(g, "api_key", None)
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write" may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
+25 -28
View File
@@ -1,29 +1,26 @@
"""Rulebook / topic REST endpoints. """Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the Wraps services/rulebooks.py. Standard Scribe auth: get_current_user_id() is the
authenticated owner; the service enforces ownership scoping. authenticated owner; the service enforces ownership scoping.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
import scribe.services.rulebooks as rulebooks_svc import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete from scribe.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api") rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ─────────────────────────────────────────────────────────── # ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks") @rulebooks_bp.get("/rulebooks")
@login_required @login_required
async def list_rulebooks(): async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid()) rows = await rulebooks_svc.list_rulebooks(get_current_user_id())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]}) return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@@ -35,7 +32,7 @@ async def create_rulebook():
if not title: if not title:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook( rb = await rulebooks_svc.create_rulebook(
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description", ""), description=data.get("description", ""),
) )
@@ -45,7 +42,7 @@ async def create_rulebook():
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>") @rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required @login_required
async def get_rulebook(rulebook_id: int): async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid()) rb = await rulebooks_svc.get_rulebook(rulebook_id, get_current_user_id())
if rb is None: if rb is None:
return jsonify({"error": "rulebook not found"}), 404 return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict()) return jsonify(rb.to_dict())
@@ -56,7 +53,7 @@ async def get_rulebook(rulebook_id: int):
async def update_rulebook(rulebook_id: int): async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {} data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")} fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields) rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
if rb is None: if rb is None:
return jsonify({"error": "rulebook not found"}), 404 return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict()) return jsonify(rb.to_dict())
@@ -65,7 +62,7 @@ async def update_rulebook(rulebook_id: int):
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>") @rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required @login_required
async def delete_rulebook(rulebook_id: int): async def delete_rulebook(rulebook_id: int):
await trash_delete(_uid(), "rulebook", rulebook_id) await trash_delete(get_current_user_id(), "rulebook", rulebook_id)
return "", 204 return "", 204
@@ -75,7 +72,7 @@ async def delete_rulebook(rulebook_id: int):
@login_required @login_required
async def list_topics(rulebook_id: int): async def list_topics(rulebook_id: int):
try: try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid()) rows = await rulebooks_svc.list_topics(rulebook_id, get_current_user_id())
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]}) return jsonify({"topics": [t.to_dict() for t in rows]})
@@ -91,7 +88,7 @@ async def create_topic(rulebook_id: int):
try: try:
topic = await rulebooks_svc.create_topic( topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id, rulebook_id=rulebook_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
description=data.get("description", ""), description=data.get("description", ""),
order_index=data.get("order_index", 0), order_index=data.get("order_index", 0),
@@ -109,7 +106,7 @@ async def update_topic(topic_id: int):
k: v for k, v in data.items() k: v for k, v in data.items()
if k in ("title", "description", "order_index") if k in ("title", "description", "order_index")
} }
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields) topic = await rulebooks_svc.update_topic(topic_id, get_current_user_id(), **fields)
if topic is None: if topic is None:
return jsonify({"error": "topic not found"}), 404 return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict()) return jsonify(topic.to_dict())
@@ -118,7 +115,7 @@ async def update_topic(topic_id: int):
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>") @rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required @login_required
async def delete_topic(topic_id: int): async def delete_topic(topic_id: int):
if await trash_delete(_uid(), "topic", topic_id) is None: if await trash_delete(get_current_user_id(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404 return jsonify({"error": "topic not found"}), 404
return "", 204 return "", 204
@@ -140,7 +137,7 @@ async def list_rules():
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400 return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
rows = await rulebooks_svc.list_rules( rows = await rulebooks_svc.list_rules(
user_id=_uid(), user_id=get_current_user_id(),
rulebook_id=rulebook_id, rulebook_id=rulebook_id,
topic_id=topic_id, topic_id=topic_id,
project_id=project_id, project_id=project_id,
@@ -159,7 +156,7 @@ async def create_rule(topic_id: int):
try: try:
rule = await rulebooks_svc.create_rule( rule = await rulebooks_svc.create_rule(
topic_id=topic_id, topic_id=topic_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
statement=statement, statement=statement,
why=data.get("why", ""), why=data.get("why", ""),
@@ -174,7 +171,7 @@ async def create_rule(topic_id: int):
@rulebooks_bp.get("/rules/<int:rule_id>") @rulebooks_bp.get("/rules/<int:rule_id>")
@login_required @login_required
async def get_rule(rule_id: int): async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, _uid()) rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
if rule is None: if rule is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict()) return jsonify(rule.to_dict())
@@ -188,7 +185,7 @@ async def update_rule(rule_id: int):
k: v for k, v in data.items() k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index") if k in ("title", "statement", "why", "how_to_apply", "order_index")
} }
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields) rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
if rule is None: if rule is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict()) return jsonify(rule.to_dict())
@@ -197,7 +194,7 @@ async def update_rule(rule_id: int):
@rulebooks_bp.delete("/rules/<int:rule_id>") @rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required @login_required
async def delete_rule(rule_id: int): async def delete_rule(rule_id: int):
if await trash_delete(_uid(), "rule", rule_id) is None: if await trash_delete(get_current_user_id(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404 return jsonify({"error": "rule not found"}), 404
return "", 204 return "", 204
@@ -213,7 +210,7 @@ async def subscribe_project(project_id: int):
return jsonify({"error": "rulebook_id is required"}), 400 return jsonify({"error": "rulebook_id is required"}), 400
try: try:
await rulebooks_svc.subscribe_project( await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(), project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -227,7 +224,7 @@ async def subscribe_project(project_id: int):
async def unsubscribe_project(project_id: int, rulebook_id: int): async def unsubscribe_project(project_id: int, rulebook_id: int):
try: try:
await rulebooks_svc.unsubscribe_project( await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(), project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -238,7 +235,7 @@ async def unsubscribe_project(project_id: int, rulebook_id: int):
@login_required @login_required
async def get_project_rules(project_id: int): async def get_project_rules(project_id: int):
result = await rulebooks_svc.get_applicable_rules( result = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=_uid(), project_id=project_id, user_id=get_current_user_id(),
) )
return jsonify(result) return jsonify(result)
@@ -248,7 +245,7 @@ async def get_project_rules(project_id: int):
async def suppress_project_rule(project_id: int, rule_id: int): async def suppress_project_rule(project_id: int, rule_id: int):
try: try:
await rulebooks_svc.suppress_rule_for_project( await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(), project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -260,7 +257,7 @@ async def suppress_project_rule(project_id: int, rule_id: int):
async def unsuppress_project_rule(project_id: int, rule_id: int): async def unsuppress_project_rule(project_id: int, rule_id: int):
try: try:
await rulebooks_svc.unsuppress_rule_for_project( await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(), project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -272,7 +269,7 @@ async def unsuppress_project_rule(project_id: int, rule_id: int):
async def suppress_project_topic(project_id: int, topic_id: int): async def suppress_project_topic(project_id: int, topic_id: int):
try: try:
await rulebooks_svc.suppress_topic_for_project( await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(), project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -284,7 +281,7 @@ async def suppress_project_topic(project_id: int, topic_id: int):
async def unsuppress_project_topic(project_id: int, topic_id: int): async def unsuppress_project_topic(project_id: int, topic_id: int):
try: try:
await rulebooks_svc.unsuppress_topic_for_project( await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(), project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
@@ -303,7 +300,7 @@ async def create_project_rule(project_id: int):
try: try:
rule = await rulebooks_svc.create_project_rule( rule = await rulebooks_svc.create_project_rule(
project_id=project_id, project_id=project_id,
user_id=_uid(), user_id=get_current_user_id(),
title=title, title=title,
statement=statement, statement=statement,
why=data.get("why", ""), why=data.get("why", ""),
+6 -5
View File
@@ -9,7 +9,9 @@ from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from scribe.config import Config from scribe.config import Config
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,12 +24,11 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# rows live on the admin's own user_id, so the plain GET returned them raw. # rows live on the admin's own user_id, so the plain GET returned them raw.
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.) # (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"}) _SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
_SECRET_MASK = "********"
def _masked(settings: dict) -> dict: def _masked(settings: dict) -> dict:
return { return {
k: (_SECRET_MASK if k in _SECRET_KEYS and v else v) k: (SECRET_MASK if k in _SECRET_KEYS and v else v)
for k, v in settings.items() for k, v in settings.items()
} }
@@ -53,7 +54,7 @@ async def update_settings_route():
str_v = str(v) str_v = str(v)
# A masked secret round-tripping through a client is "unchanged", not # A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential. # a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == _SECRET_MASK: if k in _SECRET_KEYS and str_v == SECRET_MASK:
continue continue
if not str_v: if not str_v:
await delete_setting(uid, k) await delete_setting(uid, k)
@@ -127,7 +128,7 @@ async def update_forge_connection_route(connection_id: int):
token = str(data.get("token", "")) token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the # The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection. # list showed, and storing the mask would silently break the connection.
if token == _SECRET_MASK: if token == SECRET_MASK:
token = "" token = ""
try: try:
row = await update_connection( row = await update_connection(
+5 -8
View File
@@ -1,33 +1,30 @@
"""Trash REST API — list / restore / purge soft-deleted content by batch.""" """Trash REST API — list / restore / purge soft-deleted content by batch."""
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify from quart import Blueprint, jsonify
from scribe.auth import login_required from scribe.auth import get_current_user_id, login_required
import scribe.services.trash as trash_svc import scribe.services.trash as trash_svc
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash") trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
def _uid() -> int:
return g.user.id
@trash_bp.get("") @trash_bp.get("")
@login_required @login_required
async def list_trash(): async def list_trash():
return jsonify({"batches": await trash_svc.list_trash(_uid())}) return jsonify({"batches": await trash_svc.list_trash(get_current_user_id())})
@trash_bp.post("/<batch_id>/restore") @trash_bp.post("/<batch_id>/restore")
@login_required @login_required
async def restore_batch(batch_id: str): async def restore_batch(batch_id: str):
n = await trash_svc.restore(_uid(), batch_id) n = await trash_svc.restore(get_current_user_id(), batch_id)
return jsonify({"restored": n}) return jsonify({"restored": n})
@trash_bp.delete("/<batch_id>") @trash_bp.delete("/<batch_id>")
@login_required @login_required
async def purge_batch(batch_id: str): async def purge_batch(batch_id: str):
n = await trash_svc.purge(_uid(), batch_id) n = await trash_svc.purge(get_current_user_id(), batch_id)
return jsonify({"purged": n}) return jsonify({"purged": n})
+7 -4
View File
@@ -13,8 +13,11 @@ def generate_key() -> str:
return "fmcp_" + secrets.token_urlsafe(32) return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str: def hash_token(raw: str) -> str:
return hashlib.sha256(key.encode()).hexdigest() """The ONE fingerprint for every bearer secret stored by hash — API keys,
password-reset tokens, invitation tokens. Stored rows hold this, never
the raw value; a lookup hashes the presented token and compares."""
return hashlib.sha256(raw.encode()).hexdigest()
def _key_prefix(key: str) -> str: def _key_prefix(key: str) -> str:
@@ -32,7 +35,7 @@ async def create_api_key(
key = ApiKey( key = ApiKey(
user_id=user_id, user_id=user_id,
name=name, name=name,
key_hash=_hash_key(full_key), key_hash=hash_token(full_key),
key_prefix=_key_prefix(full_key), key_prefix=_key_prefix(full_key),
scope=scope, scope=scope,
) )
@@ -70,7 +73,7 @@ async def revoke_api_key(user_id: int, key_id: int) -> bool:
async def lookup_key(raw_key: str) -> ApiKey | None: async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" """Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key) key_hash = hash_token(raw_key)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(ApiKey).where( select(ApiKey).where(
+16 -28
View File
@@ -1,4 +1,3 @@
import hashlib
import logging import logging
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -12,6 +11,8 @@ from scribe.models.invitation import InvitationToken
from scribe.models.password_reset import PasswordResetToken from scribe.models.password_reset import PasswordResetToken
from scribe.models.setting import Setting from scribe.models.setting import Setting
from scribe.models.user import User from scribe.models.user import User
from scribe.services.api_keys import hash_token
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,16 +143,7 @@ async def is_registration_open() -> bool:
user_count = await get_user_count() user_count = await get_user_count()
if user_count == 0: if user_count == 0:
return True return True
return await get_admin_setting("registration_open", "false") == "true"
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
async def list_users() -> list[User]: async def list_users() -> list[User]:
@@ -211,7 +203,7 @@ async def get_user_by_email(email: str) -> User | None:
async def create_password_reset_token(user_id: int) -> str: async def create_password_reset_token(user_id: int) -> str:
"""Generate a password reset token. Returns the raw token (for the email link).""" """Generate a password reset token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32) raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=1) expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
async with async_session() as session: async with async_session() as session:
@@ -239,7 +231,7 @@ async def create_password_reset_token(user_id: int) -> str:
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None: async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success.""" """Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -270,7 +262,7 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> int |
async def create_invitation(email: str, invited_by: int) -> str: async def create_invitation(email: str, invited_by: int) -> str:
"""Generate an invitation token. Returns the raw token (for the email link).""" """Generate an invitation token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32) raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(days=7) expires_at = datetime.now(timezone.utc) + timedelta(days=7)
async with async_session() as session: async with async_session() as session:
@@ -299,7 +291,7 @@ async def create_invitation(email: str, invited_by: int) -> str:
async def validate_invitation_token(raw_token: str) -> InvitationToken | None: async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
"""Look up by hash, check not used/expired. Returns the token record with email.""" """Look up by hash, check not used/expired. Returns the token record with email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -319,7 +311,7 @@ async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None: async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
"""Validate token, create user with the invitation's email, mark token used.""" """Validate token, create user with the invitation's email, mark token used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest() token_hash = hash_token(raw_token)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -398,20 +390,16 @@ async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
return removed return removed
async def _auth_token_retention_loop() -> None: async def _auth_token_retention_tick() -> None:
import asyncio removed = await purge_expired_auth_tokens()
while True: if removed:
await asyncio.sleep(86400) # daily logger.info("Auth token retention: deleted %d expired token(s)", removed)
try:
removed = await purge_expired_auth_tokens()
if removed:
logger.info("Auth token retention: deleted %d expired token(s)", removed)
except Exception:
logger.exception("Error in auth token retention cleanup")
def start_auth_token_retention_loop() -> None: def start_auth_token_retention_loop() -> None:
global _auth_retention_task global _auth_retention_task
import asyncio
if _auth_retention_task is None or _auth_retention_task.done(): if _auth_retention_task is None or _auth_retention_task.done():
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop()) from scribe.services.background import start_periodic
_auth_retention_task = start_periodic(
86400, _auth_token_retention_tick, label="auth_token_retention", # daily
)
+20
View File
@@ -45,6 +45,26 @@ def spawn(coro: Coroutine, *, site: str) -> None:
task.add_done_callback(_done) task.add_done_callback(_done)
def start_periodic(interval_s: float, work, *, label: str) -> asyncio.Task:
"""A forever loop that sleeps ``interval_s`` then awaits ``work()``, logging
(never raising) when a tick fails — the one shape the hourly/daily
retention sweeps share (log retention, notification sweep, auth-token
purge). Sleeps FIRST so startup isn't a sweep; holds a strong reference
like spawn() so the loop cannot be garbage-collected mid-flight."""
async def _loop() -> None:
while True:
await asyncio.sleep(interval_s)
try:
await work()
except Exception:
logger.exception("periodic task %s failed", label)
task = asyncio.get_running_loop().create_task(_loop(), name=f"periodic-{label}")
_pending.add(task)
task.add_done_callback(_pending.discard)
return task
async def drain() -> None: async def drain() -> None:
"""Await everything in flight — for tests that need the writes landed.""" """Await everything in flight — for tests that need the writes landed."""
while _pending: while _pending:
+157 -271
View File
@@ -189,6 +189,142 @@ def _repo_binding_rows(rows) -> list[dict]:
] ]
# Row builders for the sections both exporters carry. Pure, like the join-table
# helpers above; the full and per-user exports used to restate every one of
# these comprehensions side by side, and a column added to one and not the
# other is a backup that silently drops it (#2293's shape, one layer down).
def _user_rows(rows) -> list[dict]:
return [
{
"id": u.id, "username": u.username, "email": u.email,
"password_hash": u.password_hash, "oauth_sub": u.oauth_sub,
"role": u.role, "session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in rows
]
def _project_rows(rows) -> list[dict]:
return [
{
"id": p.id, "user_id": p.user_id, "title": p.title,
"description": p.description, "goal": p.goal, "status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in rows
]
def _milestone_rows(rows) -> list[dict]:
return [
{
"id": m.id, "user_id": m.user_id, "project_id": m.project_id,
"title": m.title, "description": m.description, "status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in rows
]
def _note_rows(rows) -> list[dict]:
return [
{
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
"tags": n.tags or [], "parent_id": n.parent_id,
"project_id": n.project_id, "milestone_id": n.milestone_id,
"status": n.status, "priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in rows
]
def _task_log_rows(rows) -> list[dict]:
return [
{
"id": tl.id, "user_id": tl.user_id, "task_id": tl.task_id,
"content": tl.content, "duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in rows
]
def _note_draft_rows(rows) -> list[dict]:
return [
{
"id": nd.id, "user_id": nd.user_id, "note_id": nd.note_id,
"proposed_body": nd.proposed_body, "original_body": nd.original_body,
"instruction": nd.instruction, "scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in rows
]
def _note_version_rows(rows) -> list[dict]:
return [
{
"id": nv.id, "user_id": nv.user_id, "note_id": nv.note_id,
"title": nv.title, "body": nv.body, "tags": nv.tags or [],
"pin_kind": nv.pin_kind, "pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in rows
]
def _setting_rows(rows) -> list[dict]:
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
def _rulebook_rows(rows) -> list[dict]:
return [
{
"id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title,
"description": rb.description, "always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rows
]
def _topic_rows(rows) -> list[dict]:
return [
{
"id": t.id, "rulebook_id": t.rulebook_id, "title": t.title,
"description": t.description, "order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in rows
]
def _rule_rows(rows) -> list[dict]:
return [
{
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
"title": r.title, "statement": r.statement, "why": r.why,
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rows
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Export # Export
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -247,148 +383,17 @@ async def export_full_backup() -> dict:
"Store it securely and restrict access." "Store it securely and restrict access."
), ),
"_not_included": _NOT_INCLUDED, "_not_included": _NOT_INCLUDED,
"users": [ "users": _user_rows(users),
{ "projects": _project_rows(projects),
"id": u.id, "milestones": _milestone_rows(milestones),
"username": u.username, "notes": _note_rows(notes),
"email": u.email, "task_logs": _task_log_rows(task_logs),
"password_hash": u.password_hash, "note_drafts": _note_draft_rows(note_drafts),
"oauth_sub": u.oauth_sub, "note_versions": _note_version_rows(note_versions),
"role": u.role, "settings": _setting_rows(settings),
"session_version": u.session_version, "rulebooks": _rulebook_rows(rulebooks),
"created_at": u.created_at.isoformat(), "rulebook_topics": _topic_rows(topics),
} "rules": _rule_rows(rules),
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"id": r.id,
"topic_id": r.topic_id,
"project_id": r.project_id,
"title": r.title,
"statement": r.statement,
"why": r.why,
"how_to_apply": r.how_to_apply,
"order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"rulebook_subscriptions": _subscription_rows(subscriptions), "rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions), "rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions),
@@ -526,135 +531,16 @@ async def export_user_backup(user_id: int) -> dict:
"role": user.role, "role": user.role,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
} if user else None, } if user else None,
"projects": [ "projects": _project_rows(projects),
{ "milestones": _milestone_rows(milestones),
"id": p.id, "notes": _note_rows(notes),
"user_id": p.user_id, "task_logs": _task_log_rows(task_logs),
"title": p.title, "note_drafts": _note_draft_rows(note_drafts),
"description": p.description, "note_versions": _note_version_rows(note_versions),
"goal": p.goal, "settings": _setting_rows(settings),
"status": p.status, "rulebooks": _rulebook_rows(rulebooks),
"color": p.color, "rulebook_topics": _topic_rows(topics),
"created_at": p.created_at.isoformat(), "rules": _rule_rows(rules),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"id": r.id,
"topic_id": r.topic_id,
"project_id": r.project_id,
"title": r.title,
"statement": r.statement,
"why": r.why,
"how_to_apply": r.how_to_apply,
"order_index": r.order_index,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"rulebook_subscriptions": _subscription_rows(subscriptions), "rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions), "rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions),
+2 -1
View File
@@ -15,6 +15,7 @@ from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.base import iso
from scribe.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,7 +174,7 @@ async def _recently_completed(user_id: int) -> list[dict]:
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT) .order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all() )).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle, return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows] "completed_at": iso(n.completed_at)} for n, ptitle in rows]
async def _week_stats(user_id: int) -> dict: async def _week_stats(user_id: int) -> dict:
+3 -6
View File
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
from sqlalchemy import text from sqlalchemy import text
from scribe.models import async_session, engine from scribe.models import async_session, engine
from scribe.models.base import iso
from scribe.services.settings import get_admin_setting, set_admin_setting from scribe.services.settings import get_admin_setting, set_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -128,10 +129,6 @@ _HEALTH_SQL = text("""
""") """)
def _iso(value) -> str | None:
return value.isoformat() if value is not None else None
async def get_table_health() -> dict: async def get_table_health() -> dict:
"""Per-table health from Postgres statistics + the total database size. """Per-table health from Postgres statistics + the total database size.
@@ -156,8 +153,8 @@ async def get_table_health() -> dict:
"dead_pct": float(r["dead_pct"] or 0), "dead_pct": float(r["dead_pct"] or 0),
"total_bytes": int(r["total_bytes"] or 0), "total_bytes": int(r["total_bytes"] or 0),
"mod_since_analyze": int(r["mod_since_analyze"] or 0), "mod_since_analyze": int(r["mod_since_analyze"] or 0),
"last_vacuum": _iso(r["last_vacuum"]), "last_vacuum": iso(r["last_vacuum"]),
"last_analyze": _iso(r["last_analyze"]), "last_analyze": iso(r["last_analyze"]),
} }
for r in rows for r in rows
] ]
+18 -46
View File
@@ -1,9 +1,8 @@
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE). """Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
Mirrors trash_scheduler.py: a single global BackgroundScheduler job bridges One ScheduledJob (services/scheduler.py). Scheduled for 04:00 UTC by default
into the asyncio loop to run the async maintenance. Scheduled for 04:00 UTC by — after the 03:30 trash purge — so it collects the dead tuples that night's
default — after the 03:30 trash purge — so it collects the dead tuples that delete sweeps leave behind.
night's delete sweeps leave behind.
Two things are operator-tunable from the admin Settings card: Two things are operator-tunable from the admin Settings card:
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling - db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
@@ -16,9 +15,9 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from scribe.services.scheduler import ScheduledJob
from scribe.services.settings import get_admin_setting from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,9 +25,6 @@ logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum" _JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4 _DEFAULT_HOUR = 4
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
async def get_maintenance_hour() -> int: async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 023), clamped; default 04:00.""" """The configured run-hour (UTC, 023), clamped; default 04:00."""
@@ -45,23 +41,20 @@ async def is_maintenance_enabled() -> bool:
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false" return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
def _run_maintenance_threadsafe() -> None: async def _run_maintenance() -> None:
"""APScheduler invokes this from a worker thread; bridge into the loop.""" if not await is_maintenance_enabled():
if _loop is None: logger.debug("db maintenance: disabled, skipping scheduled run")
logger.warning("db maintenance scheduler: no loop registered")
return return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
async def _runner():
try:
if not await is_maintenance_enabled():
logger.debug("db maintenance: disabled, skipping scheduled run")
return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
except Exception:
logger.exception("db maintenance run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop) _JOB = ScheduledJob(_JOB_ID, _run_maintenance, label="DB maintenance")
def _trigger(hour: int) -> CronTrigger:
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
return CronTrigger(hour=hour, minute=0, timezone="UTC")
def start_db_maintenance_scheduler( def start_db_maintenance_scheduler(
@@ -72,36 +65,15 @@ def start_db_maintenance_scheduler(
in rather than read here so we never block the event loop at startup. The in rather than read here so we never block the event loop at startup. The
job's enabled-gate is re-checked at every fire, so only the hour is needed job's enabled-gate is re-checked at every fire, so only the hour is needed
up front.""" up front."""
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler = BackgroundScheduler() _JOB.start(loop, _trigger(hour), describe=f"daily {hour:02d}:00 UTC")
_scheduler.add_job(
_run_maintenance_threadsafe,
trigger=CronTrigger(hour=hour, minute=0, timezone="UTC"),
id=_JOB_ID,
replace_existing=True,
)
_scheduler.start()
logger.info("DB maintenance scheduler started (daily %02d:00 UTC)", hour)
def reschedule_db_maintenance(hour: int) -> None: def reschedule_db_maintenance(hour: int) -> None:
"""Move the live job to a new UTC hour (called when the admin changes it).""" """Move the live job to a new UTC hour (called when the admin changes it)."""
if _scheduler is None:
return
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler.reschedule_job( _JOB.reschedule(_trigger(hour), describe=f"{hour:02d}:00 UTC")
_JOB_ID, trigger=CronTrigger(hour=hour, minute=0, timezone="UTC")
)
logger.info("DB maintenance scheduler rescheduled to %02d:00 UTC", hour)
def stop_db_maintenance_scheduler() -> None: def stop_db_maintenance_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("DB maintenance scheduler stopped")
+3 -2
View File
@@ -32,6 +32,7 @@ from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding from scribe.models.embedding import NoteEmbedding
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.rulebook import Rule from scribe.models.rulebook import Rule
from scribe.models.base import iso
from scribe.services import embeddings as embeddings_svc from scribe.services import embeddings as embeddings_svc
# Imported rather than redeclared: no service imports this module (the create # Imported rather than redeclared: no service imports this module (the create
# gate is called from the routes/tools layer), so there is no cycle to dodge, # gate is called from the routes/tools layer), so there is no cycle to dodge,
@@ -592,8 +593,8 @@ async def find_duplicate_records(
titles[int(i)] = t titles[int(i)] = t
records[int(i)] = d or {} records[int(i)] = d or {}
meta[int(i)] = { meta[int(i)] = {
"created_at": created.isoformat() if created else None, "created_at": iso(created),
"updated_at": updated.isoformat() if updated else None, "updated_at": iso(updated),
"task_kind": task_kind, "task_kind": task_kind,
} }
except Exception: except Exception:
+4 -3
View File
@@ -22,6 +22,7 @@ from sqlalchemy import and_, func, or_, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.base import iso
from scribe.services.access import browsable_notes_clause, readable_notes_clause from scribe.services.access import browsable_notes_clause, readable_notes_clause
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -211,8 +212,8 @@ def _note_to_item(note: Note) -> dict:
# These lists now include records shared with the caller, so the client # These lists now include records shared with the caller, so the client
# needs the owner to tell "mine" from "someone else's" in a mixed list. # needs the owner to tell "mine" from "someone else's" in a mixed list.
"user_id": note.user_id, "user_id": note.user_id,
"created_at": note.created_at.isoformat(), "created_at": iso(note.created_at),
"updated_at": note.updated_at.isoformat(), "updated_at": iso(note.updated_at),
} }
# Drift verdict (#2086), when one has been recorded. Included here rather # Drift verdict (#2086), when one has been recorded. Included here rather
# than decorated on by the snippet layer because `current` is derivable from # than decorated on by the snippet layer because `current` is derivable from
@@ -249,7 +250,7 @@ def _note_to_item(note: Note) -> dict:
item["task_kind"] = note.task_kind item["task_kind"] = note.task_kind
item["status"] = note.status item["status"] = note.status
item["priority"] = note.priority item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None item["due_date"] = iso(note.due_date)
return item return item
+6 -10
View File
@@ -194,18 +194,14 @@ async def delete_old_logs(retention_days: int) -> int:
return result.rowcount return result.rowcount
async def _retention_loop() -> None: async def _retention_tick() -> None:
while True: deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
await asyncio.sleep(3600) # hourly if deleted:
try: logger.info("Log retention: deleted %d old log entries", deleted)
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
if deleted:
logger.info("Log retention: deleted %d old log entries", deleted)
except Exception:
logger.exception("Error in log retention cleanup")
def start_log_retention_loop() -> None: def start_log_retention_loop() -> None:
global _retention_task global _retention_task
if _retention_task is None or _retention_task.done(): if _retention_task is None or _retention_task.done():
_retention_task = asyncio.create_task(_retention_loop()) from scribe.services.background import start_periodic
_retention_task = start_periodic(3600, _retention_tick, label="log_retention") # hourly
+3 -9
View File
@@ -235,12 +235,6 @@ async def get_project_milestone_summaries(
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]: async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats.""" """Ordered milestones with progress — the one-project view of
milestones = await list_milestones(user_id, project_id) get_project_milestone_summaries (two queries, not N+1)."""
result = [] return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+3 -2
View File
@@ -36,6 +36,7 @@ from sqlalchemy import case, func, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.models.base import iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -232,13 +233,13 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
slot["ambient_count"] = int(n) slot["ambient_count"] = int(n)
elif event == SURFACED: elif event == SURFACED:
slot["surfaced_count"] = int(n) slot["surfaced_count"] = int(n)
slot["last_surfaced_at"] = last_at.isoformat() if last_at else None slot["last_surfaced_at"] = iso(last_at)
elif event == PULLED: elif event == PULLED:
# Pulls are pulls regardless of what surfaced the record — the # Pulls are pulls regardless of what surfaced the record — the
# question a pull answers ("did anyone ever open this?") doesn't # question a pull answers ("did anyone ever open this?") doesn't
# depend on how it was found. # depend on how it was found.
slot["pull_count"] = slot["pull_count"] + int(n) slot["pull_count"] = slot["pull_count"] + int(n)
latest = last_at.isoformat() if last_at else None latest = iso(last_at)
if latest and (slot["last_pulled_at"] or "") < latest: if latest and (slot["last_pulled_at"] or "") < latest:
slot["last_pulled_at"] = latest slot["last_pulled_at"] = latest
return out return out
+24 -44
View File
@@ -3,17 +3,20 @@
import asyncio import asyncio
import json import json
import logging import logging
from datetime import date, datetime, time, timezone from datetime import date, datetime, time, timedelta, timezone
from sqlalchemy import func, select, text from sqlalchemy import delete as sa_delete, func, select, text
from sqlalchemy import update as sa_update
from scribe.models import async_session from scribe.models import async_session
from scribe.models.app_log import AppLog from scribe.models.app_log import AppLog
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.setting import Setting from scribe.models.notification import Notification
from scribe.models.user import User from scribe.models.user import User
from scribe.models.base import iso
from scribe.services.email import _email_html, is_smtp_configured, send_email from scribe.services.email import _email_html, is_smtp_configured, send_email
from scribe.services.logging import log_audit from scribe.services.logging import log_audit
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,13 +32,7 @@ SECURITY_EVENT_LABELS = {
async def _get_user_notification_pref(user_id: int, key: str) -> bool: async def _get_user_notification_pref(user_id: int, key: str) -> bool:
"""Check if a user has a notification preference enabled (default True).""" """Check if a user has a notification preference enabled (default True)."""
async with async_session() as session: return await get_setting(user_id, key, "true") != "false"
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
# Default to enabled
return setting.value != "false" if setting else True
async def _get_user_email(user_id: int) -> str | None: async def _get_user_email(user_id: int) -> str | None:
@@ -222,7 +219,7 @@ async def check_due_tasks() -> None:
for task in user_tasks: for task in user_tasks:
overdue = task.due_date < today if task.due_date else False overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151" date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else "" date_label = f'<span style="color: {date_color};">{iso(task.due_date)}</span>' if task.due_date else ""
overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else "" overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else ""
task_rows += ( task_rows += (
f'<tr>' f'<tr>'
@@ -261,13 +258,10 @@ _NOTIFICATION_RETENTION_DAYS = 30
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int: async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
"""Delete already-read in-app notifications older than retention_days.""" """Delete already-read in-app notifications older than retention_days."""
from datetime import timedelta
from sqlalchemy import delete
from scribe.models.notification import Notification
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
delete(Notification).where( sa_delete(Notification).where(
Notification.read_at.isnot(None), Notification.read_at.isnot(None),
Notification.read_at < cutoff, Notification.read_at < cutoff,
) )
@@ -276,25 +270,21 @@ async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETEN
return result.rowcount or 0 return result.rowcount or 0
async def _notification_loop() -> None: async def _notification_tick() -> None:
while True: try:
await asyncio.sleep(3600) # hourly await check_due_tasks()
try: except Exception:
await check_due_tasks() logger.exception("Error in notification loop")
except Exception: removed = await purge_old_read_notifications()
logger.exception("Error in notification loop") if removed:
try: logger.info("Notification retention: deleted %d read notification(s)", removed)
removed = await purge_old_read_notifications()
if removed:
logger.info("Notification retention: deleted %d read notification(s)", removed)
except Exception:
logger.exception("Error in notification retention cleanup")
def start_notification_loop() -> None: def start_notification_loop() -> None:
global _notification_task global _notification_task
if _notification_task is None or _notification_task.done(): if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop()) from scribe.services.background import start_periodic
_notification_task = start_periodic(3600, _notification_tick, label="notifications") # hourly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -303,7 +293,6 @@ def start_notification_loop() -> None:
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict): async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
"""Create an in-app Notification record.""" """Create an in-app Notification record."""
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
n = Notification(user_id=user_id, type=notif_type, payload=payload) n = Notification(user_id=user_id, type=notif_type, payload=payload)
session.add(n) session.add(n)
@@ -316,11 +305,10 @@ async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try: try:
if not await is_smtp_configured(): if not await is_smtp_configured():
return return
async with async_session() as session: email = await _get_user_email(user_id)
user = await session.get(User, user_id) if email:
if user and user.email:
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>") html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
await send_email(user.email, subject, html) await send_email(email, subject, html)
except Exception: except Exception:
logger.exception("Share email notification failed for user %d", user_id) logger.exception("Share email notification failed for user %d", user_id)
@@ -427,7 +415,6 @@ async def notify_group_added(
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]: async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
q = select(Notification).where(Notification.user_id == user_id) q = select(Notification).where(Notification.user_id == user_id)
if unread_only: if unread_only:
@@ -438,7 +425,6 @@ async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> l
async def unread_notification_count(user_id: int) -> int: async def unread_notification_count(user_id: int) -> int:
from scribe.models.notification import Notification
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(func.count()).where( select(func.count()).where(
@@ -450,8 +436,6 @@ async def unread_notification_count(user_id: int) -> int:
async def mark_notification_read(user_id: int, notification_id: int) -> bool: async def mark_notification_read(user_id: int, notification_id: int) -> bool:
from scribe.models.notification import Notification
from datetime import timezone as tz
async with async_session() as session: async with async_session() as session:
n = (await session.execute( n = (await session.execute(
select(Notification).where( select(Notification).where(
@@ -461,21 +445,17 @@ async def mark_notification_read(user_id: int, notification_id: int) -> bool:
)).scalar_one_or_none() )).scalar_one_or_none()
if not n: if not n:
return False return False
from datetime import datetime n.read_at = datetime.now(timezone.utc)
n.read_at = datetime.now(tz.utc)
await session.commit() await session.commit()
return True return True
async def mark_all_notifications_read(user_id: int) -> int: async def mark_all_notifications_read(user_id: int) -> int:
from scribe.models.notification import Notification
from datetime import datetime, timezone as tz
from sqlalchemy import update as sa_update
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
sa_update(Notification) sa_update(Notification)
.where(Notification.user_id == user_id, Notification.read_at.is_(None)) .where(Notification.user_id == user_id, Notification.read_at.is_(None))
.values(read_at=datetime.now(tz.utc)) .values(read_at=datetime.now(timezone.utc))
.returning(Notification.id) .returning(Notification.id)
) )
await session.commit() await session.commit()
+1 -6
View File
@@ -60,12 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
return { return {
"milestone": milestone.to_dict(), "milestone": milestone.to_dict(),
"applicable_rules": applicable["rules"], **rulebooks_svc.rules_payload(applicable),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"project_goal": getattr(project, "goal", "") or "", "project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count, "open_task_count": open_count,
} }
+11 -57
View File
@@ -208,54 +208,9 @@ async def get_project_summaries(
async def get_project_summary(user_id: int, project_id: int) -> dict: async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity.""" """Return task counts by status, note count, and last activity — the
async with async_session() as session: one-project view of get_project_summaries (one rule, not two copies)."""
# Task counts by status return (await get_project_summaries(user_id, [project_id]))[project_id]
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
# Initialise all three lifecycle keys to 0 so consumers can sum them
# safely without `?? 0` guards. Frontend interface declares all three
# as required; rendering `undefined + N` yields NaN.
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
Note.deleted_at.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from scribe.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,8 +234,6 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
"""Owned projects + shared projects, each dict has 'permission' field.""" """Owned projects + shared projects, each dict has 'permission' field."""
from scribe.models.group import GroupMembership from scribe.models.group import GroupMembership
from scribe.models.share import ProjectShare from scribe.models.share import ProjectShare
from scribe.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status) owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned} owned_ids = {p.id for p in owned}
@@ -307,13 +260,14 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
) )
)).scalars().all() )).scalars().all()
seen: dict[int, str] = {} from scribe.services.sharing import best_permission_by
for share in list(shared_direct) + list(shared_group): seen = {
if share.project_id in owned_ids: pid: perm
continue for pid, perm in best_permission_by(
prev = seen.get(share.project_id) list(shared_direct) + list(shared_group), "project_id"
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]: ).items()
seen[share.project_id] = share.permission if pid not in owned_ids
}
for pid, perm in seen.items(): for pid, perm in seen.items():
if status: if status:
+7 -32
View File
@@ -4,7 +4,7 @@ Every 15 minutes, creates the next occurrence of any recurring task whose spawn
time has arrived — draining `recurrence_next_spawn_at`, which is armed on task time has arrived — draining `recurrence_next_spawn_at`, which is armed on task
completion. Without this job, recurring tasks would never recur. completion. Without this job, recurring tasks would never recur.
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules. One ScheduledJob (services/scheduler.py), like the other *_scheduler modules.
(Formerly event_scheduler.py, which also ran event reminders + CalDAV sync; (Formerly event_scheduler.py, which also ran event reminders + CalDAV sync;
those were removed when the calendar surface was retired.) those were removed when the calendar surface was retired.)
""" """
@@ -13,52 +13,27 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
logger = logging.getLogger(__name__) from scribe.services.scheduler import ScheduledJob
_scheduler: BackgroundScheduler | None = None logger = logging.getLogger(__name__)
_loop: asyncio.AbstractEventLoop | None = None
async def _run_recurrence_spawn() -> None: async def _run_recurrence_spawn() -> None:
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415 from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
try: await spawn_recurring_tasks()
await spawn_recurring_tasks()
except Exception:
logger.warning("Recurring-task spawn job failed", exc_info=True)
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None: _JOB = ScheduledJob("recurrence_spawn", _run_recurrence_spawn, label="Recurring-task spawn")
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None: def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
# Spawn the next occurrence of due recurring tasks every 15 minutes. # Spawn the next occurrence of due recurring tasks every 15 minutes.
# Without this job, recurrence_next_spawn_at is armed on completion but # Without this job, recurrence_next_spawn_at is armed on completion but
# never drained, so recurring tasks never recur. # never drained, so recurring tasks never recur.
_scheduler.add_job( _JOB.start(loop, IntervalTrigger(minutes=15), describe="recurring-task spawn every 15m")
_run_recurrence_spawn_threadsafe,
trigger=IntervalTrigger(minutes=15),
args=[loop],
id="recurrence_spawn",
replace_existing=True,
)
_scheduler.start()
logger.info("Recurrence scheduler started (recurring-task spawn every 15m)")
def stop_recurrence_scheduler() -> None: def stop_recurrence_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Recurrence scheduler stopped")
+19
View File
@@ -779,3 +779,22 @@ async def get_applicable_rules(
"truncated": truncated, "truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks, "subscribed_rulebooks": subscribed_rulebooks,
} }
def rules_payload(applicable: dict) -> dict:
"""The caller-facing shape of a get_applicable_rules() result.
Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the
same six keys under the same names — so a reader learns them once. One
place renames `rules` → `applicable_rules` and `truncated` →
`applicable_rules_truncated`; the tools merge this into their payloads.
"""
return {
"applicable_rules": applicable["rules"],
"applicable_rules_truncated": applicable["truncated"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
}
+78
View File
@@ -0,0 +1,78 @@
"""One APScheduler job bridged into the asyncio loop — the shape the four
*_scheduler modules (recurrence spawn, auto-pin scan, trash purge, DB
maintenance) each used to carry a private copy of.
APScheduler's BackgroundScheduler fires from a worker thread; the work is
async and must run on the app's loop, so the fire is bridged with
``run_coroutine_threadsafe``. Each job is a module-level singleton: start is
idempotent, stop shuts the scheduler down, and a job whose trigger the
operator can change (the maintenance hour) reschedules the live job instead
of restarting.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from apscheduler.schedulers.background import BackgroundScheduler
logger = logging.getLogger(__name__)
class ScheduledJob:
"""A named APScheduler job that awaits ``work()`` on the asyncio loop.
``work`` is an async callable; exceptions it raises are logged under
``label`` and never propagate into APScheduler's thread.
"""
def __init__(self, job_id: str, work: Callable[[], Awaitable[None]], *, label: str) -> None:
self.job_id = job_id
self._work = work
self.label = label
self._scheduler: BackgroundScheduler | None = None
self._loop: asyncio.AbstractEventLoop | None = None
@property
def running(self) -> bool:
return self._scheduler is not None
def _fire(self) -> None:
"""APScheduler invokes this from its worker thread; bridge into the loop."""
if self._loop is None:
logger.warning("%s scheduler: no loop registered", self.label)
return
async def _runner() -> None:
try:
await self._work()
except Exception:
logger.exception("%s run failed", self.label)
asyncio.run_coroutine_threadsafe(_runner(), self._loop)
def start(self, loop: asyncio.AbstractEventLoop, trigger, *, describe: str = "") -> None:
"""Start the job on ``trigger``. Idempotent — a second start is a no-op."""
if self._scheduler is not None:
return
self._loop = loop
self._scheduler = BackgroundScheduler()
self._scheduler.add_job(
self._fire, trigger=trigger, id=self.job_id, replace_existing=True,
)
self._scheduler.start()
logger.info("%s scheduler started%s", self.label, f" ({describe})" if describe else "")
def reschedule(self, trigger, *, describe: str = "") -> None:
"""Move the live job to a new trigger; a no-op when not running."""
if self._scheduler is None:
return
self._scheduler.reschedule_job(self.job_id, trigger=trigger)
logger.info("%s scheduler rescheduled%s", self.label, f" to {describe}" if describe else "")
def stop(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
logger.info("%s scheduler stopped", self.label)
+7
View File
@@ -8,6 +8,13 @@ from scribe.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# What a stored credential looks like on the wire. Every surface that READS a
# secret (smtp_password, forge_webhook_secret, a forge token) returns this
# when one is set; every surface that WRITES one treats this value coming back
# as "unchanged", never as a request to store eight asterisks over the real
# credential. One constant so the read and write halves cannot disagree.
SECRET_MASK = "********"
async def get_admin_setting(key: str, default: str = "") -> str: async def get_admin_setting(key: str, default: str = "") -> str:
"""Read an instance-global setting (one stored on an admin account). """Read an instance-global setting (one stored on an admin account).
+4 -3
View File
@@ -31,6 +31,7 @@ from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.code_shape import CodeShape, CodeShapeEvent from scribe.models.code_shape import CodeShape, CodeShapeEvent
from scribe.models.base import iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1318,9 +1319,9 @@ async def shape_history(
"classified_by": r.classified_by, "reason": r.reason, "classified_by": r.classified_by, "reason": r.reason,
"first_seen_commit": r.first_seen_commit, "first_seen_commit": r.first_seen_commit,
"last_seen_commit": r.last_seen_commit, "last_seen_commit": r.last_seen_commit,
"first_seen_at": r.created_at.isoformat() if r.created_at else None, "first_seen_at": iso(r.created_at),
"vanished_at": r.vanished_at.isoformat() if r.vanished_at else None, "vanished_at": iso(r.vanished_at),
"recheck_at": r.recheck_at.isoformat() if r.recheck_at else None, "recheck_at": iso(r.recheck_at),
"diverges_from": r.diverges_from, "diverges_from": r.diverges_from,
} }
for r in rows for r in rows
+6 -5
View File
@@ -10,6 +10,7 @@ from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User from scribe.models.user import User
from scribe.models.base import iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,7 +32,7 @@ async def _enrich_shares(session, shares) -> list[dict]:
return result return result
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]: def best_permission_by(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource.""" """Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from scribe.services.access import PERMISSION_RANK from scribe.services.access import PERMISSION_RANK
seen: dict[int, str] = {} seen: dict[int, str] = {}
@@ -210,7 +211,7 @@ async def list_shared_with_me(user_id: int) -> dict:
) )
)).scalars().all() )).scalars().all()
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id") seen_projects = best_permission_by(list(proj_direct) + list(proj_group), "project_id")
projects = [] projects = []
for pid, perm in seen_projects.items(): for pid, perm in seen_projects.items():
@@ -223,7 +224,7 @@ async def list_shared_with_me(user_id: int) -> dict:
"description": proj.description, "description": proj.description,
"status": proj.status, "status": proj.status,
"color": proj.color, "color": proj.color,
"updated_at": proj.updated_at.isoformat(), "updated_at": iso(proj.updated_at),
"owner_username": owner.username if owner else None, "owner_username": owner.username if owner else None,
"permission": perm, "permission": perm,
}) })
@@ -241,7 +242,7 @@ async def list_shared_with_me(user_id: int) -> dict:
) )
)).scalars().all() )).scalars().all()
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id") seen_notes = best_permission_by(list(note_direct) + list(note_group), "note_id")
notes = [] notes = []
for nid, perm in seen_notes.items(): for nid, perm in seen_notes.items():
@@ -253,7 +254,7 @@ async def list_shared_with_me(user_id: int) -> dict:
"title": note.title, "title": note.title,
"is_task": note.is_task, "is_task": note.is_task,
"project_id": note.project_id, "project_id": note.project_id,
"updated_at": note.updated_at.isoformat(), "updated_at": iso(note.updated_at),
"owner_username": owner.username if owner else None, "owner_username": owner.username if owner else None,
"permission": perm, "permission": perm,
}) })
+32
View File
@@ -181,3 +181,35 @@ async def superseded_ids(note_ids: list[int]) -> set[int]:
.where(NoteSupersession.superseded_id.in_(note_ids)) .where(NoteSupersession.superseded_id.in_(note_ids))
)).scalars().all() )).scalars().all()
return {int(r) for r in rows} return {int(r) for r in rows}
SUPERSEDED_HINT = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def attach_relations(user_id: int, note_id: int, data: dict, *, hint: bool = False) -> None:
"""Add both directions of the supersession relation to a note payload.
ONE seam for the REST and MCP surfaces, which must agree about what a
note's payload says — or the web UI and the agent would disagree about
whether a record is current. Both directions, because they answer
different questions and only one is obvious: `supersedes` is what the
author claimed; `superseded_by` is what a READER needs and what the note
itself cannot know — a stale record handed over without that marker gets
acted on confidently, which is worse than never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow
two permanently-empty lists (#2483 — a field that always says nothing
trains readers to skip fields). `hint=True` (the agent surface) also
attaches `superseded_note`, the one-sentence reading instruction.
"""
rel = await get_relations(user_id, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
if hint:
data["superseded_note"] = SUPERSEDED_HINT
+6 -9
View File
@@ -8,15 +8,16 @@ trashed rows via `alive()`.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select, update from sqlalchemy import delete as sql_delete, or_, select, update
from scribe.models import async_session from scribe.models import async_session
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.rulebook import Rulebook, RulebookTopic, Rule from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
from scribe.models.base import iso
# entity_type -> Model. Used to resolve which table a trash op targets. # entity_type -> Model. Used to resolve which table a trash op targets.
_MODEL_FOR = { _MODEL_FOR = {
@@ -87,16 +88,15 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
# FK CASCADE would handle a full DELETE on the project row, but the # FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the # soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged. # rows are gone whether or not the project ever gets purged.
from sqlalchemy import delete as _sql_delete
from scribe.models.rulebook import ( from scribe.models.rulebook import (
project_rule_suppressions, project_topic_suppressions, project_rule_suppressions, project_topic_suppressions,
) )
await session.execute( await session.execute(
_sql_delete(project_rule_suppressions) sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid) .where(project_rule_suppressions.c.project_id == eid)
) )
await session.execute( await session.execute(
_sql_delete(project_topic_suppressions) sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid) .where(project_topic_suppressions.c.project_id == eid)
) )
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now) await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
@@ -213,7 +213,6 @@ async def restore_entity(user_id: int, entity_type: str, entity_id: int) -> int
async def purge(user_id: int, batch_id: str) -> int: async def purge(user_id: int, batch_id: str) -> int:
"""Hard-delete every row in the batch. Irreversible.""" """Hard-delete every row in the batch. Irreversible."""
from sqlalchemy import delete as sql_delete
n = 0 n = 0
async with async_session() as session: async with async_session() as session:
for model in _ALL: for model in _ALL:
@@ -241,7 +240,7 @@ async def list_trash(user_id: int) -> list[dict]:
grp = batches.setdefault( grp = batches.setdefault(
r.deleted_batch_id, r.deleted_batch_id,
{"batch_id": r.deleted_batch_id, {"batch_id": r.deleted_batch_id,
"deleted_at": r.deleted_at.isoformat() if r.deleted_at else None, "deleted_at": iso(r.deleted_at),
"items": []}, "items": []},
) )
grp["items"].append({ grp["items"].append({
@@ -265,8 +264,6 @@ async def purge_expired(user_id: int, retention_days: int) -> int:
user's short window prematurely destroy another's data. user's short window prematurely destroy another's data.
retention_days <= 0 disables auto-purge (returns 0 without touching anything). retention_days <= 0 disables auto-purge (returns 0 without touching anything).
""" """
from datetime import timedelta
from sqlalchemy import delete as sql_delete
if retention_days <= 0: if retention_days <= 0:
return 0 return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
+24 -54
View File
@@ -1,80 +1,50 @@
"""Daily APScheduler cron that purges expired trash. """Daily APScheduler cron that purges expired trash.
Mirrors version_pinning_scheduler.py: a single global BackgroundScheduler job A single job at 03:30 UTC (services/scheduler.py). Iterates every user and
at 03:30 UTC bridges into the asyncio loop to run the async purge. Iterates applies that user's own `trash_retention_days` setting; 0 disables auto-purge
every user and applies that user's own `trash_retention_days` setting; 0 for that user.
disables auto-purge for that user.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.user import User
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.scheduler import ScheduledJob
from scribe.services.settings import get_setting from scribe.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
async def _run_purge() -> None:
async with async_session() as session:
user_ids = (await session.execute(select(User.id))).scalars().all()
def _run_purge_threadsafe() -> None: purged = 0
"""APScheduler invokes this from a worker thread; bridge into the loop.""" for uid in user_ids:
if _loop is None: raw = await get_setting(uid, "trash_retention_days", "90")
logger.warning("trash scheduler: no loop registered")
return
async def _runner():
try: try:
from sqlalchemy import select days = int(raw)
except (TypeError, ValueError):
days = 90
purged += await trash_svc.purge_expired(uid, days)
if purged:
logger.info("trash purge: removed %d expired row(s)", purged)
else:
logger.debug("trash purge: nothing expired")
from scribe.models import async_session
from scribe.models.user import User
async with async_session() as session: _JOB = ScheduledJob("trash_retention_purge", _run_purge, label="Trash retention")
user_ids = (await session.execute(select(User.id))).scalars().all()
purged = 0
for uid in user_ids:
raw = await get_setting(uid, "trash_retention_days", "90")
try:
days = int(raw)
except (TypeError, ValueError):
days = 90
purged += await trash_svc.purge_expired(uid, days)
if purged:
logger.info("trash purge: removed %d expired row(s)", purged)
else:
logger.debug("trash purge: nothing expired")
except Exception:
logger.exception("trash purge run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
def start_trash_scheduler(loop: asyncio.AbstractEventLoop) -> None: def start_trash_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop _JOB.start(loop, CronTrigger(hour=3, minute=30, timezone="UTC"), describe="daily 03:30 UTC")
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_purge_threadsafe,
trigger=CronTrigger(hour=3, minute=30, timezone="UTC"),
id="trash_retention_purge",
replace_existing=True,
)
_scheduler.start()
logger.info("Trash retention scheduler started (daily 03:30 UTC)")
def stop_trash_scheduler() -> None: def stop_trash_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Trash retention scheduler stopped")
@@ -5,68 +5,39 @@ system promotes stable note versions before they get aged out of the
rolling cap. Off-hours by design — the scan is cheap but not time- rolling cap. Off-hours by design — the scan is cheap but not time-
critical and doesn't need to interrupt regular activity. critical and doesn't need to interrupt regular activity.
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by One ScheduledJob (services/scheduler.py), like the other *_scheduler modules.
journal_scheduler.py.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from scribe.services.scheduler import ScheduledJob
from scribe.services.version_pinning import scan_all_users_for_auto_pins from scribe.services.version_pinning import scan_all_users_for_auto_pins
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None async def _run_scan() -> None:
results = await scan_all_users_for_auto_pins()
total = sum(results.values())
if total > 0:
logger.info(
"auto-pin scan: pinned %d version(s) across %d user(s)",
total, len(results),
)
else:
logger.debug("auto-pin scan: no new pins")
def _run_scan_threadsafe() -> None: _JOB = ScheduledJob("version_pinning_auto_scan", _run_scan, label="Version pinning")
"""APScheduler invokes this from a worker thread; bridge into the
asyncio loop so the scan can await its DB operations."""
if _loop is None:
logger.warning("version_pinning scheduler: no loop registered")
return
async def _runner():
try:
results = await scan_all_users_for_auto_pins()
total = sum(results.values())
if total > 0:
logger.info(
"auto-pin scan: pinned %d version(s) across %d user(s)",
total, len(results),
)
else:
logger.debug("auto-pin scan: no new pins")
except Exception:
logger.exception("auto-pin scan run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
def start_version_pinning_scheduler(loop: asyncio.AbstractEventLoop) -> None: def start_version_pinning_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop _JOB.start(loop, CronTrigger(hour=3, minute=0, timezone="UTC"), describe="daily 03:00 UTC")
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_scan_threadsafe,
trigger=CronTrigger(hour=3, minute=0, timezone="UTC"),
id="version_pinning_auto_scan",
replace_existing=True,
)
_scheduler.start()
logger.info("Version pinning scheduler started (daily 03:00 UTC)")
def stop_version_pinning_scheduler() -> None: def stop_version_pinning_scheduler() -> None:
global _scheduler _JOB.stop()
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Version pinning scheduler stopped")
+128 -21
View File
@@ -6,6 +6,8 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -48,30 +50,135 @@ async def ensure_user(session, username: str, role: str = "user"):
return user return user
def fake_note(**attrs) -> MagicMock: def fake_record(**attrs) -> MagicMock:
"""A MagicMock note with REAL values on every attribute the product reads """A MagicMock record with REAL values on the attributes named, and a
to label, scope, or render a record. ``to_dict()`` that mirrors them.
The hazard this exists for (note 2109): an auto-created MagicMock attribute The hazard this exists for (note 2109): an auto-created MagicMock attribute
is truthy and has a repr. The injected menu reads ``is_task`` / is truthy and has a repr — so a bare MagicMock handed to the product reads
``task_kind`` / ``note_type`` for its kind marker, ``user_id`` to decide as trashed, shared, a task, and owned by a MagicMock. Name every attribute
whether a line needs a "shared by …" attribution, ``data`` for a snippet's the code under test will read; the per-model ``fake_*`` builders below
language tag, and ``deleted_at`` to spot trash — on a bare MagicMock every carry the ordinary defaults so a call site states only what the test is
record renders as another user's trashed task with a mock repr for a about. ``created_at`` / ``updated_at`` are set as attributes but kept out
language. Defaults below are the ORDINARY state (own note, live, no of ``to_dict()`` (no test serialises them, and the real models isoformat
structured data); override what the test is about. them).
``to_dict()`` returns the same values as a plain dict, so a tool that
repackages ``note.to_dict()`` sees keys that agree with the attributes.
""" """
values = { n = MagicMock()
for key, value in attrs.items():
setattr(n, key, value)
n.to_dict.return_value = {
k: v for k, v in attrs.items() if k not in ("created_at", "updated_at")
}
return n
def _with_defaults(defaults: dict, attrs: dict) -> MagicMock:
values = dict(defaults)
values.update(attrs)
return fake_record(**values)
def _now():
return datetime.now(timezone.utc)
def fake_note(**attrs) -> MagicMock:
"""A stand-in Note: own (user_id=7, the caller `_bind_user` binds), live,
not a task, no structured data. The injected menu reads is_task /
task_kind / note_type for its kind marker, user_id for the "shared by …"
attribution, data for a snippet's language, deleted_at for trash."""
return _with_defaults({
"id": 1, "title": "t", "body": "", "tags": [], "user_id": 7, "id": 1, "title": "t", "body": "", "tags": [], "user_id": 7,
"note_type": "note", "is_task": False, "task_kind": "work", "note_type": "note", "is_task": False, "task_kind": "work",
"data": None, "deleted_at": None, "data": None, "deleted_at": None,
} }, attrs)
values.update(attrs)
n = MagicMock()
for key, value in values.items(): def fake_task(**attrs) -> MagicMock:
setattr(n, key, value) """A stand-in task note — get_task reads parent_id, deleted_at, user_id."""
n.to_dict.return_value = dict(values) return _with_defaults({
return n "id": 1, "title": "t", "body": "", "status": "todo", "priority": "none",
"tags": [], "parent_id": None, "project_id": None, "is_task": True,
"task_kind": "work", "user_id": 7, "deleted_at": None,
}, attrs)
def fake_snippet(**attrs) -> MagicMock:
"""A stand-in snippet note. ``data`` is explicitly None: snippet_fields
prefers `data` when truthy, and a MagicMock is truthy."""
return _with_defaults({
"id": 1, "title": "debounce — rate-limit a callback",
"body": "```js\nreturn 1\n```\n", "tags": ["js", "snippet"],
"note_type": "snippet", "is_task": False, "task_kind": "work",
"user_id": 7, "data": None, "deleted_at": None,
}, attrs)
def fake_project(**attrs) -> MagicMock:
"""design_system_id is explicit: a truthy auto-attribute would route every
project through the design-system branch and out to a real database."""
return _with_defaults({
"id": 1, "title": "P", "description": "", "goal": "", "status": "active",
"color": None, "design_system_id": None, "user_id": 7,
}, attrs)
def fake_milestone(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "project_id": 1, "title": "MS", "description": None,
"status": "active", "order_index": 0,
}, attrs)
def fake_system(**attrs) -> MagicMock:
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
def fake_rulebook(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "owner_user_id": 7, "title": "FabledSword family",
"description": "", "created_at": _now(), "updated_at": _now(),
}, attrs)
def fake_topic(**attrs) -> MagicMock:
return _with_defaults({
"id": 10, "rulebook_id": 1, "title": "git-workflow", "description": "",
"order_index": 0, "created_at": _now(), "updated_at": _now(),
}, attrs)
def fake_rule(**attrs) -> MagicMock:
return _with_defaults({
"id": 1, "topic_id": 10, "title": "dev is home",
"statement": "Work directly on dev", "why": "", "how_to_apply": "",
"order_index": 0, "created_at": _now(), "updated_at": _now(),
}, attrs)
class FakeMCP:
"""Stand-in for the FastMCP server a tool module's ``register(mcp)`` is
handed: records the ``name=`` of every ``@mcp.tool(...)`` registration in
``names`` and leaves the function untouched, so a test can assert which
tools a module exposes."""
def __init__(self) -> None:
self.names: list[str] = []
def tool(self, name=None):
self.names.append(name)
return lambda fn: fn
def loc(path: str = "", repo: str = "", symbol: str = "") -> dict:
"""One snippet location, in the shape the record stores."""
return {"repo": repo, "path": path, "symbol": symbol}
def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
order_index=0, supersedes=None) -> SimpleNamespace:
"""A design-token row as the cascade / stylesheet code reads it."""
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
+6 -9
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services.api_keys import ( from scribe.services.api_keys import (
_hash_key, hash_token,
_key_prefix, _key_prefix,
generate_key, generate_key,
create_api_key, create_api_key,
@@ -13,6 +13,7 @@ from scribe.services.api_keys import (
revoke_api_key, revoke_api_key,
lookup_key, lookup_key,
) )
from tests.helpers import make_mock_session
def test_generate_key_format(): def test_generate_key_format():
@@ -28,7 +29,7 @@ def test_generate_key_uniqueness():
def test_hash_key_is_sha256(): def test_hash_key_is_sha256():
key = "fmcp_testkey" key = "fmcp_testkey"
h = _hash_key(key) h = hash_token(key)
expected = hashlib.sha256(key.encode()).hexdigest() expected = hashlib.sha256(key.encode()).hexdigest()
assert h == expected assert h == expected
@@ -45,9 +46,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock() mock_session.add = MagicMock()
mock_session.commit = AsyncMock() mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock(side_effect=lambda obj: None) mock_session.refresh = AsyncMock(side_effect=lambda obj: None)
@@ -67,9 +66,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown(): async def test_lookup_key_returns_none_for_unknown():
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_result = MagicMock() mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None mock_result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
@@ -82,7 +79,7 @@ async def test_lookup_key_returns_none_for_unknown():
def test_hash_key_deterministic(): def test_hash_key_deterministic():
key = "fmcp_some_test_key_value" key = "fmcp_some_test_key_value"
assert _hash_key(key) == _hash_key(key) assert hash_token(key) == hash_token(key)
@pytest.mark.asyncio @pytest.mark.asyncio
+36 -46
View File
@@ -7,6 +7,7 @@ own import-free module — see services/design_cascade.py.
from types import SimpleNamespace from types import SimpleNamespace
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
from tests.helpers import design_token_stub
# --- ancestry --------------------------------------------------------------- # --- ancestry ---------------------------------------------------------------
@@ -102,14 +103,6 @@ def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
# because resolve_tokens is pure and duck-typed — which is the whole reason it # because resolve_tokens is pure and duck-typed — which is the whole reason it
# lives here rather than inside the service. # lives here rather than inside the service.
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
supersedes=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
# A family (1) and an app inheriting from it (2) — the shape the model exists for. # A family (1) and an app inheriting from it (2) — the shape the model exists for.
FAMILY, APP = 1, 2 FAMILY, APP = 1, 2
PARENTS = {FAMILY: None, APP: FAMILY} PARENTS = {FAMILY: None, APP: FAMILY}
@@ -124,7 +117,7 @@ def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
and the state every app system starts in.""" and the state every app system starts in."""
resolved = resolve_tokens( resolved = resolve_tokens(
APP, PARENTS, APP, PARENTS,
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []}, {FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"})], APP: []},
) )
assert [t.name for t in resolved] == ["--fs-obsidian"] assert [t.name for t in resolved] == ["--fs-obsidian"]
assert resolved[0].value_by_mode == {"base": "#14171a"} assert resolved[0].value_by_mode == {"base": "#14171a"}
@@ -138,8 +131,8 @@ def test_the_deepest_system_wins_and_says_what_it_overrode():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -158,8 +151,8 @@ def test_overriding_one_mode_leaves_the_others_inherited():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#34a877", "dark": "#34a877"})],
APP: [_token("--fs-accent", {"base": "#15803d"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#15803d"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -172,7 +165,7 @@ def test_a_token_only_the_app_defines_is_not_an_override():
labelled both "overridden here" would misdescribe the first.""" labelled both "overridden here" would misdescribe the first."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]}, {FAMILY: [], APP: [design_token_stub(name="--fs-editor-caret", value_by_mode={"base": "#5b4a8a"})]},
)) ))
caret = resolved["--fs-editor-caret"] caret = resolved["--fs-editor-caret"]
assert caret.origin_by_mode == {"base": APP} assert caret.origin_by_mode == {"base": APP}
@@ -183,8 +176,8 @@ def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
accent = resolved["--fs-accent"] accent = resolved["--fs-accent"]
@@ -198,9 +191,9 @@ def test_three_levels_stack_nearest_first():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
3, parents, 3, parents,
{ {
1: [_token("--fs-bg", {"base": "a"})], 1: [design_token_stub(name="--fs-bg", value_by_mode={"base": "a"})],
2: [_token("--fs-bg", {"base": "b"})], 2: [design_token_stub(name="--fs-bg", value_by_mode={"base": "b"})],
3: [_token("--fs-bg", {"base": "c"})], 3: [design_token_stub(name="--fs-bg", value_by_mode={"base": "c"})],
}, },
)) ))
bg = resolved["--fs-bg"] bg = resolved["--fs-bg"]
@@ -214,8 +207,8 @@ def test_resolving_the_family_itself_ignores_its_children():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{ {
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})], FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})], APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
}, },
)) ))
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"} assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
@@ -226,7 +219,7 @@ def test_value_for_falls_back_to_the_base_mode():
"dark" must yield that rather than nothing — the read rule the storage shape "dark" must yield that rather than nothing — the read rule the storage shape
implies.""" implies."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
)) ))
radius = resolved["--fs-radius-md"] radius = resolved["--fs-radius-md"]
assert radius.value_for("dark") == "8px" assert radius.value_for("dark") == "8px"
@@ -236,7 +229,7 @@ def test_value_for_falls_back_to_the_base_mode():
def test_value_for_prefers_an_explicit_mode_over_the_fallback(): def test_value_for_prefers_an_explicit_mode_over_the_fallback():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]}, {FAMILY: [design_token_stub(name="--fs-bg", value_by_mode={"base": "#f7f5ef", "dark": "#14171a"})]},
)) ))
assert resolved["--fs-bg"].value_for("dark") == "#14171a" assert resolved["--fs-bg"].value_for("dark") == "#14171a"
@@ -248,11 +241,8 @@ def test_metadata_is_inherited_when_the_override_leaves_it_blank():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token( FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface", purpose="page bg, deepest surface")],
"--fs-obsidian", {"base": "#14171a"}, APP: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#101317"})],
group_name="surface", purpose="page bg, deepest surface",
)],
APP: [_token("--fs-obsidian", {"base": "#101317"})],
}, },
)) ))
obsidian = resolved["--fs-obsidian"] obsidian = resolved["--fs-obsidian"]
@@ -265,8 +255,8 @@ def test_an_override_that_states_metadata_wins_it_too():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")], FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, purpose="family says")],
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")], APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"}, purpose="app says")],
}, },
)) ))
assert resolved["--fs-x"].purpose == "app says" assert resolved["--fs-x"].purpose == "app says"
@@ -279,8 +269,8 @@ def test_an_override_at_default_order_keeps_the_familys_position():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)], FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, order_index=7)],
APP: [_token("--fs-x", {"base": "b"})], APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"})],
}, },
)) ))
assert resolved["--fs-x"].order_index == 7 assert resolved["--fs-x"].order_index == 7
@@ -290,10 +280,10 @@ def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last
resolved = resolve_tokens( resolved = resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [ {FAMILY: [
_token("--fs-z", {"base": "1"}), # ungrouped design_token_stub(name="--fs-z", value_by_mode={"base": "1"}), # ungrouped
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1), design_token_stub(name="--fs-b", value_by_mode={"base": "2"}, group_name="text", order_index=1),
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2), design_token_stub(name="--fs-a", value_by_mode={"base": "3"}, group_name="surface", order_index=2),
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1), design_token_stub(name="--fs-c", value_by_mode={"base": "4"}, group_name="surface", order_index=1),
]}, ]},
) )
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"] assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
@@ -306,7 +296,7 @@ def test_resolution_terminates_on_a_corrupt_hierarchy():
parents = {1: 2, 2: 1} parents = {1: 2, 2: 1}
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
1, parents, 1, parents,
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]}, {1: [design_token_stub(name="--fs-a", value_by_mode={"base": "one"})], 2: [design_token_stub(name="--fs-b", value_by_mode={"base": "two"})]},
)) ))
assert set(resolved) == {"--fs-a", "--fs-b"} assert set(resolved) == {"--fs-a", "--fs-b"}
# Each system contributes exactly once, not endlessly. # Each system contributes exactly once, not endlessly.
@@ -326,8 +316,8 @@ def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])], FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "#f0ece0"})], APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "#f0ece0"})],
}, },
)) ))
text = resolved["--fs-text"] text = resolved["--fs-text"]
@@ -341,8 +331,8 @@ def test_an_override_that_states_its_own_supersedes_replaces_the_list():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
APP, PARENTS, APP, PARENTS,
{ {
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])], FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "a"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])], APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "b"}, supersedes=["#fff"])],
}, },
)) ))
assert resolved["--fs-text"].supersedes == ("#fff",) assert resolved["--fs-text"].supersedes == ("#fff",)
@@ -352,7 +342,7 @@ def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
"""Most tokens replace nothing. That has to be an empty sequence rather than """Most tokens replace nothing. That has to be an empty sequence rather than
None, so no caller has to test for two kinds of nothing.""" None, so no caller has to test for two kinds of nothing."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
)) ))
assert resolved["--fs-radius-md"].supersedes == () assert resolved["--fs-radius-md"].supersedes == ()
@@ -360,7 +350,7 @@ def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
def test_supersedes_survives_serialisation_as_a_list(): def test_supersedes_survives_serialisation_as_a_list():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]}, {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
)) ))
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"] assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
@@ -372,7 +362,7 @@ def test_the_superseded_literal_need_not_match_the_tokens_own_value():
it was turned around.""" it was turned around."""
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]}, {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
)) ))
text = resolved["--fs-text"] text = resolved["--fs-text"]
assert text.value_by_mode["base"] not in text.supersedes assert text.value_by_mode["base"] not in text.supersedes
@@ -404,7 +394,7 @@ def test_rationale_cascades_like_purpose_and_is_a_different_question():
rationale="equals Moss, aligned by design", rationale="equals Moss, aligned by design",
order_index=0, supersedes=[], order_index=0, supersedes=[],
)], )],
APP: [_token("--fs-success", {"base": "#3f5236"})], APP: [design_token_stub(name="--fs-success", value_by_mode={"base": "#3f5236"})],
}, },
)) ))
token = resolved["--fs-success"] token = resolved["--fs-success"]
@@ -415,6 +405,6 @@ def test_rationale_cascades_like_purpose_and_is_a_different_question():
def test_a_token_without_a_rationale_resolves_to_none(): def test_a_token_without_a_rationale_resolves_to_none():
resolved = _by_name(resolve_tokens( resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]}, FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})]},
)) ))
assert resolved["--fs-x"].rationale is None assert resolved["--fs-x"].rationale is None
+21 -28
View File
@@ -17,13 +17,7 @@ from scribe.services.design_stylesheet import (
safe_value, safe_value,
selector_for_mode, selector_for_mode,
) )
from tests.helpers import design_token_stub
def _token(name, value_by_mode, group_name=None, purpose=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode,
group_name=group_name, purpose=purpose,
)
# --- safety ----------------------------------------------------------------- # --- safety -----------------------------------------------------------------
@@ -66,7 +60,7 @@ def test_a_rejected_value_is_dropped_not_cleaned_up():
"""Rejecting beats stripping. A partially-sanitised value is one the operator """Rejecting beats stripping. A partially-sanitised value is one the operator
never wrote, and the sheet's entire claim is that it IS the record — quietly never wrote, and the sheet's entire claim is that it IS the record — quietly
rendering a different colour would break that claim invisibly.""" rendering a different colour would break that claim invisibly."""
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})]) css = render_stylesheet([design_token_stub(name="--fs-x", value_by_mode={"base": "red; } body { color: blue"})])
assert "body" not in css assert "body" not in css
assert "value rejected" in css assert "value rejected" in css
@@ -85,7 +79,7 @@ def test_a_malformed_token_name_is_dropped():
assert not is_valid_token_name("color: red") assert not is_valid_token_name("color: red")
assert not is_valid_token_name("fs-obsidian") # no leading -- assert not is_valid_token_name("fs-obsidian") # no leading --
css = render_stylesheet([_token("--bad name", {"base": "red"})]) css = render_stylesheet([design_token_stub(name="--bad name", value_by_mode={"base": "red"})])
assert "bad name" not in css assert "bad name" not in css
@@ -100,8 +94,8 @@ def test_the_sheet_declares_properties_and_styles_no_elements():
handful of values once per element and grow with the UI. Purpose tokens are handful of values once per element and grow with the UI. Purpose tokens are
stated once and reused; components are snippets that reference them.""" stated once and reused; components are snippets that reference them."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface"),
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"), design_token_stub(name="--fs-moss", value_by_mode={"base": "#4a5d3f"}, group_name="action"),
]) ])
assert "--fs-obsidian: #14171a;" in css assert "--fs-obsidian: #14171a;" in css
# No element or class rules — the sheet has exactly one block here, and # No element or class rules — the sheet has exactly one block here, and
@@ -117,7 +111,7 @@ def test_the_sheet_declares_properties_and_styles_no_elements():
def test_the_header_says_what_the_sheet_is_for(): def test_the_header_says_what_the_sheet_is_for():
"""A generated file with no explanation gets hand-edited, and then it has """A generated file with no explanation gets hand-edited, and then it has
diverged from the record it claims to be.""" diverged from the record it claims to be."""
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword") css = render_stylesheet([design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})], title="FabledSword")
assert "FabledSword" in css assert "FabledSword" in css
assert "Generated" in css assert "Generated" in css
assert "snippets" in css assert "snippets" in css
@@ -129,7 +123,7 @@ def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
"""Matches the convention already in the codebase, and the one-way scoping """Matches the convention already in the codebase, and the one-way scoping
#251 recorded: light on `:root`, dark layered on an attribute selector.""" #251 recorded: light on `:root`, dark layered on an attribute selector."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}), design_token_stub(name="--fs-bg", value_by_mode={"base": "#f5f1e8", "dark": "#14171a"}),
]) ])
assert ":root {" in css assert ":root {" in css
assert '[data-theme="dark"] {' in css assert '[data-theme="dark"] {' in css
@@ -141,8 +135,8 @@ def test_a_mode_block_contains_only_what_that_mode_declares():
Repeating every token in every block would make the sheet claim each mode Repeating every token in every block would make the sheet claim each mode
redefines the whole system.""" redefines the whole system."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}), design_token_stub(name="--fs-bg", value_by_mode={"base": "#f5f1e8", "dark": "#14171a"}),
_token("--fs-radius-md", {"base": "8px"}), design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"}),
]) ])
dark_block = css.split('[data-theme="dark"] {')[1] dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-bg" in dark_block assert "--fs-bg" in dark_block
@@ -153,7 +147,7 @@ def test_the_root_selector_is_caller_chosen():
"""A container-scoped preview cannot use `:root`. A generator that hardcoded """A container-scoped preview cannot use `:root`. A generator that hardcoded
it could not serve the preview surface at all.""" it could not serve the preview surface at all."""
css = render_stylesheet( css = render_stylesheet(
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]" [design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})], root_selector="[data-preview]"
) )
assert "[data-preview] {" in css assert "[data-preview] {" in css
assert ":root {" not in css assert ":root {" not in css
@@ -163,8 +157,8 @@ def test_the_root_selector_is_caller_chosen():
def test_tokens_are_grouped_by_purpose_with_the_group_named(): def test_tokens_are_grouped_by_purpose_with_the_group_named():
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface"),
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"), design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"}, group_name="radius"),
]) ])
assert "/* surface */" in css assert "/* surface */" in css
assert "/* radius */" in css assert "/* radius */" in css
@@ -174,8 +168,7 @@ def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
"""Repeating the same prose in every mode block is noise: the token means """Repeating the same prose in every mode block is noise: the token means
the same thing in dark mode.""" the same thing in dark mode."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"}, design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a", "dark": "#000000"}, purpose="page bg, deepest surface"),
purpose="page bg, deepest surface"),
]) ])
assert css.count("page bg, deepest surface") == 1 assert css.count("page bg, deepest surface") == 1
@@ -185,8 +178,8 @@ def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
that finding where the reader is already looking; dropping it would make the that finding where the reader is already looking; dropping it would make the
sheet look complete.""" sheet look complete."""
css = render_stylesheet([ css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}),
_token("--fs-radius-sm", {}), design_token_stub(name="--fs-radius-sm", value_by_mode={}),
]) ])
assert "--fs-radius-sm" in css assert "--fs-radius-sm" in css
assert "no value set yet" in css assert "no value set yet" in css
@@ -210,9 +203,9 @@ def test_two_tokens_sharing_a_value_are_reported_not_refused():
purpose — "Success = Moss, by design" — so this reports and lets a human purpose — "Success = Moss, by design" — so this reports and lets a human
decide which it is.""" decide which it is."""
dupes = duplicate_values([ dupes = duplicate_values([
_token("--fs-moss", {"base": "#4A5D3F"}), design_token_stub(name="--fs-moss", value_by_mode={"base": "#4A5D3F"}),
_token("--fs-success", {"base": "#4a5d3f"}), design_token_stub(name="--fs-success", value_by_mode={"base": "#4a5d3f"}),
_token("--fs-obsidian", {"base": "#14171a"}), design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}),
]) ])
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]} assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
@@ -221,15 +214,15 @@ def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates(
"""A near-miss is a different, weaker finding, and calling it a duplicate """A near-miss is a different, weaker finding, and calling it a duplicate
would send someone to merge two tokens that genuinely diverge.""" would send someone to merge two tokens that genuinely diverge."""
assert duplicate_values([ assert duplicate_values([
_token("--fs-a", {"base": "#fff", "dark": "#000"}), design_token_stub(name="--fs-a", value_by_mode={"base": "#fff", "dark": "#000"}),
_token("--fs-b", {"base": "#fff", "dark": "#111"}), design_token_stub(name="--fs-b", value_by_mode={"base": "#fff", "dark": "#111"}),
]) == {"#fff": ["--fs-a", "--fs-b"]} ]) == {"#fff": ["--fs-a", "--fs-b"]}
def test_valueless_tokens_never_count_as_duplicates_of_each_other(): def test_valueless_tokens_never_count_as_duplicates_of_each_other():
"""Otherwise every unfilled token would collide with every other one and the """Otherwise every unfilled token would collide with every other one and the
report would be nothing but noise on a fresh import.""" report would be nothing but noise on a fresh import."""
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {} assert duplicate_values([design_token_stub(name="--fs-a", value_by_mode={}), design_token_stub(name="--fs-b", value_by_mode={})]) == {}
# --- reading the sheet from the other side ---------------------------------- # --- reading the sheet from the other side ----------------------------------
+5 -8
View File
@@ -31,14 +31,11 @@ from scribe.services.snippets import (
list_snippets, list_snippets,
snippet_fields, snippet_fields,
) )
from tests.helpers import loc
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
def _loc(repo="", path="", symbol=""):
return {"repo": repo, "path": path, "symbol": symbol}
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seeded(): async def seeded():
"""A user with four snippets covering the cases the filter has to separate. """A user with four snippets covering the cases the filter has to separate.
@@ -60,13 +57,13 @@ async def seeded():
data=compose_data(name=name, language="python", locations=locations), data=compose_data(name=name, language="python", locations=locations),
) )
nested = _snippet("nested", [_loc("Scribe", "frontend/src/lib/x.ts", "helper")]) nested = _snippet("nested", [loc(repo="Scribe", path="frontend/src/lib/x.ts", symbol="helper")])
sibling = _snippet("sibling", [_loc("Scribe", "frontend/srcmap.ts", "other")]) sibling = _snippet("sibling", [loc(repo="Scribe", path="frontend/srcmap.ts", symbol="other")])
# Two locations, deliberately crossed: repo Scribe at src/a.py and repo # Two locations, deliberately crossed: repo Scribe at src/a.py and repo
# Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it. # Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it.
multi = _snippet( multi = _snippet(
"multi", "multi",
[_loc("Scribe", "src/a.py", "alpha"), _loc("Portal", "src/b.py", "beta")], [loc(repo="Scribe", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")],
) )
# No structured location at all — must never satisfy a location filter, # No structured location at all — must never satisfy a location filter,
# and must not error the query either. # and must not error the query either.
@@ -242,7 +239,7 @@ async def test_backfill_makes_a_pre_0070_snippet_findable_by_location(seeded):
defaults to none_as_null=False) — a different state, covered by the next test. defaults to none_as_null=False) — a different state, covered by the next test.
""" """
user_id, _ids = seeded user_id, _ids = seeded
locations = [_loc("Legacy", "old/path/y.py", "legacy_helper")] locations = [loc(repo="Legacy", path="old/path/y.py", symbol="legacy_helper")]
async with async_session() as s: async with async_session() as s:
old = Note( old = Note(
user_id=user_id, user_id=user_id,
+13 -11
View File
@@ -3,20 +3,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.mcp.auth import resolve_bearer, resolve_bearer_to_user_id from scribe.mcp.auth import resolve_bearer
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_bearer_missing_header_returns_none(): async def test_resolve_bearer_missing_header_returns_none():
assert await resolve_bearer_to_user_id(None) is None assert await resolve_bearer(None) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_bearer_malformed_header_returns_none(): async def test_resolve_bearer_malformed_header_returns_none():
assert await resolve_bearer_to_user_id("Token abc") is None assert await resolve_bearer("Token abc") is None
assert await resolve_bearer_to_user_id("Bearer") is None assert await resolve_bearer("Bearer") is None
assert await resolve_bearer_to_user_id("Bearer ") is None assert await resolve_bearer("Bearer ") is None
assert await resolve_bearer_to_user_id("") is None assert await resolve_bearer("") is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -25,19 +25,20 @@ async def test_resolve_bearer_unknown_token_returns_none():
"scribe.mcp.auth.lookup_key", "scribe.mcp.auth.lookup_key",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
assert await resolve_bearer_to_user_id("Bearer fmcp_doesnotexist") is None assert await resolve_bearer("Bearer fmcp_doesnotexist") is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_bearer_valid_token_returns_user_id(): async def test_resolve_bearer_valid_token_returns_user_id():
fake_key = MagicMock() fake_key = MagicMock()
fake_key.user_id = 42 fake_key.user_id = 42
fake_key.scope = "write"
with patch( with patch(
"scribe.mcp.auth.lookup_key", "scribe.mcp.auth.lookup_key",
AsyncMock(return_value=fake_key), AsyncMock(return_value=fake_key),
): ):
uid = await resolve_bearer_to_user_id("Bearer fmcp_validkey") uid, scope = await resolve_bearer("Bearer fmcp_validkey")
assert uid == 42 assert (uid, scope) == (42, "write")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -45,13 +46,14 @@ async def test_resolve_bearer_calls_lookup_with_stripped_token():
"""The Bearer prefix and any trailing whitespace must be stripped before lookup.""" """The Bearer prefix and any trailing whitespace must be stripped before lookup."""
fake_key = MagicMock() fake_key = MagicMock()
fake_key.user_id = 1 fake_key.user_id = 1
fake_key.scope = "write"
mock_lookup = AsyncMock(return_value=fake_key) mock_lookup = AsyncMock(return_value=fake_key)
with patch("scribe.mcp.auth.lookup_key", mock_lookup): with patch("scribe.mcp.auth.lookup_key", mock_lookup):
await resolve_bearer_to_user_id("Bearer fmcp_abc123 ") await resolve_bearer("Bearer fmcp_abc123 ")
mock_lookup.assert_awaited_once_with("fmcp_abc123") mock_lookup.assert_awaited_once_with("fmcp_abc123")
# ── resolve_bearer (user_id + scope) ──────────────────────────────────── # ── scope ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_bearer_returns_user_id_and_scope(): async def test_resolve_bearer_returns_user_id_and_scope():
+12 -20
View File
@@ -5,26 +5,23 @@ calling convention meets the service's: an agent cannot omit an argument, so
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting "leave unchanged", "clear" and "set" have to be encoded in the value. Getting
that mapping wrong is silent the call succeeds and changes the wrong thing. that mapping wrong is silent the call succeeds and changes the wrong thing.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from scribe.services.design_systems import DesignSystemCycle from scribe.services.design_systems import DesignSystemCycle
from tests.helpers import design_token_stub, fake_record
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_system(): def _fake_design_system():
s = MagicMock() return fake_record(id=1, title="FabledSword", parent_id=None)
s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None}
return s
def _fake_token(): def _fake_token():
t = MagicMock() return fake_record(id=9, name="--fs-obsidian")
t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"}
return t
# --- create ----------------------------------------------------------------- # --- create -----------------------------------------------------------------
@@ -35,7 +32,7 @@ async def test_creating_without_a_parent_passes_none_not_zero():
system id there is no system 0, so the create would fail an ACL check for system id there is no system 0, so the create would fail an ACL check for
a record that cannot exist.""" a record that cannot exist."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system()) svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import create_design_system from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword") await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@@ -44,7 +41,7 @@ async def test_creating_without_a_parent_passes_none_not_zero():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through(): async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system()) svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import create_design_system from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="Scribe", parent_id=4) await create_design_system(title="Scribe", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4 assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@@ -65,7 +62,7 @@ async def test_create_raises_when_the_parent_is_not_writable():
async def test_update_with_parent_id_zero_leaves_the_parent_alone(): async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it.""" """The common case — renaming a system must not silently re-root it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed") await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs fields = svc.update_design_system.await_args.kwargs
@@ -79,7 +76,7 @@ async def test_update_with_parent_id_minus_one_clears_it():
None, which is the value the service reads as "become a root" where None, which is the value the service reads as "become a root" where
omitting the key means "leave alone".""" omitting the key means "leave alone"."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1) await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@@ -88,7 +85,7 @@ async def test_update_with_parent_id_minus_one_clears_it():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it(): async def test_update_with_a_positive_parent_id_sets_it():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system()) svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4) await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4 assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@@ -161,16 +158,11 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
resolution was built to preserve.""" resolution was built to preserve."""
from scribe.services.design_cascade import resolve_tokens from scribe.services.design_cascade import resolve_tokens
class _T:
def __init__(self, name, value_by_mode):
self.name, self.value_by_mode = name, value_by_mode
self.group_name = self.purpose = None
self.order_index = 0
resolved = resolve_tokens( resolved = resolve_tokens(
2, {1: None, 2: 1}, 2, {1: None, 2: 1},
{1: [_T("--fs-accent", {"base": "#6b2118"})], {1: [design_token_stub("--fs-accent", {"base": "#6b2118"})],
2: [_T("--fs-accent", {"base": "#5b4a8a"})]}, 2: [design_token_stub("--fs-accent", {"base": "#5b4a8a"})]},
) )
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc: with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved) svc.resolve_design_system = AsyncMock(return_value=resolved)
+10 -18
View File
@@ -6,20 +6,12 @@ import pytest
from scribe.mcp.tools.milestones import ( from scribe.mcp.tools.milestones import (
list_milestones, get_milestone, create_milestone, update_milestone, list_milestones, get_milestone, create_milestone, update_milestone,
) )
from tests.helpers import fake_milestone
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_ms(**overrides) -> MagicMock:
m = MagicMock()
base = {"id": 1, "project_id": 1, "title": "MS", "description": None,
"status": "active", "order_index": 0}
base.update(overrides)
m.to_dict.return_value = base
return m
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_milestones_returns_dict_with_progress(): async def test_list_milestones_returns_dict_with_progress():
rows = [{"id": 1, "title": "MS1", "status": "active", "task_count": 2}] rows = [{"id": 1, "title": "MS1", "status": "active", "task_count": 2}]
@@ -33,7 +25,7 @@ async def test_list_milestones_returns_dict_with_progress():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_passes_through(): async def test_create_milestone_passes_through():
m = _fake_ms(id=5) m = fake_milestone(id=5)
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
out = await create_milestone(project_id=1, title="new", description="d") out = await create_milestone(project_id=1, title="new", description="d")
@@ -45,7 +37,7 @@ async def test_create_milestone_passes_through():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_empty_description_becomes_none(): async def test_create_milestone_empty_description_becomes_none():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", description="") await create_milestone(project_id=1, title="t", description="")
@@ -55,7 +47,7 @@ async def test_create_milestone_empty_description_becomes_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_passes_body_through(): async def test_create_milestone_passes_body_through():
"""The milestone-as-plan body is forwarded to the service.""" """The milestone-as-plan body is forwarded to the service."""
m = _fake_ms(id=5) m = fake_milestone(id=5)
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="## Goal\n\nship") await create_milestone(project_id=1, title="t", body="## Goal\n\nship")
@@ -64,7 +56,7 @@ async def test_create_milestone_passes_body_through():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_milestone_empty_body_becomes_none(): async def test_create_milestone_empty_body_becomes_none():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="") await create_milestone(project_id=1, title="t", body="")
@@ -73,7 +65,7 @@ async def test_create_milestone_empty_body_becomes_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_sends_body(): async def test_update_milestone_sends_body():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, body="new plan") await update_milestone(project_id=1, milestone_id=5, body="new plan")
@@ -82,7 +74,7 @@ async def test_update_milestone_sends_body():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_milestone_returns_body_steps_and_rules(): async def test_get_milestone_returns_body_steps_and_rules():
m = _fake_ms(id=5, project_id=3, body="## Goal") m = fake_milestone(id=5, project_id=3, body="## Goal")
step = MagicMock() step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"} step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
@@ -112,7 +104,7 @@ async def test_get_milestone_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_only_sends_non_default_fields(): async def test_update_milestone_only_sends_non_default_fields():
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, status="done") await update_milestone(project_id=1, milestone_id=5, status="done")
@@ -124,7 +116,7 @@ async def test_update_milestone_only_sends_non_default_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_order_index_negative_is_omitted(): async def test_update_milestone_order_index_negative_is_omitted():
"""order_index=-1 sentinel means leave unchanged.""" """order_index=-1 sentinel means leave unchanged."""
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=-1) await update_milestone(project_id=1, milestone_id=5, order_index=-1)
@@ -134,7 +126,7 @@ async def test_update_milestone_order_index_negative_is_omitted():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_order_index_zero_is_explicit(): async def test_update_milestone_order_index_zero_is_explicit():
"""order_index=0 is a real value (top of list), not a sentinel.""" """order_index=0 is a real value (top of list), not a sentinel."""
m = _fake_ms() m = fake_milestone()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=0) await update_milestone(project_id=1, milestone_id=5, order_index=0)
+4 -16
View File
@@ -1,6 +1,7 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import fake_task
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -18,25 +19,12 @@ async def test_start_planning_tool_delegates_to_service():
assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"} assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"}
def _plan_note(task_kind: str):
note = MagicMock()
note.parent_id = None
note.project_id = 3
note.id = 9
# Real values — get_task reads deleted_at and compares user_id to the bound
# caller, and a MagicMock is truthy on both (note 2109).
note.user_id = 7
note.deleted_at = None
note.to_dict.return_value = {"id": 9, "task_kind": task_kind, "project_id": 3}
return note
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules(): async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(_plan_note("plan"), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)): AsyncMock(return_value=applicable)):
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
@@ -49,7 +37,7 @@ async def test_get_task_augments_plan_with_rules():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_work_kind_has_no_rules(): async def test_get_task_work_kind_has_no_rules():
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(_plan_note("work"), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="work", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock()) as mock_rules: AsyncMock()) as mock_rules:
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
+4 -11
View File
@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import FakeMCP, fake_note
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -172,18 +172,11 @@ def test_register_attaches_every_tool_in_the_module():
import inspect import inspect
from scribe.mcp.tools import processes from scribe.mcp.tools import processes
names: list[str] = [] mcp = FakeMCP()
class FakeMcp: processes.register(mcp)
def tool(self, name):
names.append(name)
def deco(fn):
return fn
return deco
processes.register(FakeMcp())
public = { public = {
name for name, obj in vars(processes).items() name for name, obj in vars(processes).items()
if inspect.iscoroutinefunction(obj) and not name.startswith("_") if inspect.iscoroutinefunction(obj) and not name.startswith("_")
} }
assert set(names) == public assert set(mcp.names) == public
+15 -34
View File
@@ -7,6 +7,7 @@ from scribe.mcp.tools.projects import (
list_projects, get_project, create_project, list_projects, get_project, create_project,
update_project, enter_project, update_project, enter_project,
) )
from tests.helpers import FakeMCP, fake_project
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -56,22 +57,9 @@ def _no_bootstrap():
yield mock yield mock
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
p = MagicMock()
base = {"id": 1, "title": "P", "description": "", "goal": "",
"status": "active", "color": None}
base.update(overrides)
p.to_dict.return_value = base
# Explicit, because a bare MagicMock hands back a truthy auto-attribute —
# which would route every project in this file through the design-system
# branch and out to a real database.
p.design_system_id = design_system_id
return p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_projects_wraps_in_dict(): async def test_list_projects_wraps_in_dict():
rows = [_fake_project(id=1), _fake_project(id=2)] rows = [fake_project(id=1), fake_project(id=2)]
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.list_projects", "scribe.mcp.tools.projects.projects_svc.list_projects",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
@@ -82,7 +70,7 @@ async def test_list_projects_wraps_in_dict():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_enriches_with_milestone_summary(): async def test_get_project_enriches_with_milestone_summary():
p = _fake_project(id=5, title="found") p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
applicable_payload = { applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [], "rules": [], "truncated": False, "subscribed_rulebooks": [],
@@ -107,7 +95,7 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"""The augmented get_project response includes applicable_rules and """The augmented get_project response includes applicable_rules and
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules.
""" """
p = _fake_project(id=3, title="Fabled Assistant") p = fake_project(id=3, title="Fabled Assistant")
milestone_summary = [] milestone_summary = []
applicable_payload = { applicable_payload = {
"rules": [ "rules": [
@@ -147,7 +135,7 @@ async def test_get_project_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_passes_color_empty_as_none(): async def test_create_project_passes_color_empty_as_none():
p = _fake_project() p = fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock): with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock):
await create_project(title="P", color="") await create_project(title="P", color="")
@@ -156,7 +144,7 @@ async def test_create_project_passes_color_empty_as_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_project_only_sends_non_default_fields(): async def test_update_project_only_sends_non_default_fields():
p = _fake_project() p = fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock): with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock):
await update_project(project_id=1, status="archived") await update_project(project_id=1, status="archived")
@@ -179,7 +167,7 @@ async def test_update_project_raises_when_not_found():
async def test_enter_project_composes_full_context(): async def test_enter_project_composes_full_context():
"""enter_project pulls project + rules + milestone summary + open tasks + """enter_project pulls project + rules + milestone summary + open tasks +
recent notes in one composed call.""" recent notes in one composed call."""
p = _fake_project(id=5, title="P") p = fake_project(id=5, title="P")
applicable_payload = { applicable_payload = {
"rules": [{"id": 1, "title": "r1", "statement": "s", "rules": [{"id": 1, "title": "r1", "statement": "s",
"topic_title": "t", "rulebook_title": "rb"}], "topic_title": "t", "rulebook_title": "rb"}],
@@ -239,7 +227,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
days after the feature landed one System, nothing tagged since July 28 days after the feature landed one System, nothing tagged since July 28
(#2546's audit). Trimmed to id/name/first-line: it rides on every session (#2546's audit). Trimmed to id/name/first-line: it rides on every session
start, and the full charter is get_system's job.""" start, and the full charter is get_system's job."""
p = _fake_project(id=5) p = fake_project(id=5)
sys1 = MagicMock() sys1 = MagicMock()
sys1.id = 3 sys1.id = 3
sys1.name = "retrieval" sys1.name = "retrieval"
@@ -294,7 +282,7 @@ async def test_enter_project_carries_the_bootstrap_ask_when_it_fires():
ask = "This project has 282 records and NO Systems modelled — ..." ask = "This project has 282 records and NO Systems modelled — ..."
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(_fake_project(id=5)): for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm) stack.enter_context(cm)
stack.enter_context(patch( stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", "scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
@@ -315,7 +303,7 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists(
sys1 = MagicMock() sys1 = MagicMock()
sys1.id = 3; sys1.name = "retrieval"; sys1.description = "" sys1.id = 3; sys1.name = "retrieval"; sys1.description = ""
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(_fake_project(id=5)): for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm) stack.enter_context(cm)
stack.enter_context(patch( stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_svc.list_systems", "scribe.mcp.tools.projects.systems_svc.list_systems",
@@ -336,7 +324,7 @@ async def test_enter_project_fires_the_coverage_seed_on_the_owner(
enter stays fast.""" enter stays fast."""
import contextlib import contextlib
project = _fake_project(id=5) project = fake_project(id=5)
project.user_id = 42 # explicit: the OWNER, not the caller (ctx uid=7) project.user_id = 42 # explicit: the OWNER, not the caller (ctx uid=7)
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(project): for cm in _enter_project_stubs(project):
@@ -357,7 +345,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
system binds the same way a rule does. Before this it was reachable only by system binds the same way a rule does. Before this it was reachable only by
an agent that already knew to call resolve_design_system so the standards an agent that already knew to call resolve_design_system so the standards
were present in the store and absent from the work.""" were present in the store and absent from the work."""
p = _fake_project(id=5, design_system_id=9) p = fake_project(id=5, design_system_id=9)
design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}], design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}],
"token_count": 95, "token_groups": ["surface"], "token_count": 95, "token_groups": ["surface"],
"inherits_from": ["House"], "description": ""} "inherits_from": ["House"], "description": ""}
@@ -399,14 +387,7 @@ async def test_enter_project_raises_when_project_not_found():
def test_enter_project_registered_in_register(): def test_enter_project_registered_in_register():
"""register(mcp) registers enter_project alongside the existing tools.""" """register(mcp) registers enter_project alongside the existing tools."""
from scribe.mcp.tools.projects import register from scribe.mcp.tools.projects import register
registered: list[str] = [] mcp = FakeMCP()
class FakeMCP: register(mcp)
def tool(self, name=None): assert "enter_project" in mcp.names
def decorator(fn):
registered.append(name)
return fn
return decorator
register(FakeMCP())
assert "enter_project" in registered
+28 -60
View File
@@ -1,41 +1,16 @@
"""Tests for MCP rulebook tools — patches the service layer.""" """Tests for MCP rulebook tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from tests.helpers import FakeMCP, fake_rule, fake_rulebook, fake_topic
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_rulebook(id=1, title="t"):
rb = MagicMock()
rb.id = id
rb.title = title
rb.to_dict.return_value = {"id": id, "title": title}
return rb
def _fake_topic(id=10, title="git"):
t = MagicMock()
t.id = id
t.title = title
t.to_dict.return_value = {"id": id, "title": title}
return t
def _fake_rule(id=100, title="r", statement="s"):
r = MagicMock()
r.id = id
r.title = title
r.topic_id = 10
r.statement = statement
r.to_dict.return_value = {"id": id, "title": title, "statement": statement}
return r
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_rulebooks_wraps_in_dict(): async def test_list_rulebooks_wraps_in_dict():
rows = [_fake_rulebook(id=1), _fake_rulebook(id=2)] rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")]
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks", "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
@@ -47,8 +22,8 @@ async def test_list_rulebooks_wraps_in_dict():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_rulebook_includes_topics(): async def test_get_rulebook_includes_topics():
rb = _fake_rulebook(id=1) rb = fake_rulebook(id=1, title="t")
topics = [_fake_topic(id=10), _fake_topic(id=11)] topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")]
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=rb), AsyncMock(return_value=rb),
@@ -75,7 +50,7 @@ async def test_get_rulebook_raises_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_passes_required_fields(): async def test_create_rule_passes_required_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock):
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
@@ -109,7 +84,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock() find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \ with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=_fake_rule(id=5))): AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))):
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True) out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
assert out["id"] == 5 assert out["id"] == 5
@@ -118,7 +93,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields(): async def test_update_rule_only_sends_non_default_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock):
from scribe.mcp.tools.rulebooks import update_rule from scribe.mcp.tools.rulebooks import update_rule
@@ -131,7 +106,7 @@ async def test_update_rule_only_sends_non_default_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_rule_without_confirmed_returns_warning(): async def test_delete_rule_without_confirmed_returns_warning():
"""delete_rule with confirmed=False returns a preview, not an action.""" """delete_rule with confirmed=False returns a preview, not an action."""
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule), AsyncMock(return_value=rule),
@@ -148,7 +123,7 @@ async def test_delete_rule_without_confirmed_returns_warning():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_rule_with_confirmed_soft_deletes(): async def test_delete_rule_with_confirmed_soft_deletes():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock_delete = AsyncMock(return_value="batch-1") mock_delete = AsyncMock(return_value="batch-1")
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule", "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
@@ -191,27 +166,20 @@ async def test_unsubscribe_project_from_rulebook_calls_service():
def test_register_attaches_all_sixteen_tools(): def test_register_attaches_all_sixteen_tools():
"""register(mcp) should call mcp.tool(name=...) for all 16 tools.""" """register(mcp) should call mcp.tool(name=...) for all 16 tools."""
from scribe.mcp.tools.rulebooks import register from scribe.mcp.tools.rulebooks import register
registered: list[str] = [] mcp = FakeMCP()
class FakeMCP: register(mcp)
def tool(self, name=None): assert len(mcp.names) == 22
def decorator(fn):
registered.append(name)
return fn
return decorator
register(FakeMCP())
assert len(registered) == 22
# spot-check a few names # spot-check a few names
assert "list_rulebooks" in registered assert "list_rulebooks" in mcp.names
assert "create_rule" in registered assert "create_rule" in mcp.names
assert "subscribe_project_to_rulebook" in registered assert "subscribe_project_to_rulebook" in mcp.names
assert "list_always_on_rules" in registered assert "list_always_on_rules" in mcp.names
assert "create_project_rule" in registered assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in registered assert "suppress_rule_for_project" in mcp.names
assert "unsuppress_rule_for_project" in registered assert "unsuppress_rule_for_project" in mcp.names
assert "suppress_topic_for_project" in registered assert "suppress_topic_for_project" in mcp.names
assert "unsuppress_topic_for_project" in registered assert "unsuppress_topic_for_project" in mcp.names
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -227,7 +195,7 @@ async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_always_on_rules_projects_each_rule(): async def test_list_always_on_rules_projects_each_rule():
rules = [_fake_rule(id=100), _fake_rule(id=101)] rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
with patch( with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules", "scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules), AsyncMock(return_value=rules),
@@ -241,7 +209,7 @@ async def test_list_always_on_rules_projects_each_rule():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rulebook_forwards_always_on_when_set(): async def test_update_rulebook_forwards_always_on_when_set():
rb = _fake_rulebook(id=1, title="t") rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb) mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook from scribe.mcp.tools.rulebooks import update_rulebook
@@ -254,7 +222,7 @@ async def test_update_rulebook_forwards_always_on_when_set():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_rulebook_omits_always_on_when_none(): async def test_update_rulebook_omits_always_on_when_none():
rb = _fake_rulebook(id=1, title="t") rb = fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb) mock = AsyncMock(return_value=rb)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from scribe.mcp.tools.rulebooks import update_rulebook from scribe.mcp.tools.rulebooks import update_rulebook
@@ -266,7 +234,7 @@ async def test_update_rulebook_omits_always_on_when_none():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_passes_required_fields(): async def test_create_project_rule_passes_required_fields():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
@@ -284,7 +252,7 @@ async def test_create_project_rule_passes_required_fields():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_derives_title_from_statement(): async def test_create_project_rule_derives_title_from_statement():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
@@ -299,7 +267,7 @@ async def test_create_project_rule_derives_title_from_statement():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_rule_uses_explicit_title_when_given(): async def test_create_project_rule_uses_explicit_title_when_given():
rule = _fake_rule() rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule) mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock): with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from scribe.mcp.tools.rulebooks import create_project_rule from scribe.mcp.tools.rulebooks import create_project_rule
+9 -37
View File
@@ -2,32 +2,12 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import FakeMCP, fake_snippet
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_snippet(user_id: int = 7):
n = MagicMock()
n.id = 1
n.title = "debounce — rate-limit a callback"
n.body = "```js\nreturn 1\n```\n"
n.tags = ["js", "snippet"]
n.note_type = "snippet"
# Real int, matching the bound caller by default. The tools compare it to
# decide whether to attach a shared/owner marker; an auto-MagicMock would read
# as another user's record and send them off to look up a username.
n.user_id = user_id
# Explicitly None, not an auto-attribute: snippet_fields prefers `data` when
# truthy, and a MagicMock is truthy — every parsed field would come back as a
# MagicMock instead of a string.
n.data = None
n.to_dict.return_value = {
"id": 1, "title": n.title, "note_type": "snippet", "tags": n.tags,
}
return n
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_snippet_requires_name_and_code(): async def test_create_snippet_requires_name_and_code():
from scribe.mcp.tools.snippets import create_snippet from scribe.mcp.tools.snippets import create_snippet
@@ -39,7 +19,7 @@ async def test_create_snippet_requires_name_and_code():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_snippet_records_and_returns_parsed(): async def test_create_snippet_records_and_returns_parsed():
created = _fake_snippet() created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet", patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create: AsyncMock(return_value=created)) as mock_create:
@@ -88,7 +68,7 @@ async def test_update_snippet_missing_raises():
async def test_update_snippet_empty_string_clears_a_field(): async def test_update_snippet_empty_string_clears_a_field():
# An omitted field must stay None ("leave alone"), but an explicit empty # An omitted field must stay None ("leave alone"), but an explicit empty
# string has to reach the service as "" so a stale field can be removed. # string has to reach the service as "" so a stale field can be removed.
updated = _fake_snippet() updated = fake_snippet()
with patch("scribe.services.snippets.update_snippet", with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update: AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet from scribe.mcp.tools.snippets import update_snippet
@@ -101,7 +81,7 @@ async def test_update_snippet_empty_string_clears_a_field():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_snippet_project_id_conventions(): async def test_update_snippet_project_id_conventions():
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
updated = _fake_snippet() updated = fake_snippet()
cases = {0: snippets_svc.UNSET, -1: None, 5: 5} cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
for given, expected in cases.items(): for given, expected in cases.items():
with patch("scribe.services.snippets.update_snippet", with patch("scribe.services.snippets.update_snippet",
@@ -115,7 +95,7 @@ async def test_update_snippet_project_id_conventions():
async def test_create_and_update_pass_locations_through(): async def test_create_and_update_pass_locations_through():
locs = [{"repo": "a", "path": "a.py", "symbol": "f"}, locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
{"repo": "b", "path": "b.py", "symbol": "g"}] {"repo": "b", "path": "b.py", "symbol": "g"}]
created = _fake_snippet() created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \ with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet", patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create: AsyncMock(return_value=created)) as mock_create:
@@ -187,7 +167,7 @@ async def test_merge_snippets_requires_a_source():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_snippets_returns_survivor_and_merged_ids(): async def test_merge_snippets_returns_survivor_and_merged_ids():
survivor = _fake_snippet() survivor = fake_snippet()
with patch("scribe.services.snippets.merge_snippets", with patch("scribe.services.snippets.merge_snippets",
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge: AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
from scribe.mcp.tools.snippets import merge_snippets from scribe.mcp.tools.snippets import merge_snippets
@@ -209,18 +189,10 @@ async def test_merge_snippets_not_found_raises():
def test_register_attaches_all_tools(): def test_register_attaches_all_tools():
from scribe.mcp.tools import snippets from scribe.mcp.tools import snippets
names: list[str] = [] mcp = FakeMCP()
class FakeMcp: snippets.register(mcp)
def tool(self, name): assert set(mcp.names) == {
names.append(name)
def deco(fn):
return fn
return deco
snippets.register(FakeMcp())
assert set(names) == {
"list_snippets", "create_snippet", "get_snippet", "update_snippet", "list_snippets", "create_snippet", "get_snippet", "update_snippet",
"delete_snippet", "merge_snippets", "verify_snippet", "delete_snippet", "merge_snippets", "verify_snippet",
"find_duplicate_snippets", "unmerge_snippet", "find_duplicate_snippets", "unmerge_snippet",
+6 -13
View File
@@ -2,21 +2,14 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import fake_note, fake_system
def _fake_system(sid=1, name="Reader", project_id=5):
s = MagicMock()
s.to_dict.return_value = {"id": sid, "name": name, "project_id": project_id}
s.project_id = project_id
return s
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_returns_dict(): async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.create_system = AsyncMock(return_value=_fake_system(name="Reader")) svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader") result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader" assert result["name"] == "Reader"
@@ -39,7 +32,7 @@ async def test_get_system_splits_records_by_kind():
note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.get_system = AsyncMock(return_value=_fake_system(sid=3)) svc.get_system = AsyncMock(return_value=fake_system(id=3))
svc.list_records_for_system = AsyncMock(return_value=[issue, work, note]) svc.list_records_for_system = AsyncMock(return_value=[issue, work, note])
from scribe.mcp.tools.systems import get_system from scribe.mcp.tools.systems import get_system
result = await get_system(system_id=3) result = await get_system(system_id=3)
@@ -164,7 +157,7 @@ async def test_populated_vocabulary_never_counts_records():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_same_normalized_name_is_duplicate_gated(): async def test_create_system_same_normalized_name_is_duplicate_gated():
existing = _fake_system(sid=7, name="Scrape Pipeline") existing = fake_system(id=7, name="Scrape Pipeline")
existing.id = 7 existing.id = 7
existing.name = "Scrape Pipeline" existing.name = "Scrape Pipeline"
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
@@ -180,12 +173,12 @@ async def test_create_system_same_normalized_name_is_duplicate_gated():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_system_distinct_name_passes_the_gate(): async def test_create_system_distinct_name_passes_the_gate():
other = _fake_system(sid=7, name="Workers") other = fake_system(id=7, name="Workers")
other.name = "Workers" other.name = "Workers"
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc: patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.list_systems = AsyncMock(return_value=[other]) svc.list_systems = AsyncMock(return_value=[other])
svc.create_system = AsyncMock(return_value=_fake_system(sid=8, name="Exporter")) svc.create_system = AsyncMock(return_value=fake_system(id=8, name="Exporter"))
from scribe.mcp.tools.systems import create_system from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Exporter") result = await create_system(project_id=5, name="Exporter")
assert result["name"] == "Exporter" assert result["name"] == "Exporter"
+3 -6
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts
from tests.helpers import make_mock_session
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -31,9 +32,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
"""End-to-end: query returns three rows, top tag wins.""" """End-to-end: query returns three rows, top tag wins."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)] mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)]
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx): with patch("scribe.mcp.tools.tags.async_session", mock_ctx):
@@ -47,9 +46,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
async def test_fable_list_tags_clamps_limit(): async def test_fable_list_tags_clamps_limit():
mock_result = MagicMock() mock_result = MagicMock()
mock_result.all.return_value = [] mock_result.all.return_value = []
mock_session = AsyncMock() mock_session = make_mock_session()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx): with patch("scribe.mcp.tools.tags.async_session", mock_ctx):

Some files were not shown because too many files have changed in this diff Show More