Drift-audit remediation + Stored Processes + Dashboard #55

Merged
bvandeusen merged 23 commits from dev into main 2026-06-03 08:11:14 -04:00
58 changed files with 1757 additions and 876 deletions
@@ -0,0 +1,49 @@
"""partial-unique topic/rule titles (ignore soft-deleted rows)
Revision ID: 0061
Revises: 0060
Create Date: 2026-06-02
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
named "X" then creating a new "X" — or restoring into a reused title slot —
collided with the dead row and raised an unhandled 500. Replace the full
UNIQUE constraints with partial unique indexes that only consider live
(deleted_at IS NULL) rows.
"""
from alembic import op
import sqlalchemy as sa
revision = "0061"
down_revision = "0060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
op.create_index(
"uq_topic_per_rulebook",
"rulebook_topics",
["rulebook_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
op.create_index(
"uq_rule_per_topic",
"rules",
["topic_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_rule_per_topic", table_name="rules")
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
op.create_unique_constraint(
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
)
@@ -0,0 +1,41 @@
"""drop dead UserProfile curator columns
Revision ID: 0062
Revises: 0061
Create Date: 2026-06-02
learned_summary, observations_raw, and observations_updated_at were written by
the curator/LLM-profile machinery removed in the Phase-8 MCP pivot. Nothing has
populated them since; the profile API returned permanently-empty fields. Drop
the columns.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0062"
down_revision = "0061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("user_profiles", "learned_summary")
op.drop_column("user_profiles", "observations_raw")
op.drop_column("user_profiles", "observations_updated_at")
def downgrade() -> None:
op.add_column(
"user_profiles",
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("observations_raw", postgresql.JSONB(), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("learned_summary", sa.Text(), nullable=True),
)
+1 -1
View File
@@ -1,6 +1,6 @@
services:
app:
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
environment:
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY}"
+4 -2
View File
@@ -45,7 +45,8 @@ router.afterEach(() => {
<!-- Center: primary navigation (desktop) -->
<div class="nav-center">
<div class="nav-pill-bar">
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
@@ -90,7 +91,8 @@ router.afterEach(() => {
<!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
+18 -5
View File
@@ -13,6 +13,23 @@ const typeIcon: Record<string, string> = {
project_shared: '📁',
note_shared: '📝',
group_added: '👥',
event_reminder: '⏰',
}
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
const p = n.payload
switch (n.type) {
case 'project_shared':
return ` shared "${p.project_title}" with you as ${p.permission}`
case 'note_shared':
return ` shared "${p.note_title}" with you as ${p.permission}`
case 'group_added':
return ` added you to "${p.group_name}" as ${p.role}`
case 'event_reminder':
return `Reminder: "${p.title}" is coming up`
default:
return ''
}
}
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
@@ -49,11 +66,7 @@ onMounted(() => store.fetchAll())
<div class="notif-body">
<p class="notif-msg">
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
{{ n.type === 'project_shared'
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
: n.type === 'note_shared'
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
{{ notifMessage(n) }}
</p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div>
+8 -3
View File
@@ -5,10 +5,15 @@ const router = createRouter({
history: createWebHistory(),
routes: [
{
// Knowledge is the landing page in the MCP-first architecture
// (chat / journal / workspace surfaces have been removed).
// The dashboard ("what to work on") is the landing page; Knowledge
// remains as the exhaustive "Browse" surface.
path: "/",
redirect: "/knowledge",
redirect: "/dashboard",
},
{
path: "/dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
},
{
path: "/knowledge",
+5 -117
View File
@@ -2,66 +2,16 @@ import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Note, NoteListResponse } from "@/types/note";
import type { Note } from "@/types/note";
// Single-note + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /notes list view was dropped in the 2026-06-02 drift-audit
// cleanup (no consumers — KnowledgeView and the editors use single-entity and
// mutation methods only).
export const useNotesStore = defineStore("notes", () => {
const notes = ref<Note[]>([]);
const currentNote = ref<Note | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<NoteListResponse>(
`/api/notes${qs ? `?${qs}` : ""}`
);
notes.value = data.notes;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load notes", "error");
throw e;
} finally {
loading.value = false;
}
}
async function fetchNotes(params?: {
q?: string;
tag?: string[];
sort?: string;
order?: string;
limit?: number;
offset?: number;
}) {
if (params?.q !== undefined) searchQuery.value = params.q || "";
if (params?.tag) activeTagFilters.value = params.tag;
if (params?.sort) sortField.value = params.sort;
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
if (params?.limit) limit.value = params.limit;
if (params?.offset !== undefined) offset.value = params.offset;
await refresh();
}
async function fetchNote(id: number) {
loading.value = true;
try {
@@ -110,7 +60,6 @@ export const useNotesStore = defineStore("notes", () => {
async function deleteNote(id: number) {
try {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
}
@@ -120,50 +69,6 @@ export const useNotesStore = defineStore("notes", () => {
}
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setTagFilters(tags: string[]) {
activeTagFilters.value = [...tags];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
async function resolveTitle(title: string): Promise<Note> {
return await apiPost<Note>("/api/notes/resolve-title", { title });
}
@@ -216,29 +121,12 @@ export const useNotesStore = defineStore("notes", () => {
}
return {
notes,
currentNote,
total,
loading,
activeTagFilters,
limit,
offset,
sortField,
sortOrder,
searchQuery,
fetchNotes,
fetchNote,
createNote,
updateNote,
deleteNote,
addTagFilter,
removeTagFilter,
clearTagFilters,
setTagFilters,
setSort,
setOffset,
setSearch,
refresh,
resolveTitle,
convertToTask,
convertToNote,
+4 -116
View File
@@ -2,53 +2,15 @@ import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Task, TaskListResponse, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
// Single-task + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
// cleanup (no consumers — ProjectView's kanban keeps its own local task list).
export const useTasksStore = defineStore("tasks", () => {
const tasks = ref<Task[]>([]);
const currentTask = ref<Task | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus[]>([]);
const priorityFilter = ref<TaskPriority[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
for (const s of statusFilter.value) searchParams.append("status", s);
for (const p of priorityFilter.value) searchParams.append("priority", p);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<TaskListResponse>(
`/api/tasks${qs ? `?${qs}` : ""}`
);
tasks.value = data.tasks;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load tasks", "error");
throw e;
} finally {
loading.value = false;
}
}
async function fetchTask(id: number) {
loading.value = true;
try {
@@ -102,10 +64,6 @@ export const useTasksStore = defineStore("tasks", () => {
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
try {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
@@ -119,7 +77,6 @@ export const useTasksStore = defineStore("tasks", () => {
async function deleteTask(id: number) {
try {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
}
@@ -144,83 +101,14 @@ export const useTasksStore = defineStore("tasks", () => {
}
}
function setStatusFilter(statuses: TaskStatus[]) {
statusFilter.value = statuses;
offset.value = 0;
refresh();
}
function setPriorityFilter(priorities: TaskPriority[]) {
priorityFilter.value = priorities;
offset.value = 0;
refresh();
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
return {
tasks,
currentTask,
total,
loading,
activeTagFilters,
statusFilter,
priorityFilter,
limit,
offset,
sortField,
sortOrder,
searchQuery,
refresh,
fetchTask,
createTask,
updateTask,
patchStatus,
deleteTask,
startPlanning,
setStatusFilter,
setPriorityFilter,
addTagFilter,
removeTagFilter,
clearTagFilters,
setSort,
setOffset,
setSearch,
};
});
-67
View File
@@ -1,67 +0,0 @@
export interface GenerationTiming {
total_ms: number;
intent_ms: number | null;
ttft_ms: number | null;
generation_ms: number | null;
tools: Array<{ name: string; ms: number }>;
}
export interface ToolCallRecord {
function: string;
arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
status: "running" | "success" | "error" | "declined";
}
export interface ToolPendingRecord {
function: string;
arguments: Record<string, unknown>;
label?: string;
}
export interface Message {
id: number;
conversation_id: number;
role: "system" | "user" | "assistant";
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null;
metadata?: Record<string, unknown> | null;
created_at: string;
timing?: GenerationTiming;
thinking?: string;
}
export interface SendMessageResponse {
assistant_message_id: number;
status: string;
}
export interface Conversation {
id: number;
title: string;
model: string;
message_count: number;
rag_project_id: number | null;
created_at: string;
updated_at: string;
}
export interface ConversationDetail extends Conversation {
messages: Message[];
}
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
}
export interface OllamaStatus {
ollama: "available" | "unavailable";
model: "loaded" | "cold" | "not_found";
default_model: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type NoteType = "note" | "person" | "place" | "list";
export type NoteType = "note" | "person" | "place" | "list" | "process";
export interface Note {
id: number;
+3 -2
View File
@@ -1,5 +1,6 @@
// Settings are free-form string key/value pairs keyed by setting name; the
// backend has no fixed schema, so this is an open string map. (The former
// assistant_name/default_model hints were dead after the Phase-8 chat removal.)
export interface AppSettings {
assistant_name?: string;
default_model?: string;
[key: string]: string | undefined;
}
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import { relativeTime } from "@/composables/useRelativeTime";
interface TaskRow { id: number; title: string; status: string; priority: string }
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
interface ActiveProject {
id: number; title: string; color: string | null; last_activity: string;
open_count: number; progress_pct: number;
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
}
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
interface DashboardData {
active_projects: ActiveProject[];
recently_completed: DoneItem[];
upcoming_events: UpcomingEvent[];
week_stats: WeekStats;
}
const data = ref<DashboardData | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
data.value = await apiGet<DashboardData>("/api/dashboard");
} catch {
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
} finally {
loading.value = false;
}
});
function fmtEvent(e: UpcomingEvent): string {
if (!e.start_dt) return "";
const d = new Date(e.start_dt);
const day = d.toLocaleDateString(undefined, { weekday: "short" });
if (e.all_day) return day;
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
}
</script>
<template>
<div class="dash-root">
<header class="dash-head">
<h1>Dashboard</h1>
<p class="dash-sub">What to work on, across your most-active projects.</p>
</header>
<div v-if="loading" class="dash-empty">Loading</div>
<template v-else-if="data">
<!-- Done recently -->
<section v-if="data.recently_completed.length" class="done-strip">
<span class="dash-label"> Done recently</span>
<router-link
v-for="d in data.recently_completed"
:key="d.id"
:to="`/tasks/${d.id}`"
class="done-chip"
>
{{ d.title }}
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
</router-link>
</section>
<div class="dash-cols">
<!-- Active now -->
<main class="dash-main">
<div class="dash-label">Active now</div>
<div v-if="!data.active_projects.length" class="dash-empty card">
No active projects yet <router-link to="/projects">create one</router-link>.
</div>
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
<header class="proj-head">
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
</header>
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
<div class="ms-head">
<span class="ms-title">{{ m.title }}</span>
<span class="ms-pct">{{ m.progress_pct }}%</span>
</div>
<router-link
v-for="t in m.open_tasks"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<div v-if="p.no_milestone.length" class="ms-block">
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
<router-link
v-for="t in p.no_milestone"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} </router-link>
</article>
</main>
<!-- Right rail -->
<aside class="dash-rail">
<div class="dash-label">Upcoming · 7 days</div>
<div class="rail-card">
<template v-if="data.upcoming_events.length">
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
<span class="evt-when">{{ fmtEvent(e) }}</span>
<span class="evt-title">{{ e.title }}</span>
</div>
</template>
<p v-else class="rail-empty">Nothing scheduled.</p>
</div>
<div class="dash-label">This week</div>
<div class="rail-card stats">
<span> {{ data.week_stats.completed_this_week }} done</span>
<span> {{ data.week_stats.open_total }} open</span>
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
</div>
<div class="quick-add">
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
</div>
</aside>
</div>
</template>
</div>
</template>
<style scoped>
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.done-chip:hover { border-color: var(--color-primary); }
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
.dash-main { flex: 1.7; min-width: 0; }
.dash-rail { flex: 1; min-width: 240px; }
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
.proj-title:hover { color: var(--color-primary); }
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
.ms-block { margin-top: 0.7rem; }
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
.task-row:hover { background: var(--color-hover); }
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
.task-mark { color: var(--color-muted); }
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
.pri-high { color: #c0556b; border-color: #c0556b66; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
.evt-row:last-child { border-bottom: none; }
.evt-when { color: var(--color-muted); white-space: nowrap; }
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+12 -6
View File
@@ -10,6 +10,7 @@ import {
User,
MapPin,
List,
Workflow,
Search,
Share2,
ChevronLeft,
@@ -23,7 +24,7 @@ const router = useRouter();
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list" | "task";
note_type: "note" | "person" | "place" | "list" | "task" | "process";
title: string;
snippet: string;
tags: string[];
@@ -61,7 +62,7 @@ interface UpcomingEvent {
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan">("");
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
@@ -69,8 +70,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Type counts ──────────────────────────────────────────────────────────────
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, total: 0 });
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
async function fetchCounts() {
try {
@@ -403,6 +404,10 @@ onUnmounted(() => {
<List :size="16" />
List
</button>
<button @click="createNew('process')">
<Workflow :size="16" />
Process
</button>
</div>
</div>
@@ -417,11 +422,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan')"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -494,6 +499,7 @@ onUnmounted(() => {
<span v-else-if="item.note_type === 'person'">Person</span>
<span v-else-if="item.note_type === 'place'">Place</span>
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
<span v-else>List</span>
</span>
+33
View File
@@ -130,6 +130,7 @@ const titlePlaceholder = computed(() => {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
case 'process': return 'Process name';
default: return 'Title';
}
});
@@ -602,6 +603,18 @@ onUnmounted(() => assist.clearSelection());
</div>
</template>
<!-- Process (prompt) editor -->
<template v-else-if="noteType === 'process'">
<label class="ef-label">Prompt</label>
<textarea
class="prompt-editor"
:value="body"
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
spellcheck="false"
></textarea>
</template>
<!-- Generic note editor -->
<template v-else>
<div class="body-tabs-row">
@@ -1038,6 +1051,26 @@ onUnmounted(() => assist.clearSelection());
font-size: 0.92rem;
color: var(--color-primary);
}
.prompt-editor {
width: 100%;
min-height: 60vh;
margin-top: 8px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
/* Prompts are plain markdown — a code-style editor, not rich text. */
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 0.88rem;
line-height: 1.55;
tab-size: 2;
resize: vertical;
outline: none;
}
.prompt-editor:focus {
border-color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
-40
View File
@@ -14,25 +14,10 @@ const toastStore = useToastStore();
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const autoConsolidateTasks = ref(true);
const savingAutoConsolidate = ref(false);
const trashRetentionDays = ref("90");
const savingRetention = ref(false);
const retentionSaved = ref(false);
async function saveAutoConsolidate() {
savingAutoConsolidate.value = true;
try {
await apiPut('/api/settings', {
auto_consolidate_tasks: autoConsolidateTasks.value ? "true" : "false",
});
} catch {
toastStore.show('Failed to save setting', 'error');
} finally {
savingAutoConsolidate.value = false;
}
}
// think_enabled setting removed 2026-05-23. The chat+curator architecture
// has tools=[] on the chat model; think on a no-tools conversational pass
// is pure latency cost. See generation_task.py:run_generation comment for
@@ -403,8 +388,6 @@ onMounted(async () => {
const allSettings = await apiGet<Record<string, string>>("/api/settings");
userTimezone.value = allSettings.user_timezone ?? "";
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -992,29 +975,6 @@ function formatUserDate(iso: string): string {
<!-- General -->
<div v-show="activeTab === 'general'" class="settings-grid">
<!-- Tasks -->
<section class="settings-section full-width">
<h2>Tasks</h2>
<p class="section-desc">
Task bodies are auto-summarized from accumulated work logs. The summary runs every few logs, plus on task close.
</p>
<div class="field">
<label class="checkbox-label">
<input
type="checkbox"
v-model="autoConsolidateTasks"
:disabled="savingAutoConsolidate"
@change="saveAutoConsolidate"
/>
Auto-consolidate task bodies
</label>
<p class="field-hint">
When off, the task body is only refreshed when you click "Re-consolidate"
on a task. Existing summaries remain in place.
</p>
</div>
</section>
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
+4
View File
@@ -27,6 +27,7 @@ from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
from fabledassistant.routes.rulebooks import rulebooks_bp
from fabledassistant.routes.trash import trash_bp
from fabledassistant.routes.dashboard import dashboard_bp
from fabledassistant.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
@@ -87,6 +88,7 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
@app.before_request
async def before_request():
@@ -148,12 +150,14 @@ def create_app() -> Quart:
async def startup():
import asyncio
from fabledassistant.services.auth import start_auth_token_retention_loop
from fabledassistant.services.embeddings import backfill_note_embeddings
from fabledassistant.services.logging import start_log_retention_loop
from fabledassistant.services.notifications import start_notification_loop
start_log_retention_loop()
start_notification_loop()
start_auth_token_retention_loop()
# Backfill embeddings for any notes that don't have one. Runs in the
# background so it never blocks the server from accepting requests.
+18
View File
@@ -17,3 +17,21 @@ async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
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:
"""Resolve a Bearer token to (user_id, scope).
scope is 'read' or 'write'. Returns None for a missing/malformed/invalid
token. The MCP dispatch layer uses scope to deny write-class tool calls
from read-only keys — the same read/write boundary the REST API enforces.
"""
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)
if api_key is None:
return None
return api_key.user_id, (api_key.scope or "write")
+91 -3
View File
@@ -78,9 +78,76 @@ descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
auto-purges after the operator's retention window.
Scribe stores reusable Processes — saved prompts/workflows (note_type
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
X process" or otherwise references a saved process, call list_processes() /
get_process(name) and follow the returned prompt verbatim, including any
"clarify first" steps it contains. Author a new one with create_process(title,
body); edit with update_process.
"""
# Tools a read-only API key may call. Anything not listed is treated as a
# write for read keys (default-deny), so a newly-added tool is locked down
# until explicitly classified here.
_READ_ONLY_TOOLS = frozenset({
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
"get_task", "get_recent", "enter_project",
"list_events", "list_lists", "list_milestones", "list_notes",
"list_persons", "list_places", "list_projects", "list_rulebooks",
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"list_always_on_rules", "search",
})
async def _buffer_request_body(receive):
"""Drain the ASGI request body and return (body_bytes, replay_receive).
The MCP sub-app still needs to read the body, so we return a fresh
`receive` that replays the buffered bytes.
"""
chunks: list[bytes] = []
more = True
while more:
message = await receive()
if message["type"] == "http.request":
chunks.append(message.get("body", b""))
more = message.get("more_body", False)
else: # http.disconnect
more = False
body = b"".join(chunks)
sent = False
async def replay():
nonlocal sent
if not sent:
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return {"type": "http.disconnect"}
return body, replay
def _body_calls_write_tool(body: bytes) -> bool:
"""True if the JSON-RPC body invokes a tool outside the read all-list."""
import json
try:
payload = json.loads(body)
except Exception:
return False
items = payload if isinstance(payload, list) else [payload]
for item in items:
if not isinstance(item, dict):
continue
if item.get("method") == "tools/call":
name = (item.get("params") or {}).get("name", "")
if name and name not in _READ_ONLY_TOOLS:
return True
return False
def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.
@@ -128,7 +195,7 @@ def mount_mcp(app: Quart) -> None:
inside Quart, we hook the session manager's `run()` async context manager
into Quart's serving lifecycle (before_serving / after_serving).
"""
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
from fabledassistant.mcp.auth import resolve_bearer
mcp = build_mcp_server()
mcp_asgi = mcp.streamable_http_app()
@@ -151,8 +218,8 @@ def mount_mcp(app: Quart) -> None:
return await mcp_asgi(scope, receive, send)
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
if user_id is None:
resolved = await resolve_bearer(headers.get("authorization"))
if resolved is None:
await send({
"type": "http.response.start",
"status": 401,
@@ -166,6 +233,27 @@ def mount_mcp(app: Quart) -> None:
"body": b'{"error":"unauthorized"}',
})
return
user_id, key_scope = resolved
# Enforce read-only keys: REST blocks non-GET for scope='read', and the
# MCP surface must match or the read-only guarantee is void. A tool call
# arrives as a JSON-RPC POST; buffer the body, and if it invokes a tool
# outside the read all-list, reject before dispatch. (default-deny: any
# unknown/new tool is treated as a write for read keys.)
if key_scope == "read" and scope.get("method") == "POST":
body, receive = await _buffer_request_body(receive)
if _body_calls_write_tool(body):
await send({
"type": "http.response.start",
"status": 403,
"headers": [(b"content-type", b"application/json")],
})
await send({
"type": "http.response.body",
"body": b'{"error":"read-only API key cannot call write tools"}',
})
return
scope["scribe_user_id"] = user_id
from fabledassistant.mcp._context import _user_id_ctx
token = _user_id_ctx.set(user_id)
+2 -1
View File
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from fabledassistant.mcp.tools import (
entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash,
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
)
@@ -20,5 +20,6 @@ def register_all(mcp) -> None:
tags.register(mcp)
recent.register(mcp)
entities.register(mcp)
processes.register(mcp)
rulebooks.register(mcp)
trash.register(mcp)
@@ -0,0 +1,91 @@
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
A process is a Note whose body is a prompt the operator fires later
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
get_process is the fire mechanism: it returns the full prompt for Claude to run.
"""
from __future__ import annotations
from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import knowledge as knowledge_svc
from fabledassistant.services import notes as notes_svc
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
"""List stored processes (reusable saved prompts).
Args:
q: Free-text search across title + body (optional).
tag: Filter to a single tag (optional).
limit: Max results (1-100).
Returns {"processes": [{id, title, tags, preview}], "total": int}.
"""
uid = current_user_id()
items, total = await knowledge_svc.query_knowledge(
user_id=uid, note_type="process", tags=[tag] if tag else [],
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
)
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
"preview": it.get("snippet", "")} for it in items]
return {"processes": procs, "total": total}
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
"""Create a stored process (a reusable saved prompt).
Args:
title: Process name, e.g. "Drift Audit" (required).
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
"""
if not (title or "").strip() or not (body or "").strip():
raise ValueError("create_process requires a non-empty title and body")
uid = current_user_id()
note = await notes_svc.create_note(
uid, title=title.strip(), body=body, note_type="process", tags=tags,
)
return note.to_dict()
async def get_process(name_or_id: str) -> dict:
"""Fetch a stored process by name or id and return its full prompt — the
fire mechanism. The operator says "run the <name> process"; call this and
follow the returned body (including any 'clarify first' steps it contains).
Resolution: numeric id → exact (case-insensitive) title → substring. On an
ambiguous substring match, the best (most-recent) match is returned with an
`other_matches` list so you can disambiguate with the operator.
"""
uid = current_user_id()
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
if note is None:
raise ValueError(f"process {name_or_id!r} not found")
out = note.to_dict()
if candidates:
out["other_matches"] = candidates
return out
async def update_process(process_id: int, title: str = "", body: str = "",
tags: list[str] | None = None) -> dict:
"""Update a stored process. Only provided fields change — empty title/body
leave that field unchanged; pass tags to replace the tag set."""
uid = current_user_id()
note = await notes_svc.get_note(uid, process_id)
if note is None or note.note_type != "process":
raise ValueError(f"process {process_id} not found")
fields: dict = {}
if title.strip():
fields["title"] = title.strip()
if body.strip():
fields["body"] = body
if tags is not None:
fields["tags"] = tags
updated = await notes_svc.update_note(uid, process_id, **fields)
return updated.to_dict()
def register(mcp) -> None:
for fn in (list_processes, create_process, get_process, update_process):
mcp.tool(name=fn.__name__)(fn)
+3 -3
View File
@@ -27,7 +27,7 @@ from fabledassistant.services import trash as trash_svc
async def list_projects() -> dict:
"""List all Scribe projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
Returns id, title, description, goal, status (active/paused/completed/archived), color,
and a short auto-generated summary for each project.
"""
uid = current_user_id()
@@ -142,7 +142,7 @@ async def create_project(
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
"""
uid = current_user_id()
@@ -172,7 +172,7 @@ async def update_project(
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
status: New status — one of active, paused, completed, archived.
color: New hex colour, or omit to leave unchanged.
"""
uid = current_user_id()
+6 -3
View File
@@ -38,7 +38,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
items: list[dict] = []
async with async_session() as session:
notes = (await session.execute(
select(Note).where(Note.user_id == uid, Note.updated_at >= since)
select(Note).where(Note.user_id == uid, Note.updated_at >= since,
Note.deleted_at.is_(None))
.order_by(Note.updated_at.desc()).limit(limit)
)).scalars().all()
for n in notes:
@@ -50,7 +51,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
})
projects = (await session.execute(
select(Project).where(Project.user_id == uid,
Project.updated_at >= since)
Project.updated_at >= since,
Project.deleted_at.is_(None))
.order_by(Project.updated_at.desc()).limit(limit)
)).scalars().all()
for p in projects:
@@ -62,7 +64,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
})
events = (await session.execute(
select(Event).where(Event.user_id == uid,
Event.updated_at >= since)
Event.updated_at >= since,
Event.deleted_at.is_(None))
.order_by(Event.updated_at.desc()).limit(limit)
)).scalars().all()
for e in events:
+3 -3
View File
@@ -322,7 +322,7 @@ async def update_rule(
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"""Permanently delete a rule. Requires confirmed=True."""
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
@@ -330,8 +330,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
if not confirmed:
return {
"warning": (
f"Rule {rule_id} ('{rule.title}') will be permanently deleted. "
f"Pass confirmed=True to proceed."
f"Rule {rule_id} ('{rule.title}') will be moved to the trash "
f"(recoverable via restore). Pass confirmed=True to proceed."
),
"confirmed_required": True,
}
+1 -1
View File
@@ -39,7 +39,7 @@ async def list_tags(limit: int = 50) -> dict:
limit = max(1, min(limit, 200))
async with async_session() as session:
result = await session.execute(
select(Note.tags).where(Note.user_id == uid)
select(Note.tags).where(Note.user_id == uid, Note.deleted_at.is_(None))
)
tag_lists = [row[0] for row in result.all()]
counts = _aggregate_tag_counts(tag_lists)
+1
View File
@@ -8,6 +8,7 @@ from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class ProjectStatus(str, enum.Enum):
active = "active"
paused = "paused"
completed = "completed"
archived = "archived"
+13 -3
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
@@ -42,8 +42,13 @@ class Rulebook(Base, SoftDeleteMixin):
class RulebookTopic(Base, SoftDeleteMixin):
__tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title.
__table_args__ = (
UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
Index(
"uq_topic_per_rulebook", "rulebook_id", "title",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -76,8 +81,13 @@ class RulebookTopic(Base, SoftDeleteMixin):
class Rule(Base, SoftDeleteMixin):
__tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title).
__table_args__ = (
UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
Index(
"uq_rule_per_topic", "topic_id", "title",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+1 -17
View File
@@ -1,6 +1,4 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -27,13 +25,6 @@ class UserProfile(Base, TimestampMixin):
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# LLM-consolidated summary of learned preferences
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
observations_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def to_dict(self) -> dict:
return {
@@ -45,11 +36,4 @@ class UserProfile(Base, TimestampMixin):
"tone": self.tone or "casual",
"interests": self.interests or [],
"work_schedule": self.work_schedule or {},
"learned_summary": self.learned_summary or "",
"observations_count": len(self.observations_raw or []),
"observations_updated_at": (
self.observations_updated_at.isoformat()
if self.observations_updated_at
else None
),
}
+13
View File
@@ -0,0 +1,13 @@
"""Dashboard REST endpoint — the aggregated landing payload."""
from quart import Blueprint, g, jsonify
from fabledassistant.auth import login_required
from fabledassistant.services.dashboard import build_dashboard
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
@dashboard_bp.get("")
@login_required
async def get_dashboard():
return jsonify(await build_dashboard(g.user.id))
+1 -1
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan"}
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
+28 -6
View File
@@ -8,12 +8,12 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note
from fabledassistant.services.notes import (
build_note_graph,
convert_note_to_task,
convert_task_to_note,
create_note,
delete_note,
get_all_tags,
get_backlinks,
get_note,
@@ -192,6 +192,15 @@ async def get_note_route(note_id: int):
@login_required
async def update_note_route(note_id: int):
uid = get_current_user_id()
# 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.
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"):
@@ -212,14 +221,14 @@ async def update_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(uid, note_id, **fields)
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")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -227,6 +236,13 @@ async def update_note_route(note_id: int):
@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"):
@@ -245,14 +261,14 @@ async def patch_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(uid, note_id, **fields)
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")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -260,8 +276,14 @@ async def patch_note_route(note_id: int):
@login_required
async def delete_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
from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(uid, "note", note_id)
batch = await trash_delete(note_obj.user_id, "note", note_id)
if batch is None:
return not_found("Note")
return "", 204
+7 -4
View File
@@ -53,7 +53,7 @@ async def create_project_route():
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
project = await create_project(
uid,
title=data["title"],
@@ -90,7 +90,7 @@ async def update_project_route(project_id: int):
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields)
if project is None:
return not_found("Project")
@@ -116,6 +116,9 @@ async def get_project_notes_route(project_id: int):
if result is None:
return not_found("Project")
project, _ = result
# Use the project owner's uid so the ownership filter on notes/milestones
# matches for shared collaborators (who'd otherwise see an empty panel).
owner_uid = project.user_id or uid
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
@@ -128,11 +131,11 @@ async def get_project_notes_route(project_id: int):
elif type_filter == "note":
is_task = False
ms_list = await list_milestones(uid, project_id)
ms_list = await list_milestones(owner_uid, project_id)
milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes(
uid,
owner_uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
+4 -2
View File
@@ -118,7 +118,8 @@ async def update_topic(topic_id: int):
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required
async def delete_topic(topic_id: int):
await trash_delete(_uid(), "topic", topic_id)
if await trash_delete(_uid(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404
return "", 204
@@ -196,7 +197,8 @@ async def update_rule(rule_id: int):
@rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required
async def delete_rule(rule_id: int):
await trash_delete(_uid(), "rule", rule_id)
if await trash_delete(_uid(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404
return "", 204
-1
View File
@@ -10,7 +10,6 @@ from fabledassistant.services.access import can_write_note
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
delete_note,
get_note,
get_note_for_user,
list_notes,
+64 -5
View File
@@ -106,6 +106,11 @@ async def change_password(user_id: int, current_password: str, new_password: str
user = await session.get(User, user_id)
if not user:
return None
# OAuth-only accounts have no local password hash — verify_password
# would crash on None. Treat as "no local credential to change" (the
# route turns None into a clean 4xx, not a 500).
if user.password_hash is None:
return None
if not verify_password(current_password, user.password_hash):
return None
user.password_hash = hash_password(new_password)
@@ -325,12 +330,23 @@ async def register_with_invitation(raw_token: str, username: str, password: str)
if not invitation or invitation.used or invitation.expires_at < datetime.now(timezone.utc):
return None
invitation.used = True
await session.commit()
invitation_id = invitation.id
invite_email = invitation.email
# Create user outside the invitation session
user = await create_user(username, password, invitation.email)
logger.info("User '%s' registered via invitation for %s", username, invitation.email)
# Create the user FIRST, then consume the token. create_user can fail (e.g.
# a username collision raises and the route returns 409); marking the token
# used before that would burn this single-use invite and lock the invitee
# out of retrying. Ordering it after means a failed creation leaves the
# token valid.
user = await create_user(username, password, invite_email)
async with async_session() as session:
invitation = await session.get(InvitationToken, invitation_id)
if invitation is not None:
invitation.used = True
await session.commit()
logger.info("User '%s' registered via invitation for %s", username, invite_email)
return user
@@ -356,3 +372,46 @@ async def revoke_invitation(invitation_id: int) -> bool:
await session.commit()
logger.info("Invitation %d revoked", invitation_id)
return True
# ── Token retention ─────────────────────────────────────────────────────
# Password-reset and invitation tokens are only ever flipped used=True and are
# never pruned, so on a long-lived instance both tables grow without bound.
# A daily sweep deletes any token whose validity window ended over grace_days
# ago (covers both used and naturally-expired rows once they're cold).
_auth_retention_task = None
async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
"""Delete password-reset / invitation tokens that expired > grace_days ago."""
from sqlalchemy import delete
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
removed = 0
async with async_session() as session:
for model in (PasswordResetToken, InvitationToken):
result = await session.execute(
delete(model).where(model.expires_at < cutoff)
)
removed += result.rowcount or 0
await session.commit()
return removed
async def _auth_token_retention_loop() -> None:
import asyncio
while True:
await asyncio.sleep(86400) # daily
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:
global _auth_retention_task
import asyncio
if _auth_retention_task is None or _auth_retention_task.done():
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop())
+36 -6
View File
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
# in update_event, since None is a meaningful value for recurrence.
_RECURRENCE_UNSET = object()
async def get_caldav_config(user_id: int) -> dict[str, str]:
"""Return the user's CalDAV config from their settings."""
@@ -214,14 +218,22 @@ async def create_event(
result_end = d_end.isoformat()
else:
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
event.add("dtstart", dt_start)
result_start = dt_start.isoformat()
if end:
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
elif duration:
dt_end = dt_start + timedelta(minutes=duration)
else:
dt_end = dt_start + timedelta(minutes=duration or 60)
event.add("dtstart", dt_start)
event.add("dtend", dt_end)
result_start = dt_start.isoformat()
result_end = dt_end.isoformat()
dt_end = None
if dt_end is not None:
event.add("dtend", dt_end)
result_end = dt_end.isoformat()
else:
# Point event (no end, no duration): emit DTSTART only. Fabricating
# a 60-min DTEND here would round-trip back on the next pull as
# duration_minutes=60, silently lengthening a point event.
result_end = None
if description:
event.add("description", description)
@@ -325,8 +337,14 @@ async def update_event(
location: str | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
recurrence: str | None | object = _RECURRENCE_UNSET,
) -> dict:
"""Update a calendar event matching the query."""
"""Update a calendar event matching the query.
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
RRULE string to set it, or None/"" to remove it. The push path passes the
local event's recurrence so RRULE edits propagate to the server.
"""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
@@ -382,6 +400,18 @@ async def update_event(
if "LOCATION" in component:
del component["LOCATION"]
component.add("location", location)
if recurrence is not _RECURRENCE_UNSET:
# Authoritatively sync the RRULE to the local event: drop the old
# rule, then re-add if a non-empty rule was provided (else clear it).
if "RRULE" in component:
del component["RRULE"]
if recurrence:
rrule_parts = {}
for part in str(recurrence).split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
component.add("rrule", rrule_parts)
# Rebuild ical data and save
cal_data = icalendar.Calendar()
+51 -7
View File
@@ -11,7 +11,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy import select, update
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
@@ -20,6 +20,9 @@ logger = logging.getLogger(__name__)
_SYNC_PAST_DAYS = 30
_SYNC_FUTURE_DAYS = 180
# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't
# wedge the hourly sweep indefinitely.
_SYNC_TIMEOUT_SECONDS = 120
def _parse_dt(val: Any) -> datetime | None:
@@ -116,16 +119,24 @@ async def sync_user_events(user_id: int) -> dict:
config = await get_caldav_config(user_id)
started = datetime.now(timezone.utc)
range_start = started - timedelta(days=_SYNC_PAST_DAYS)
range_end = started + timedelta(days=_SYNC_FUTURE_DAYS)
loop = asyncio.get_running_loop()
try:
remote_events: list[dict] = await loop.run_in_executor(
None, _sync_one_user, config, user_id
remote_events: list[dict] = await asyncio.wait_for(
loop.run_in_executor(None, _sync_one_user, config, user_id),
timeout=_SYNC_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS)
return {"error": "CalDAV fetch timed out"}
except Exception:
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
return {"error": "CalDAV fetch failed"}
created = updated = unchanged = 0
created = updated = unchanged = skipped = deleted = 0
async with async_session() as session:
for ev in remote_events:
@@ -149,6 +160,14 @@ async def sync_user_events(user_id: int) -> dict:
)
existing = result.scalar_one_or_none()
if existing is not None and existing.deleted_at is not None:
# The user trashed this event locally. Don't resurrect it by
# updating, and don't create a duplicate live copy — leave it
# in the trash. (Propagating the delete to the remote server is
# tracked separately.)
skipped += 1
continue
if existing is None:
# Create new event
new_ev = Event(
@@ -177,13 +196,38 @@ async def sync_user_events(user_id: int) -> dict:
else:
unchanged += 1
# Reconcile deletions: a previously-synced event (has a caldav_uid)
# that no longer appears remotely within the synced window is
# soft-deleted, so a delete on the remote propagates locally instead
# of orphaning forever. Guarded on a non-empty fetch so a spurious
# empty result can't wipe every local copy.
if remote_events:
remote_uids = {e["caldav_uid"] for e in remote_events}
orphan_batch = str(uuid.uuid4())
orphan_res = await session.execute(
update(Event)
.where(
Event.user_id == user_id,
Event.caldav_uid.isnot(None),
Event.caldav_uid.notin_(remote_uids),
Event.deleted_at.is_(None),
Event.start_dt >= range_start,
Event.start_dt <= range_end,
)
.values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch)
)
deleted = orphan_res.rowcount or 0
await session.commit()
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
logger.info(
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
user_id, created, updated, unchanged,
"CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), "
"%d deleted (orphaned) in %.1fs",
user_id, created, updated, unchanged, skipped, deleted, elapsed,
)
return {"created": created, "updated": updated, "unchanged": unchanged}
return {"created": created, "updated": updated, "unchanged": unchanged,
"skipped": skipped, "deleted": deleted}
async def sync_all_users() -> None:
@@ -1,312 +0,0 @@
"""Calendar sync service: bridges the DB Event table with local Radicale.
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
The app connects without auth (Radicale runs with auth.type = none inside Docker).
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
"""
import asyncio
import logging
import uuid
from datetime import datetime, timezone
import httpx
import icalendar
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
# Internal Radicale URL (Docker service name)
_RADICALE_INTERNAL = "http://radicale:5232"
def _user_calendar_path(user_id: int) -> str:
return f"/user_{user_id}/calendar/"
def _user_calendar_url(user_id: int) -> str:
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
return f"{base}/user_{user_id}/calendar/"
async def setup_user_calendar(user_id: int) -> bool:
"""Create Radicale collection for user if it doesn't exist.
Returns True on success or if already exists, False on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
user_url = f"{base}/user_{user_id}/"
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Create user home collection
await client.request(
"MKCOL",
user_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set><prop><resourcetype><collection/></resourcetype></prop></set>
</mkcol>""",
)
# Create calendar collection
resp = await client.request(
"MKCOL",
cal_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set>
<prop>
<resourcetype><collection/><C:calendar/></resourcetype>
<displayname>Fabled Scribe</displayname>
</prop>
</set>
</mkcol>""",
)
if resp.status_code in (201, 405): # 405 = already exists
logger.info("Calendar collection ready for user %d", user_id)
return True
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
return False
except Exception:
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
return False
def _make_vcalendar(event: Event) -> str:
"""Build iCalendar string from DB Event."""
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
cal.add("version", "2.0")
vevent = icalendar.Event()
vevent.add("uid", event.uid)
vevent.add("summary", event.title)
if event.all_day:
vevent.add("dtstart", event.start_dt.date())
if event.end_dt:
vevent.add("dtend", event.end_dt.date())
else:
vevent.add("dtstart", event.start_dt)
if event.end_dt:
vevent.add("dtend", event.end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.recurrence:
rrule_parts: dict = {}
for part in event.recurrence.split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
if rrule_parts:
vevent.add("rrule", rrule_parts)
vevent.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(vevent)
return cal.to_ical().decode("utf-8")
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
"""Write a DB Event to Radicale. Creates or updates the resource.
Returns True on success.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
ical_data = _make_vcalendar(event)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
resource_url,
content=ical_data.encode("utf-8"),
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if resp.status_code in (200, 201, 204):
return True
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
return False
except Exception:
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
return False
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
"""Delete an event from Radicale by UID."""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(resource_url)
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
except Exception:
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
return False
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
"""Fetch an event from Radicale and upsert into the DB Event table.
Returns the upserted Event or None on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(resource_url)
if resp.status_code == 404:
return None
resp.raise_for_status()
ical_data = resp.text
except Exception:
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
return None
try:
cal = icalendar.Calendar.from_ical(ical_data)
for component in cal.walk():
if component.name != "VEVENT":
continue
title = str(component.get("SUMMARY", ""))
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
description = str(component.get("DESCRIPTION", ""))
location = str(component.get("LOCATION", ""))
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
all_day = False
if dtstart and isinstance(dtstart.dt, datetime):
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
elif dtstart:
from datetime import date
d = dtstart.dt
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
all_day = True
else:
continue
end_dt = None
if dtend and isinstance(dtend.dt, datetime):
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
elif dtend:
from datetime import date
d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
# Storage uses duration, not end_dt. Convert iCal DTEND to a
# minute count anchored on DTSTART. Treat invalid (end <= start)
# incoming data as a point event rather than rejecting; we
# don't control external CalDAV writers.
duration_minutes = None
if end_dt is not None and end_dt > start_dt:
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
)
existing = result.scalars().first()
if existing:
existing.title = title
existing.start_dt = start_dt
existing.duration_minutes = duration_minutes
existing.all_day = all_day
existing.description = description
existing.location = location
existing.recurrence = recurrence
existing.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
event_obj = Event(
user_id=user_id,
uid=ical_uid,
title=title,
start_dt=start_dt,
duration_minutes=duration_minutes,
all_day=all_day,
description=description,
location=location,
recurrence=recurrence,
)
session.add(event_obj)
await session.commit()
await session.refresh(event_obj)
return event_obj
except Exception:
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
return None
async def sync_all_events_from_radicale(user_id: int) -> int:
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
Returns number of events synced.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# PROPFIND to get all .ics resources
resp = await client.request(
"PROPFIND",
cal_url,
headers={"Depth": "1", "Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
)
if resp.status_code == 404:
logger.info("No calendar found for user %d — setting up", user_id)
await setup_user_calendar(user_id)
return 0
if resp.status_code != 207:
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
return 0
# Extract UIDs from href paths
import re
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
except Exception:
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
return 0
synced = 0
for uid in uids:
if uid:
event = await sync_event_to_db(user_id, uid)
if event:
synced += 1
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
return synced
def get_external_caldav_url(user_id: int) -> str:
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
"""
external = Config.RADICALE_EXTERNAL_URL
if external:
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
# Fall back to BASE_URL host with port 5232
from urllib.parse import urlparse
parsed = urlparse(Config.BASE_URL)
host = parsed.hostname or "localhost"
return f"http://{host}:5232/user_{user_id}/calendar/"
+171
View File
@@ -0,0 +1,171 @@
"""Dashboard aggregation — assembles the /dashboard landing payload.
One call: most-recently-active projects (each -> active milestones -> open
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
trashed rows excluded. Each section is independent — a failure returns its
empty value rather than blanking the page.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.services import milestones as milestones_svc
logger = logging.getLogger(__name__)
N_PROJECTS = 3 # most-recently-active projects shown
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
_OPEN = ["todo", "in_progress"]
def _open_order():
"""in-progress first -> priority high..none -> most-recently-updated."""
status_rank = case((Note.status == "in_progress", 0), else_=1)
priority_rank = case(
(Note.priority == "high", 0), (Note.priority == "medium", 1),
(Note.priority == "low", 2), else_=3,
)
return status_rank, priority_rank, Note.updated_at.desc()
def _task_row(n: Note) -> dict:
return {"id": n.id, "title": n.title, "status": n.status,
"priority": n.priority or "none"}
async def _safe(coro, empty):
try:
return await coro
except Exception:
logger.warning("dashboard section failed", exc_info=True)
return empty
async def build_dashboard(user_id: int) -> dict:
return {
"active_projects": await _safe(_active_projects(user_id), []),
"recently_completed": await _safe(_recently_completed(user_id), []),
"upcoming_events": await _safe(_upcoming_events(user_id), []),
"week_stats": await _safe(_week_stats(user_id), {}),
}
async def _active_projects(user_id: int) -> list[dict]:
so, po, ro = _open_order()
async with async_session() as session:
recency = (
select(Note.project_id, func.max(Note.updated_at).label("last"))
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
Note.project_id.isnot(None))
.group_by(Note.project_id).subquery()
)
prows = (await session.execute(
select(Project, recency.c.last)
.outerjoin(recency, Project.id == recency.c.project_id)
.where(Project.user_id == user_id, Project.status == "active",
Project.deleted_at.is_(None))
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
.limit(N_PROJECTS)
)).all()
out = []
for project, last in prows:
counts = (await session.execute(
select(
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "done"),
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
).where(Note.user_id == user_id, Note.project_id == project.id,
Note.deleted_at.is_(None))
)).one()
open_count, done_count, resolved_total = counts
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
mrows = (await session.execute(
select(Milestone).where(
Milestone.user_id == user_id, Milestone.project_id == project.id,
Milestone.status == "active", Milestone.deleted_at.is_(None))
.order_by(Milestone.order_index.asc())
)).scalars().all()
milestones = []
for m in mrows:
tasks = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.milestone_id == m.id,
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
if not tasks:
continue
prog = await milestones_svc.get_milestone_progress(m.id)
milestones.append({
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
"open_tasks": [_task_row(t) for t in tasks],
})
no_ms = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.project_id == project.id,
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
out.append({
"id": project.id, "title": project.title, "color": project.color,
"last_activity": (last or project.updated_at).isoformat(),
"open_count": open_count, "progress_pct": progress_pct,
"milestones": milestones,
"no_milestone": [_task_row(t) for t in no_ms],
})
return out
async def _recently_completed(user_id: int) -> list[dict]:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
rows = (await session.execute(
select(Note, Project.title)
.outerjoin(Project, Note.project_id == Project.id)
.where(Note.user_id == user_id, Note.status == "done",
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
Note.completed_at >= cutoff)
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
async def _upcoming_events(user_id: int) -> list[dict]:
from fabledassistant.services import events as events_svc
now = datetime.now(timezone.utc)
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
out = []
for e in rows:
d = e if isinstance(e, dict) else e.to_dict()
out.append({"id": d["id"], "title": d["title"],
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
return out
async def _week_stats(user_id: int) -> dict:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
row = (await session.execute(
select(
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "in_progress"),
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
)).one()
return {"completed_this_week": row[0], "open_total": row[1],
"in_progress": row[2], "active_plans": row[3]}
+15 -10
View File
@@ -153,17 +153,22 @@ async def semantic_search_notes(
if not rows:
return []
scored: list[tuple[float, Note]] = []
for ne, note in rows:
try:
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= threshold:
scored.append((sim, note))
def _score() -> list[tuple[float, Note]]:
out: list[tuple[float, Note]] = []
for ne, note in rows:
try:
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= threshold:
out.append((sim, note))
out.sort(key=lambda x: x[0], reverse=True)
return out[:limit]
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:limit]
# Offload the O(rows) cosine scoring off the event loop so a large corpus
# doesn't stall other requests while ranking. Results are unchanged; the
# real scaling fix (ORDER BY / LIMIT in pgvector) is a separate effort.
return await asyncio.to_thread(_score)
async def backfill_note_embeddings() -> None:
+92 -26
View File
@@ -1,10 +1,12 @@
"""Scheduler jobs for background maintenance tasks.
- Reminder notifications: checks every 5 minutes for due event reminders.
- Reminder notifications: checks every 5 minutes for due event reminders and
delivers them to the in-app notification feed.
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
recurring task whose spawn time has arrived.
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
"""
from __future__ import annotations
@@ -14,7 +16,8 @@ from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from dateutil.rrule import rrulestr
from sqlalchemy import and_, or_, select
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
@@ -30,7 +33,13 @@ _loop: asyncio.AbstractEventLoop | None = None
# ---------------------------------------------------------------------------
async def _fire_reminders() -> None:
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
"""Fire in-app reminders for events whose reminder time has arrived.
One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring
events fire once PER OCCURRENCE: reminder_sent_at stores the start of the
occurrence we last reminded about, so each new occurrence re-arms the
reminder instead of the whole series firing only once.
"""
now = datetime.now(timezone.utc)
window_end = now + timedelta(minutes=5)
@@ -38,36 +47,63 @@ async def _fire_reminders() -> None:
result = await session.execute(
select(Event).where(
Event.reminder_minutes.isnot(None),
Event.reminder_sent_at.is_(None),
Event.start_dt > now, # event hasn't started yet
# reminder fires when now >= start_dt - reminder_minutes
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
Event.deleted_at.is_(None),
or_(
# Recurring events are evaluated every sweep against their
# next occurrence (the base start_dt is long past).
Event.recurrence.isnot(None),
# One-shot events: classic gate.
and_(Event.reminder_sent_at.is_(None), Event.start_dt > now),
),
)
)
candidates = list(result.scalars().all())
to_notify: list[Event] = []
# (event_id, occurrence_start) — occurrence_start is also the dedup marker
# written to reminder_sent_at, so a given occurrence reminds exactly once.
to_notify: list[tuple[int, datetime]] = []
for event in candidates:
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
if reminder_dt <= window_end:
to_notify.append(event)
if event.recurrence:
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occ = rule.after(now, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True)
continue
if occ is None:
continue
reminder_dt = occ - timedelta(minutes=event.reminder_minutes)
if reminder_dt <= window_end and event.reminder_sent_at != occ:
to_notify.append((event.id, occ))
else:
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
if reminder_dt <= window_end:
to_notify.append((event.id, event.start_dt))
if not to_notify:
return
async with async_session() as session:
for event in to_notify:
# Push delivery removed alongside the chat subsystem in Phase 8.
# Event reminders are still flagged via in-app notifications
# (see services/notifications.py).
# Deliver via the in-app notification feed (push was removed in Phase 8).
from fabledassistant.services.notifications import create_in_app_notification
# Mark as sent regardless of push success to avoid re-firing
result = await session.execute(
select(Event).where(Event.id == event.id)
)
ev = result.scalar_one_or_none()
if ev:
ev.reminder_sent_at = datetime.now(timezone.utc)
async with async_session() as session:
for event_id, occurrence_start in to_notify:
ev = (await session.execute(
select(Event).where(Event.id == event_id)
)).scalar_one_or_none()
# Skip if this exact occurrence was already reminded (covers a
# concurrent sweep and the one-shot already-sent case).
if ev is None or ev.reminder_sent_at == occurrence_start:
continue
await create_in_app_notification(ev.user_id, "event_reminder", {
"event_id": ev.id,
"title": ev.title,
"start_dt": occurrence_start.isoformat(),
"url": "/calendar",
})
# Stamp the occurrence marker only after the notification is
# created, so a delivery failure leaves it eligible to retry.
ev.reminder_sent_at = occurrence_start
await session.commit()
@@ -91,6 +127,22 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
# ---------------------------------------------------------------------------
# Recurring-task spawn job
# ---------------------------------------------------------------------------
async def _run_recurrence_spawn() -> None:
from fabledassistant.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
try:
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:
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -120,8 +172,22 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
replace_existing=True,
)
# Spawn the next occurrence of due recurring tasks every 15 minutes.
# Without this job, recurrence_next_spawn_at is armed on completion but
# never drained, so recurring tasks never recur.
_scheduler.add_job(
_run_recurrence_spawn_threadsafe,
trigger=IntervalTrigger(minutes=15),
args=[loop],
id="recurrence_spawn",
replace_existing=True,
)
_scheduler.start()
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
logger.info(
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
"recurring-task spawn every 15m)"
)
def stop_event_scheduler() -> None:
+37 -1
View File
@@ -68,6 +68,19 @@ def _normalize_duration(
return None
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
Naive datetimes are the user's local wall-clock time (the MCP create/update
tools combine date+time without a zone). Attaching the user's tzinfo lets
asyncpg store the correct UTC instant, matching the REST/UI path.
"""
if dt is not None and dt.tzinfo is None:
from fabledassistant.services.tz import get_user_tz # noqa: PLC0415
return dt.replace(tzinfo=await get_user_tz(user_id))
return dt
async def create_event(
user_id: int,
title: str,
@@ -97,6 +110,13 @@ async def create_event(
"""
if duration is not None and duration_minutes is None:
duration_minutes = duration
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
# date+time split) is the user's wall-clock time, so anchor it in their
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
# left untouched. Without this, MCP-created events landed at the same
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
start_dt = await _localize_naive(user_id, start_dt)
end_dt = await _localize_naive(user_id, end_dt)
duration_minutes = _normalize_duration(
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
)
@@ -261,13 +281,21 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
select(Event).where(
Event.id == event_id, Event.user_id == user_id,
Event.deleted_at.is_(None),
)
)
event = result.scalar_one_or_none()
if event is None:
return None
old_title = event.title # capture before mutation for CalDAV lookup
# Localize a naive start_dt patch to the user's timezone (same canonical
# rule as create_event) before it's used or persisted.
if fields.get("start_dt") is not None:
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
# Resolve any end_dt/duration_minutes inputs against the
# post-update start_dt. If neither is in the patch, leave the
# existing duration_minutes alone.
@@ -303,6 +331,11 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
for key, value in fields.items():
if key in allowed and (value is not None or key in nullable):
setattr(event, key, value)
# Re-arm the reminder when the timing changes, so an event moved to a
# new (future) time — or given a new lead time — fires again instead of
# being permanently suppressed by a stale reminder_sent_at.
if "start_dt" in fields or "reminder_minutes" in fields:
event.reminder_sent_at = None
await session.commit()
await session.refresh(event)
@@ -422,6 +455,9 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
end=derived_end.isoformat() if derived_end else None,
description=event.description or None,
location=event.location or None,
# Propagate the (possibly cleared) RRULE so a local recurrence edit
# isn't overwritten by the stale remote rule on the next pull.
recurrence=event.recurrence,
)
except Exception:
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
+11 -3
View File
@@ -140,6 +140,14 @@ async def _semantic_knowledge_search(
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
BEST-EFFORT TOP-N, not exhaustive pagination: the ranked candidate set is
capped (keyword limit*2 + up to ~200 semantic), so `total` is the size of
that window, NOT the true match count, and matches beyond the cap are not
reachable by paging. Each page also recomputes the full merge (O(corpus)
per page). Acceptable for an interactive "best results" feed; a cached
ranked-id list or pgvector ORDER BY/LIMIT is the fix if exhaustive,
cheap pagination is ever needed.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
@@ -229,7 +237,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.deleted_at.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.where(Note.note_type.in_(["note", "person", "place", "list", "process"]))
.group_by(Note.note_type)
)
if tags:
@@ -265,9 +273,9 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
for t in ("note", "person", "place", "list", "task", "plan"):
for t in ("note", "person", "place", "list", "task", "plan", "process"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process"))
return counts
-20
View File
@@ -59,26 +59,6 @@ async def log_usage(
await session.commit()
async def log_generation(
user_id: int,
conv_id: int,
model: str,
timing: dict,
) -> None:
"""Persist per-generation timing breakdown to app_logs for benchmarking."""
async with async_session() as session:
log = AppLog(
category="generation",
user_id=user_id,
action="generation",
endpoint=f"/chat/conversations/{conv_id}",
duration_ms=timing.get("total_ms"),
details=json.dumps({"model": model, "conv_id": conv_id, **timing}),
)
session.add(log)
await session.commit()
async def log_error(
user_id: int | None = None,
username: str | None = None,
+12 -2
View File
@@ -53,6 +53,7 @@ async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milest
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
@@ -108,7 +109,10 @@ async def list_milestones(
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
milestone = result.scalars().first()
if milestone is None:
@@ -151,8 +155,13 @@ async def get_milestone_progress(milestone_id: int) -> dict:
status_counts[status] = count
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
pct = round(completed / total * 100, 1) if total > 0 else 0.0
# Cancelled tasks are resolved work, not pending — exclude them from the
# percent-complete denominator so a milestone whose only open task was
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
active_total = total - cancelled
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
return {
"total": total,
@@ -162,6 +171,7 @@ async def get_milestone_progress(milestone_id: int) -> dict:
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
+47 -27
View File
@@ -66,6 +66,20 @@ async def create_note(
entity_meta: dict | None = None,
task_kind: str = "work",
) -> Note:
# Validate status/priority here so the MCP create_task path (which passes
# them straight through) can't persist an out-of-enum value that the REST
# route would have rejected — there's no DB CHECK on notes.status.
if isinstance(status, str):
try:
status = TaskStatus(status).value
except ValueError:
raise ValueError(f"Invalid status: {status!r}. Must be one of: {[s.value for s in TaskStatus]}")
if isinstance(priority, str):
try:
priority = TaskPriority(priority).value
except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from fabledassistant.models.milestone import Milestone
@@ -384,6 +398,10 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
note.status = None
note.priority = None
note.due_date = None
# A plain note is not a task and must not recur — clear the rule and
# any armed spawn timestamp so the recurrence sweep never picks it up.
note.recurrence_rule = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
@@ -391,35 +409,37 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
return note
async def search_notes_for_context(
user_id: int,
keywords: list[str],
exclude_ids: set[int] | None = None,
limit: int = 3,
project_id: int | None = None,
orphan_only: bool = False,
) -> list[Note]:
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
"""Resolve a stored process by id or name.
Owner-scoped, note_type='process', non-trashed. Precedence: numeric id →
exact case-insensitive title → substring. Returns (note, other_candidates);
on a substring tie with no exact hit, `note` is the most-recently-updated
match and `other_candidates` lists the rest as [{id, title}] so the caller
can disambiguate. Returns (None, []) when nothing matches.
"""
async with async_session() as session:
keyword_filters = []
for kw in keywords:
escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(
Note.user_id == user_id, or_(*keyword_filters), Note.deleted_at.is_(None)
base = select(Note).where(
Note.user_id == user_id,
Note.note_type == "process",
Note.deleted_at.is_(None),
)
if orphan_only:
query = query.where(Note.project_id.is_(None))
elif project_id is not None:
query = query.where(Note.project_id == project_id)
if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids))
query = query.order_by(Note.updated_at.desc()).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
s = str(name_or_id).strip()
if s.isdigit():
row = (await session.execute(base.where(Note.id == int(s)))).scalars().first()
if row is not None:
return row, []
exact = (await session.execute(
base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc())
)).scalars().first()
if exact is not None:
return exact, []
matches = (await session.execute(
base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc())
)).scalars().all()
if not matches:
return None, []
return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]]
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
+28 -9
View File
@@ -236,6 +236,28 @@ async def check_due_tasks() -> None:
logger.exception("Failed to send task reminder for user %d", user_id)
# Read notifications are kept this long before the hourly sweep deletes them;
# unread are kept regardless. Without a sweep the table grows without bound.
_NOTIFICATION_RETENTION_DAYS = 30
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
"""Delete already-read in-app notifications older than retention_days."""
from datetime import timedelta
from sqlalchemy import delete
from fabledassistant.models.notification import Notification
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session:
result = await session.execute(
delete(Notification).where(
Notification.read_at.isnot(None),
Notification.read_at < cutoff,
)
)
await session.commit()
return result.rowcount or 0
async def _notification_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
@@ -243,6 +265,12 @@ async def _notification_loop() -> None:
await check_due_tasks()
except Exception:
logger.exception("Error in notification loop")
try:
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:
@@ -266,12 +294,6 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic
return n
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
# Push delivery was removed alongside the chat subsystem (Phase 8).
# In-app notifications still flow through the bell-icon feed.
return None
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try:
if not await is_smtp_configured():
@@ -325,7 +347,6 @@ async def notify_project_shared(
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
@@ -361,7 +382,6 @@ async def notify_note_shared(
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
@@ -385,7 +405,6 @@ async def notify_group_added(
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
+22 -2
View File
@@ -6,11 +6,25 @@ from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.models.project import Project, ProjectStatus
logger = logging.getLogger(__name__)
def _validate_status(status: str) -> str:
"""Coerce/validate a project status against ProjectStatus.
Canonical gate so the MCP create/update_project path (which passes status
straight through) can't persist an out-of-enum value — there's no DB CHECK.
"""
try:
return ProjectStatus(status).value
except ValueError:
raise ValueError(
f"Invalid status: {status!r}. Must be one of: {[s.value for s in ProjectStatus]}"
)
async def create_project(
user_id: int,
title: str,
@@ -19,6 +33,7 @@ async def create_project(
color: str | None = None,
status: str = "active",
) -> Project:
status = _validate_status(status)
async with async_session() as session:
project = Project(
user_id=user_id,
@@ -79,11 +94,16 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
select(Project).where(
Project.id == project_id, Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
project = result.scalars().first()
if project is None:
return None
if "status" in fields and fields["status"] is not None:
fields["status"] = _validate_status(fields["status"])
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
@@ -115,6 +115,9 @@ async def spawn_recurring_tasks() -> int:
and_(
Note.recurrence_rule.isnot(None),
Note.recurrence_next_spawn_at <= now,
# Never spawn children off a trashed parent — that would
# resurrect work the user explicitly deleted.
Note.deleted_at.is_(None),
)
)
)
@@ -498,6 +498,7 @@ async def subscribe_project(
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
@@ -517,6 +518,7 @@ async def unsubscribe_project(
from fabledassistant.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
+91 -23
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy import or_, select, update
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
@@ -19,19 +19,50 @@ from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
# entity_type -> (Model, owner_column_name or None). Used by the existence check.
_OWNER = {
"note": (Note, "user_id"),
"task": (Note, "user_id"),
"event": (Event, "user_id"),
"project": (Project, "user_id"),
"milestone": (Milestone, "user_id"),
"rulebook": (Rulebook, "owner_user_id"),
"topic": (RulebookTopic, None),
"rule": (Rule, None),
# entity_type -> Model. Used to resolve which table a trash op targets.
_MODEL_FOR = {
"note": Note,
"task": Note,
"event": Event,
"project": Project,
"milestone": Milestone,
"rulebook": Rulebook,
"topic": RulebookTopic,
"rule": Rule,
}
def _owner_clause(model, user_id: int):
"""Boolean expr scoping `model` rows to the ones `user_id` owns.
EVERY trash query (exists-check, restore, purge, list, retention sweep)
must carry this — a batch_id is a bearer token, so without an owner
predicate a leaked/guessed id lets one tenant read, restore, or
permanently destroy another's content. Topics and rules carry no
user_id of their own; ownership is derived through the parent rulebook
(or, for project-scoped rules, the owning project).
"""
if model is Rulebook:
return Rulebook.owner_user_id == user_id
if model is RulebookTopic:
return RulebookTopic.rulebook_id.in_(
select(Rulebook.id).where(Rulebook.owner_user_id == user_id)
)
if model is Rule:
return or_(
Rule.topic_id.in_(
select(RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(Rulebook.owner_user_id == user_id)
),
Rule.project_id.in_(
select(Project.id).where(Project.user_id == user_id)
),
)
# Note, Event, Project, Milestone all carry user_id directly.
return model.user_id == user_id
async def _set(session, model, where, batch, now) -> None:
"""Stamp deleted_at + batch on live rows matching `where`."""
await session.execute(
@@ -42,10 +73,8 @@ async def _set(session, model, where, batch, now) -> None:
async def _exists_alive(session, user_id: int, etype: str, eid: int) -> bool:
model, owner = _OWNER[etype]
where = [model.id == eid, model.deleted_at.is_(None)]
if owner:
where.append(getattr(model, owner) == user_id)
model = _MODEL_FOR[etype]
where = [model.id == eid, model.deleted_at.is_(None), _owner_clause(model, user_id)]
return (await session.execute(select(model.id).where(*where))).first() is not None
@@ -77,8 +106,21 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.id == eid], batch, now)
elif etype in ("note", "task"):
await _set(session, Note, [Note.user_id == user_id, Note.parent_id == eid], batch, now) # sub-tasks
await _set(session, Note, [Note.user_id == user_id, Note.id == eid], batch, now)
# Stamp the entire sub-task subtree (not just direct children) so a
# deeply nested task and all its descendants trash/restore as one batch.
ids = [eid]
frontier = [eid]
while frontier:
children = (await session.execute(
select(Note.id).where(
Note.user_id == user_id,
Note.parent_id.in_(frontier),
Note.deleted_at.is_(None),
)
)).scalars().all()
frontier = [c for c in children if c not in ids]
ids.extend(frontier)
await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now)
elif etype == "event":
await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now)
elif etype == "rulebook":
@@ -107,11 +149,28 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
"""
batch = str(uuid.uuid4())
now = datetime.now(timezone.utc)
caldav_event: tuple[str, str] | None = None
async with async_session() as session:
if not await _exists_alive(session, user_id, entity_type, entity_id):
return None
# Capture CalDAV linkage before soft-deleting so we can propagate the
# deletion to the external server (the row stays present locally).
if entity_type == "event":
row = (await session.execute(
select(Event.caldav_uid, Event.title).where(
Event.id == entity_id, Event.user_id == user_id
)
)).first()
if row and row[0]:
caldav_event = (row[0], row[1])
await _cascade(session, user_id, entity_type, entity_id, batch, now)
await session.commit()
# Without this the soft-delete only hides the event locally and the remote
# copy lingers forever (and re-appears on any client syncing that server).
if caldav_event:
import asyncio
from fabledassistant.services.events import _push_delete
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
return batch
@@ -135,7 +194,7 @@ async def restore(user_id: int, batch_id: str) -> int:
for model in _ALL:
res = await session.execute(
update(model)
.where(model.deleted_batch_id == batch_id)
.where(model.deleted_batch_id == batch_id, _owner_clause(model, user_id))
.values(deleted_at=None, deleted_batch_id=None)
)
n += res.rowcount or 0
@@ -150,7 +209,9 @@ async def purge(user_id: int, batch_id: str) -> int:
async with async_session() as session:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(model.deleted_batch_id == batch_id)
sql_delete(model).where(
model.deleted_batch_id == batch_id, _owner_clause(model, user_id)
)
)
n += res.rowcount or 0
await session.commit()
@@ -163,7 +224,9 @@ async def list_trash(user_id: int) -> list[dict]:
async with async_session() as session:
for model in _ALL:
rows = (await session.execute(
select(model).where(model.deleted_at.isnot(None))
select(model).where(
model.deleted_at.isnot(None), _owner_clause(model, user_id)
)
)).scalars().all()
for r in rows:
grp = batches.setdefault(
@@ -185,9 +248,12 @@ async def list_trash(user_id: int) -> list[dict]:
return out
async def purge_expired(retention_days: int) -> int:
"""Cron entry: hard-delete rows trashed more than retention_days ago.
async def purge_expired(user_id: int, retention_days: int) -> int:
"""Cron entry: hard-delete THIS user's rows trashed more than retention_days ago.
Scoped to one owner so the scheduler can apply each user's own
`trash_retention_days` window — a single global sweep would let one
user's short window prematurely destroy another's data.
retention_days <= 0 disables auto-purge (returns 0 without touching anything).
"""
from datetime import timedelta
@@ -200,7 +266,9 @@ async def purge_expired(retention_days: int) -> int:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(
model.deleted_at.isnot(None), model.deleted_at < cutoff
model.deleted_at.isnot(None),
model.deleted_at < cutoff,
_owner_clause(model, user_id),
)
)
n += res.rowcount or 0
@@ -1,9 +1,9 @@
"""Daily APScheduler cron that purges expired trash.
Mirrors version_pinning_scheduler.py: a single global BackgroundScheduler job
at 03:30 UTC bridges into the asyncio loop to run the async purge. Reads the
operator's `trash_retention_days` setting (single-tenant: user 1); 0 disables
auto-purge.
at 03:30 UTC bridges into the asyncio loop to run the async purge. Iterates
every user and applies that user's own `trash_retention_days` setting; 0
disables auto-purge for that user.
"""
from __future__ import annotations
@@ -30,12 +30,22 @@ def _run_purge_threadsafe() -> None:
async def _runner():
try:
raw = await get_setting(1, "trash_retention_days", "90")
try:
days = int(raw)
except (TypeError, ValueError):
days = 90
purged = await trash_svc.purge_expired(days)
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.user import User
async with async_session() as session:
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:
+44 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
from fabledassistant.mcp.auth import resolve_bearer, resolve_bearer_to_user_id
@pytest.mark.asyncio
@@ -49,3 +49,46 @@ async def test_resolve_bearer_calls_lookup_with_stripped_token():
with patch("fabledassistant.mcp.auth.lookup_key", mock_lookup):
await resolve_bearer_to_user_id("Bearer fmcp_abc123 ")
mock_lookup.assert_awaited_once_with("fmcp_abc123")
# ── resolve_bearer (user_id + scope) ────────────────────────────────────
@pytest.mark.asyncio
async def test_resolve_bearer_returns_user_id_and_scope():
fake_key = MagicMock()
fake_key.user_id = 9
fake_key.scope = "read"
with patch("fabledassistant.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)):
assert await resolve_bearer("Bearer fmcp_x") == (9, "read")
@pytest.mark.asyncio
async def test_resolve_bearer_none_for_invalid():
with patch("fabledassistant.mcp.auth.lookup_key", AsyncMock(return_value=None)):
assert await resolve_bearer("Bearer nope") is None
assert await resolve_bearer(None) is None
# ── read-only scope gate ────────────────────────────────────────────────
def test_body_calls_write_tool_classifies_correctly():
import json
from fabledassistant.mcp.server import _body_calls_write_tool
def call(name):
return json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": {}}}).encode()
# Write-class tools are gated.
assert _body_calls_write_tool(call("create_note")) is True
assert _body_calls_write_tool(call("delete_project")) is True
assert _body_calls_write_tool(call("purge_trash")) is True
# An unknown/new tool defaults to write (default-deny for read keys).
assert _body_calls_write_tool(call("brand_new_tool")) is True
# Read tools and non-call methods pass.
assert _body_calls_write_tool(call("list_notes")) is False
assert _body_calls_write_tool(call("get_recent")) is False
assert _body_calls_write_tool(
json.dumps({"method": "tools/list"}).encode()
) is False
assert _body_calls_write_tool(b"not json") is False
+91
View File
@@ -0,0 +1,91 @@
"""Tests for MCP process tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.mcp._context import _user_id_ctx
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_note(id=1, title="Drift Audit", note_type="process"):
n = MagicMock()
n.id = id
n.title = title
n.note_type = note_type
n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type}
return n
@pytest.mark.asyncio
async def test_create_process_requires_title_and_body():
from fabledassistant.mcp.tools.processes import create_process
with pytest.raises(ValueError):
await create_process(title="", body="something")
with pytest.raises(ValueError):
await create_process(title="X", body=" ")
@pytest.mark.asyncio
async def test_create_process_sets_note_type():
created = _fake_note()
with patch("fabledassistant.services.notes.create_note",
AsyncMock(return_value=created)) as mock_create:
from fabledassistant.mcp.tools.processes import create_process
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
assert out["note_type"] == "process"
# the service was asked to create a process
assert mock_create.await_args.kwargs["note_type"] == "process"
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
@pytest.mark.asyncio
async def test_get_process_returns_body_and_candidates():
note = _fake_note(id=7)
with patch("fabledassistant.services.notes.resolve_process",
AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))):
from fabledassistant.mcp.tools.processes import get_process
out = await get_process("drift")
assert out["id"] == 7
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
@pytest.mark.asyncio
async def test_get_process_not_found_raises():
with patch("fabledassistant.services.notes.resolve_process",
AsyncMock(return_value=(None, []))):
from fabledassistant.mcp.tools.processes import get_process
with pytest.raises(ValueError):
await get_process("missing")
@pytest.mark.asyncio
async def test_update_process_rejects_non_process_note():
plain = _fake_note(id=3, note_type="note")
with patch("fabledassistant.services.notes.get_note",
AsyncMock(return_value=plain)):
from fabledassistant.mcp.tools.processes import update_process
with pytest.raises(ValueError):
await update_process(process_id=3, title="x")
def test_register_attaches_four_tools():
from fabledassistant.mcp.tools import processes
names: list[str] = []
class FakeMcp:
def tool(self, name):
names.append(name)
def deco(fn):
return fn
return deco
processes.register(FakeMcp())
assert set(names) == {
"list_processes", "create_process", "get_process", "update_process",
}
+18
View File
@@ -0,0 +1,18 @@
"""Structural tests for the dashboard blueprint."""
def test_dashboard_blueprint_registered():
from fabledassistant.routes.dashboard import dashboard_bp
assert dashboard_bp.name == "dashboard"
assert dashboard_bp.url_prefix == "/api/dashboard"
def test_dashboard_blueprint_registered_in_app():
from fabledassistant.app import create_app
app = create_app()
assert "dashboard" in app.blueprints
def test_dashboard_handler_callable():
from fabledassistant.routes import dashboard as dashboard_routes
assert callable(dashboard_routes.get_dashboard)
+66
View File
@@ -0,0 +1,66 @@
"""build_dashboard composition + helpers (services/dashboard.py).
Query semantics (ranking/caps/owner-scope) are exercised by manual smoke —
the repo's unit tests mock the DB, so SQL isn't executed here. These cover the
pure helpers and the section-isolation contract.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_task_row_maps_fields():
from fabledassistant.services.dashboard import _task_row
n = MagicMock()
n.id = 5
n.title = "Wire reminders"
n.status = "in_progress"
n.priority = None
assert _task_row(n) == {
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
}
@pytest.mark.asyncio
async def test_safe_returns_value_then_empty_on_error():
from fabledassistant.services.dashboard import _safe
async def ok():
return [1, 2, 3]
async def boom():
raise RuntimeError("section blew up")
assert await _safe(ok(), []) == [1, 2, 3]
# a failing section returns the supplied empty default, not an exception
assert await _safe(boom(), []) == []
assert await _safe(boom(), {}) == {}
@pytest.mark.asyncio
async def test_build_dashboard_composes_sections():
import fabledassistant.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
out = await dash.build_dashboard(user_id=1)
assert out == {
"active_projects": ["P"],
"recently_completed": ["done"],
"upcoming_events": ["evt"],
"week_stats": {"open_total": 4},
}
@pytest.mark.asyncio
async def test_build_dashboard_isolates_failing_section():
import fabledassistant.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
out = await dash.build_dashboard(user_id=1)
# failing section degrades to its empty default; others still populate
assert out["active_projects"] == []
assert out["recently_completed"] == ["done"]
+45
View File
@@ -0,0 +1,45 @@
"""get_knowledge_counts includes the 'process' type and counts it in total."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_mock_session():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
return s
def _grouped(rows):
r = MagicMock()
r.all.return_value = rows
return r
def _scalar(n):
r = MagicMock()
r.scalar_one.return_value = n
return r
@pytest.mark.asyncio
async def test_counts_include_process_in_facet_and_total():
session = _make_mock_session()
# 1) grouped non-task counts, 2) task count, 3) plan count
session.execute = AsyncMock(side_effect=[
_grouped([("note", 3), ("process", 2)]),
_scalar(1), # tasks
_scalar(0), # plans
])
with patch("fabledassistant.services.knowledge.async_session") as cls:
cls.return_value = session
from fabledassistant.services.knowledge import get_knowledge_counts
counts = await get_knowledge_counts(user_id=1)
assert counts["process"] == 2
# facet keys all present (setdefault)
for key in ("note", "person", "place", "list", "task", "plan", "process"):
assert key in counts
# total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2)
assert counts["total"] == 6
+87
View File
@@ -0,0 +1,87 @@
"""resolve_process precedence (id → exact title → substring) in services/notes.py.
Mocks async_session — no real DB, matching the other notes-service tests.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_mock_session():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
return s
def _result(first=None, all_=None):
"""A SQLAlchemy-result mock exposing .scalars().first()/.all()."""
r = MagicMock()
r.scalars.return_value.first.return_value = first
r.scalars.return_value.all.return_value = all_ or []
return r
def _note(id, title):
n = MagicMock()
n.id = id
n.title = title
return n
@pytest.mark.asyncio
async def test_resolve_process_by_numeric_id():
note = _note(5, "Drift Audit")
session = _make_mock_session()
# numeric id → first execute (id lookup) hits
session.execute = AsyncMock(side_effect=[_result(first=note)])
with patch("fabledassistant.services.notes.async_session") as cls:
cls.return_value = session
from fabledassistant.services.notes import resolve_process
found, candidates = await resolve_process(1, "5")
assert found is note
assert candidates == []
assert session.execute.await_count == 1
@pytest.mark.asyncio
async def test_resolve_process_exact_title_beats_substring():
note = _note(7, "Drift Audit")
session = _make_mock_session()
# non-digit → exact-title query (first execute) hits; substring never runs
session.execute = AsyncMock(side_effect=[_result(first=note)])
with patch("fabledassistant.services.notes.async_session") as cls:
cls.return_value = session
from fabledassistant.services.notes import resolve_process
found, candidates = await resolve_process(1, "Drift Audit")
assert found is note
assert candidates == []
assert session.execute.await_count == 1
@pytest.mark.asyncio
async def test_resolve_process_substring_returns_candidates():
n1 = _note(7, "Drift Audit Remediation")
n2 = _note(9, "Drift Audit Notes")
session = _make_mock_session()
# exact miss, then substring returns two (most-recent first)
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[n1, n2])])
with patch("fabledassistant.services.notes.async_session") as cls:
cls.return_value = session
from fabledassistant.services.notes import resolve_process
found, candidates = await resolve_process(1, "drift")
assert found is n1
assert candidates == [{"id": 9, "title": "Drift Audit Notes"}]
assert session.execute.await_count == 2
@pytest.mark.asyncio
async def test_resolve_process_no_match():
session = _make_mock_session()
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[])])
with patch("fabledassistant.services.notes.async_session") as cls:
cls.return_value = session
from fabledassistant.services.notes import resolve_process
found, candidates = await resolve_process(1, "nope")
assert found is None
assert candidates == []
+31 -5
View File
@@ -21,15 +21,18 @@ def _exists_result(found=True):
@pytest.mark.asyncio
async def test_delete_note_returns_batch_and_commits():
session = _make_mock_session()
# 1 execute for _exists_alive, then 2 for the note cascade (sub-tasks + self)
session.execute = AsyncMock(side_effect=[_exists_result(True), MagicMock(), MagicMock()])
# exists-check, then the subtree descent: one child-lookup (no children
# here) + one _set stamping the whole subtree.
no_children = MagicMock()
no_children.scalars.return_value.all.return_value = []
session.execute = AsyncMock(side_effect=[_exists_result(True), no_children, MagicMock()])
with patch("fabledassistant.services.trash.async_session") as cls:
cls.return_value = session
from fabledassistant.services.trash import delete
batch = await delete(user_id=1, entity_type="note", entity_id=5)
assert isinstance(batch, str) and len(batch) > 0
assert session.commit.called
# exists-check + 2 cascade updates
# exists-check + subtree-descent query + subtree _set
assert session.execute.await_count == 3
@@ -112,7 +115,7 @@ async def test_purge_expired_skips_when_retention_zero():
with patch("fabledassistant.services.trash.async_session") as cls:
cls.return_value = session
from fabledassistant.services.trash import purge_expired
n = await purge_expired(0)
n = await purge_expired(1, 0)
assert n == 0
assert not session.execute.called # never opens a delete
@@ -124,11 +127,34 @@ async def test_purge_expired_deletes_across_models_when_positive():
with patch("fabledassistant.services.trash.async_session") as cls:
cls.return_value = session
from fabledassistant.services.trash import purge_expired
n = await purge_expired(90)
n = await purge_expired(1, 90)
assert n == 7
assert session.execute.await_count == 7
def test_owner_clause_scopes_every_model():
"""Regression: every trash op must owner-scope, including topics/rules
which previously had NO owner check (IDOR across tenants)."""
from fabledassistant.services.trash import _owner_clause, _MODEL_FOR
from fabledassistant.models.note import Note
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
# Models with a direct owner column.
assert "user_id" in str(_owner_clause(Note, 7))
assert "owner_user_id" in str(_owner_clause(Rulebook, 7))
# Topics/rules carry no user_id — ownership is derived through the
# parent rulebook (and, for rules, the owning project).
topic_sql = str(_owner_clause(RulebookTopic, 7))
assert "rulebooks" in topic_sql and "owner_user_id" in topic_sql
rule_sql = str(_owner_clause(Rule, 7))
assert "owner_user_id" in rule_sql and "projects" in rule_sql
# Every entity type resolves to a model that yields a non-empty clause.
for model in set(_MODEL_FOR.values()):
assert _owner_clause(model, 1) is not None
@pytest.mark.asyncio
async def test_list_trash_groups_by_batch():
session = _make_mock_session()