Compare commits
29
Commits
2c929a0435
...
v26.06.03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837489e4f2 | ||
|
|
9a0d5f3109 | ||
|
|
5a930319ba | ||
|
|
266af7870d | ||
|
|
f446573c3d | ||
|
|
82d6812c7f | ||
|
|
8c9ca45479 | ||
|
|
e023c21aa1 | ||
|
|
e3d7007417 | ||
|
|
65c85bab15 | ||
|
|
7ef7d10b24 | ||
|
|
6a3619555d | ||
|
|
8b3bd4804e | ||
|
|
74b337b587 | ||
|
|
fb1ae915e4 | ||
|
|
c2b2694ea3 | ||
|
|
7b5a75989a | ||
|
|
1babe59843 | ||
|
|
0e980ee4b0 | ||
|
|
b5870d4694 | ||
|
|
c810d63bee | ||
|
|
a3a056d6fd | ||
|
|
2414437061 | ||
|
|
76dc75a03b | ||
|
|
c8765959ea | ||
|
|
f85b92a885 | ||
|
|
b81c4aa600 | ||
|
|
404698521f | ||
|
|
88b351a96e |
+28
-21
@@ -1,12 +1,19 @@
|
|||||||
# CI runs first; build only proceeds if all checks pass.
|
# CI runs first; build only proceeds if all checks pass.
|
||||||
#
|
#
|
||||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
# Push to main: typecheck + lint + test + build :<sha> (no moving tag)
|
||||||
|
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||||
#
|
#
|
||||||
# main pushes are NOT gated here: a merge to main only happens after
|
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
|
||||||
# dev has already passed CI, and the release tag is the sole trigger
|
# pushes publish only the immutable :<sha> image — no :main tag, because
|
||||||
# for a production image. Re-running CI on the merge commit just burns
|
# :latest (release-only) is the single production pointer and a :main alias
|
||||||
# runner time without changing the outcome.
|
# would just duplicate it. Running CI on the main merge commit is intentional:
|
||||||
|
# main is validated and its :<sha> image is the rollback point. The v* release
|
||||||
|
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
|
||||||
|
#
|
||||||
|
# Successive pushes to the SAME ref supersede each other (see concurrency
|
||||||
|
# below), so rapid pushes don't stack identical work; dev and main runs are
|
||||||
|
# independent refs and never cancel one another.
|
||||||
#
|
#
|
||||||
# To cut a release:
|
# To cut a release:
|
||||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||||
@@ -16,11 +23,8 @@
|
|||||||
# gating on branch push is already enough.
|
# gating on branch push is already enough.
|
||||||
#
|
#
|
||||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
||||||
# still trigger the workflow, producing redundant runs on the same SHA
|
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
||||||
# that was already gated on dev. Every job therefore repeats the ref
|
|
||||||
# check so main pushes trigger the workflow but every job skips
|
|
||||||
# immediately (no runner time, no duplicate work).
|
|
||||||
#
|
#
|
||||||
# Required secrets (repo → Settings → Secrets → Actions):
|
# Required secrets (repo → Settings → Secrets → Actions):
|
||||||
# REGISTRY_USER — your Forgejo username
|
# REGISTRY_USER — your Forgejo username
|
||||||
@@ -29,7 +33,7 @@ name: CI & Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
@@ -67,8 +71,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript typecheck
|
name: TypeScript typecheck
|
||||||
# Skip on main merge-commit pushes — see workflow header comment.
|
# Gate dev, main, and v* tags; reject any other ref (see header note).
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -92,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -106,7 +110,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -138,11 +142,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
needs: [typecheck, lint, test]
|
needs: [typecheck, lint, test]
|
||||||
# Build on dev branch pushes and version tag pushes only.
|
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
|
||||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||||
# pushes skip here too, so no production image is ever built from a
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
# raw main push (only from the v* tag the release creates).
|
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -168,6 +170,11 @@ jobs:
|
|||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
|
refs/heads/main)
|
||||||
|
# main publishes only the immutable :<sha> image (set above) —
|
||||||
|
# no :main tag; :latest (release-only) is the production pointer.
|
||||||
|
BUILD_VERSION="main"
|
||||||
|
;;
|
||||||
refs/tags/*)
|
refs/tags/*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
BUILD_VERSION="${{ github.ref_name }}"
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ router.afterEach(() => {
|
|||||||
<!-- Center: primary navigation (desktop) -->
|
<!-- Center: primary navigation (desktop) -->
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<div class="nav-pill-bar">
|
<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="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
@@ -90,7 +91,8 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
<!-- Mobile dropdown -->
|
<!-- Mobile dropdown -->
|
||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<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="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
// Knowledge is the landing page in the MCP-first architecture
|
// The dashboard ("what to work on") is the landing page; Knowledge
|
||||||
// (chat / journal / workspace surfaces have been removed).
|
// remains as the exhaustive "Browse" surface.
|
||||||
path: "/",
|
path: "/",
|
||||||
redirect: "/knowledge",
|
redirect: "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("@/views/DashboardView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/knowledge",
|
path: "/knowledge",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
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 {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
MapPin,
|
MapPin,
|
||||||
List,
|
List,
|
||||||
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -23,7 +24,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list" | "task";
|
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -61,7 +62,7 @@ interface UpcomingEvent {
|
|||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── 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 activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -69,8 +70,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
|
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, total: 0 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -403,6 +404,10 @@ onUnmounted(() => {
|
|||||||
<List :size="16" />
|
<List :size="16" />
|
||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="createNew('process')">
|
||||||
|
<Workflow :size="16" />
|
||||||
|
Process
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,11 +422,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
: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 class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</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 === 'person'">Person</span>
|
||||||
<span v-else-if="item.note_type === 'place'">Place</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 === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else>List</span>
|
<span v-else>List</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const titlePlaceholder = computed(() => {
|
|||||||
case 'person': return 'Name';
|
case 'person': return 'Name';
|
||||||
case 'place': return 'Place name';
|
case 'place': return 'Place name';
|
||||||
case 'list': return 'List title';
|
case 'list': return 'List title';
|
||||||
|
case 'process': return 'Process name';
|
||||||
default: return 'Title';
|
default: return 'Title';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -602,6 +603,18 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 ──────────────────────────────── -->
|
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="body-tabs-row">
|
<div class="body-tabs-row">
|
||||||
@@ -1038,6 +1051,26 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--color-primary);
|
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 {
|
.ef-input {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from fabledassistant.routes.profile import profile_bp
|
|||||||
from fabledassistant.routes.knowledge import knowledge_bp
|
from fabledassistant.routes.knowledge import knowledge_bp
|
||||||
from fabledassistant.routes.rulebooks import rulebooks_bp
|
from fabledassistant.routes.rulebooks import rulebooks_bp
|
||||||
from fabledassistant.routes.trash import trash_bp
|
from fabledassistant.routes.trash import trash_bp
|
||||||
|
from fabledassistant.routes.dashboard import dashboard_bp
|
||||||
from fabledassistant.mcp import mount_mcp
|
from fabledassistant.mcp import mount_mcp
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
@@ -87,6 +88,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(rulebooks_bp)
|
app.register_blueprint(rulebooks_bp)
|
||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def before_request():
|
async def before_request():
|
||||||
|
|||||||
@@ -36,7 +36,20 @@ Mechanics:
|
|||||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||||
unchanged on updates.
|
unchanged on updates.
|
||||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||||
"not set".
|
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||||
|
removes the task from its milestone); 0 leaves it unchanged.
|
||||||
|
|
||||||
|
Keep task state honest — this is what makes the project a trustworthy record:
|
||||||
|
- When you begin working a task, set it to in_progress (update_task
|
||||||
|
status=in_progress).
|
||||||
|
- Log progress as you go with add_task_log — at meaningful steps, not saved up
|
||||||
|
for the end.
|
||||||
|
- The moment a task's work is complete, set it done. Never leave finished work
|
||||||
|
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
|
||||||
|
left to do.
|
||||||
|
- At a significant landing (a merge, a shipped feature, a finished plan), write
|
||||||
|
a short dated dev-log note on the project (create_note) summarizing what
|
||||||
|
landed, and mark the plan/task done.
|
||||||
|
|
||||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||||
an actionable statement plus optional Why and How-to-apply context. At the
|
an actionable statement plus optional Why and How-to-apply context. At the
|
||||||
@@ -56,6 +69,19 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
|||||||
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||||
in a project, an existing rulebook+topic, or a new rulebook.
|
in a project, an existing rulebook+topic, or a new rulebook.
|
||||||
|
|
||||||
|
That boundary cuts the other way too. Because rules are pull-only, a fresh
|
||||||
|
session won't reach for them unless its always-loaded context says to. So
|
||||||
|
when the current project subscribes to a rulebook (enter_project /
|
||||||
|
get_project reports subscribed_rulebooks), make sure the host's persistent
|
||||||
|
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
|
||||||
|
startup — carries a short pointer: that the engineering and workflow rules
|
||||||
|
for this work live in Scribe and must be loaded via list_always_on_rules (or
|
||||||
|
enter_project when a project is in scope), plus a one-line note of what the
|
||||||
|
current project is and what is in flight. Add or refresh that pointer when
|
||||||
|
it's missing or stale; never copy the rules' content into memory — the
|
||||||
|
pointer plus project context is the whole job. This is what lets the next
|
||||||
|
session reach for Scribe instead of trusting a stale local copy.
|
||||||
|
|
||||||
When you are working on a specific project, call enter_project(project_id)
|
When you are working on a specific project, call enter_project(project_id)
|
||||||
ONCE at session start (or whenever the active project changes). It returns the
|
ONCE at session start (or whenever the active project changes). It returns the
|
||||||
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||||
@@ -63,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of
|
|||||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||||
search separately when enter_project already composes them.
|
search separately when enter_project already composes them.
|
||||||
|
|
||||||
|
Don't wait to be told which project you're in. At the start of a session that
|
||||||
|
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||||
|
scope — bootstrap project context proactively: search for a related existing
|
||||||
|
project (search / list_projects, matching on the work's subject, the repo or
|
||||||
|
directory name, and recent activity). If you find a confident match, propose it
|
||||||
|
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||||
|
create a project, confirming its name and goal first. Always confirm before
|
||||||
|
adopting or creating — never do either silently, and never guess a project into
|
||||||
|
existence. Once a project is in scope, the enter_project handshake and the
|
||||||
|
host-memory pointer step above both apply.
|
||||||
|
|
||||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||||
@@ -78,6 +115,13 @@ 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
|
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||||
auto-purges after the operator's retention window.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
|||||||
from `mcp.server.build_mcp_server`.
|
from `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.mcp.tools import (
|
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)
|
tags.register(mcp)
|
||||||
recent.register(mcp)
|
recent.register(mcp)
|
||||||
entities.register(mcp)
|
entities.register(mcp)
|
||||||
|
processes.register(mcp)
|
||||||
rulebooks.register(mcp)
|
rulebooks.register(mcp)
|
||||||
trash.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)
|
||||||
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
|
|||||||
what makes a Note a Task)
|
what makes a Note a Task)
|
||||||
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
||||||
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
||||||
"leave unchanged" on update
|
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -143,10 +143,14 @@ async def update_task(
|
|||||||
task_id: ID of the task to update.
|
task_id: ID of the task to update.
|
||||||
title: New title, or omit to leave unchanged.
|
title: New title, or omit to leave unchanged.
|
||||||
body: New markdown body, or omit to leave unchanged.
|
body: New markdown body, or omit to leave unchanged.
|
||||||
status: New status — one of: todo, in_progress, done, cancelled.
|
status: New status — one of: todo, in_progress, done, cancelled. Drive
|
||||||
|
the lifecycle: set in_progress when you start, done when complete —
|
||||||
|
don't leave finished work at todo.
|
||||||
priority: New priority — one of: none, low, medium, high.
|
priority: New priority — one of: none, low, medium, high.
|
||||||
project_id: New project. Omit (0) to leave unchanged.
|
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
|
||||||
milestone_id: New milestone. Omit (0) to leave unchanged.
|
its project; also clears the milestone), positive = set.
|
||||||
|
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
|
||||||
|
from its milestone), positive = set.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -158,9 +162,15 @@ async def update_task(
|
|||||||
fields["status"] = status
|
fields["status"] = status
|
||||||
if priority:
|
if priority:
|
||||||
fields["priority"] = priority
|
fields["priority"] = priority
|
||||||
if project_id:
|
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
|
||||||
|
if project_id == -1:
|
||||||
|
fields["project_id"] = None
|
||||||
|
fields["milestone_id"] = None # a milestone can't outlive its project
|
||||||
|
elif project_id:
|
||||||
fields["project_id"] = project_id
|
fields["project_id"] = project_id
|
||||||
if milestone_id:
|
if milestone_id == -1:
|
||||||
|
fields["milestone_id"] = None
|
||||||
|
elif milestone_id:
|
||||||
fields["milestone_id"] = milestone_id
|
fields["milestone_id"] = milestone_id
|
||||||
note = await notes_svc.update_note(uid, task_id, **fields)
|
note = await notes_svc.update_note(uid, task_id, **fields)
|
||||||
if note is None:
|
if note is None:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
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"}
|
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]}
|
||||||
@@ -237,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.user_id == user_id)
|
||||||
.where(Note.status.is_(None))
|
.where(Note.status.is_(None))
|
||||||
.where(Note.deleted_at.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)
|
.group_by(Note.note_type)
|
||||||
)
|
)
|
||||||
if tags:
|
if tags:
|
||||||
@@ -273,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]))
|
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
|
||||||
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
|
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.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
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,39 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
|||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
base = select(Note).where(
|
||||||
|
Note.user_id == user_id,
|
||||||
|
Note.note_type == "process",
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
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]:
|
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
|
||||||
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
|
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
|
||||||
if not note_ids:
|
if not note_ids:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
|
|||||||
await update_task(task_id=999, status="done")
|
await update_task(task_id=999, status="done")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_milestone_zero_is_omitted():
|
||||||
|
"""milestone_id=0 is 'leave unchanged' — must not reach the service."""
|
||||||
|
fake = _fake_task()
|
||||||
|
mock = AsyncMock(return_value=fake)
|
||||||
|
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||||
|
await update_task(task_id=1, milestone_id=0)
|
||||||
|
assert "milestone_id" not in mock.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_milestone_positive_is_set():
|
||||||
|
fake = _fake_task()
|
||||||
|
mock = AsyncMock(return_value=fake)
|
||||||
|
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||||
|
await update_task(task_id=1, milestone_id=42)
|
||||||
|
assert mock.call_args.kwargs["milestone_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_milestone_negative_one_clears():
|
||||||
|
"""milestone_id=-1 clears the milestone (sets the column NULL)."""
|
||||||
|
fake = _fake_task()
|
||||||
|
mock = AsyncMock(return_value=fake)
|
||||||
|
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||||
|
await update_task(task_id=1, milestone_id=-1)
|
||||||
|
assert mock.call_args.kwargs["milestone_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_clearing_project_also_clears_milestone():
|
||||||
|
"""project_id=-1 clears the project and, with it, the milestone."""
|
||||||
|
fake = _fake_task()
|
||||||
|
mock = AsyncMock(return_value=fake)
|
||||||
|
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||||
|
await update_task(task_id=1, project_id=-1)
|
||||||
|
assert mock.call_args.kwargs["project_id"] is None
|
||||||
|
assert mock.call_args.kwargs["milestone_id"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_task_log_returns_log_dict():
|
async def test_add_task_log_returns_log_dict():
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
Reference in New Issue
Block a user