Compare commits

..

41 Commits

Author SHA1 Message Date
bvandeusen 837489e4f2 Merge pull request 'CI: build on main (and drop the :main tag)' (#57) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 14s
2026-06-03 12:44:44 -04:00
bvandeusen 9a0d5f3109 ci: drop the :main tag — main builds publish only the immutable :<sha>
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 14s
:latest (release-only) is the single production pointer; a :main moving
tag just duplicated it. main pushes still gate + build (the :<sha> image
is the rollback point), but no longer publish a :main alias. The tag was
new and unreferenced, so nothing depends on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:52:21 -04:00
bvandeusen 5a930319ba ci: gate and build main too (:main image); :latest stays release-only
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 15s
Previously main pushes were deliberately skipped — CI only ran on dev
and v* tags. This conflicted with the intended policy (CI on dev AND
main). Now main is a first-class gated, built line: dev->:dev, main->:main,
v* tag->:latest + :<version>, every build also tagged with the commit sha.
Per-ref concurrency already supersedes rapid pushes, so dev and main run
independently without stacking identical work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:27:09 -04:00
bvandeusen 266af7870d Merge pull request 'MCP instruction hardening + milestone-unset' (#56) from dev into main 2026-06-03 11:19:09 -04:00
bvandeusen f446573c3d feat(mcp): proactive project bootstrapping at session start
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m6s
Adds an always-on _INSTRUCTIONS directive: when work touches Scribe and
no project is in scope, search for a related project and propose
enter_project (confirm first), or offer to create one (confirm name/goal
first) — never silently adopt or create. Pairs with the enter_project
handshake and the host-memory pointer directive. Closes scribe task #585.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:01:55 -04:00
bvandeusen 82d6812c7f feat(mcp): milestone_id=-1 clears a task's milestone (update_task)
Optional FKs on update_task previously had no way to express 'remove' —
0 meant leave-unchanged and any positive int meant set, so a milestone
(or project) could only be cleared via the web UI. Now -1 clears the FK
(NULL); clearing project_id also clears milestone_id since a milestone
can't outlive its project. update_note already NULLs on None, so the
change is confined to the tool wrapper. Closes scribe task #586.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:59:47 -04:00
bvandeusen 8c9ca45479 feat(mcp): instruct agents to keep a Scribe-rules pointer in host memory
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 1m6s
When a project subscribes to a rulebook, the agent should ensure the
host's persistent memory carries a pointer that engineering/workflow
rules live in Scribe (loaded via list_always_on_rules / enter_project),
plus a one-line note of the current project's work. Pairs with the
existing 'don't duplicate rules into memory' directive: memory holds the
pointer + project context, Scribe holds the rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:42:23 -04:00
bvandeusen e023c21aa1 docs(mcp): instruct agents to drive task lifecycle + log work
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m48s
Adds a 'keep task state honest' directive to the MCP _INSTRUCTIONS: set
in_progress on start, log progress with add_task_log as you go, set done the
moment work completes (never leave finished work at todo), and write a dated
dev-log note on the project at significant landings. Reinforced in the
update_task status docstring. App-layer + always-loaded, no rule/config needed
— closes the gap where finished work (e.g. a shipped plan) sat open because the
lifecycle was available but never prescribed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:24:26 -04:00
bvandeusen e3d7007417 Merge pull request 'Drift-audit remediation + Stored Processes + Dashboard' (#55) from dev into main 2026-06-03 08:11:14 -04:00
bvandeusen 65c85bab15 feat(dashboard): DashboardView landing + route + nav
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Task 3 of #583. New DashboardView at /dashboard composes the approved layout:
done-recently strip, Active-now project panels (project -> active milestones ->
open tasks, in-progress flagged, + no-milestone group), and a rail with
upcoming events / week stats / quick-create (Task/Note/Process). '/' now
redirects to /dashboard; AppHeader gains a Dashboard link and relabels
Knowledge -> Browse (route unchanged). Empty + loading states included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:24:32 -04:00
bvandeusen 7ef7d10b24 feat(dashboard): GET /api/dashboard endpoint
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m17s
Task 2 of #583. Minimal login-gated blueprint returning build_dashboard(uid);
registered in the app factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:22:08 -04:00
bvandeusen 6a3619555d feat(dashboard): aggregation service build_dashboard
Task 1 of #583. build_dashboard(user_id) assembles the /dashboard payload:
most-recently-active projects (ranked by max child updated_at) each broken
into active milestones -> open tasks (in_progress->priority->recency, capped 5),
recently-completed (7d/8), upcoming events (7d), week stats. Owner-scoped,
trashed excluded; each section isolated via _safe so one failure doesn't blank
the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:21:23 -04:00
bvandeusen 8b3bd4804e feat(processes): monospace prompt editor for note_type=process
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 41s
Task 6 of #582. NoteType gains 'process'. NoteEditorView branches to a plain
monospace textarea (labeled Prompt) for processes instead of the TipTap
rich-text editor — prompts are plain markdown and rich-text round-tripping
would mangle them. Title/tags/save path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:33:23 -04:00
bvandeusen 74b337b587 feat(processes): surface processes in the Knowledge view
Task 5 of #582. Add 'process' to the KnowledgeItem/activeType/KnowledgeCounts
types, a Processes entry in the type-filter row, a Workflow-icon quick-create
button (createNew('process') -> /notes/new?type=process), and a Process card
badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:33:23 -04:00
bvandeusen fb1ae915e4 feat(processes): expose process as a knowledge type
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 1m16s
Task 4 of #582. Add 'process' to the knowledge route _VALID_TYPES and to the
get_knowledge_counts facet + total. query_knowledge/_apply_type_filter already
handle arbitrary note_type, so listing by type=process works unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:29:08 -04:00
bvandeusen c2b2694ea3 docs(mcp): document Processes in server instructions
Task 3 of #582. Tells Claude that note_type=process notes are reusable saved
prompts and to fire them via list_processes/get_process on 'run the X process'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:29:08 -04:00
bvandeusen 7b5a75989a feat(processes): MCP create/list/get/update_process tools
Task 2 of #582. New mcp/tools/processes.py mirrors entities.py — tools wrap
notes_svc directly. get_process is the fire mechanism (returns the full prompt
via resolve_process; surfaces other_matches on an ambiguous name). Registered
in register_all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:27:41 -04:00
bvandeusen 1babe59843 feat(processes): add resolve_process name/id resolver
Task 1 of the Stored Processes plan (#582). resolve_process(user_id, name_or_id)
resolves a note_type=process note owner-scoped + non-trashed, precedence
numeric id -> exact case-insensitive title -> substring; returns
(note, other_candidates) so an ambiguous fuzzy match can be disambiguated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:26:29 -04:00
bvandeusen 2c929a0435 feat(reminders): per-occurrence reminders for recurring events
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 1m6s
Drift-audit Group 8 (final item). _fire_reminders previously gated on the
base row (reminder_sent_at IS NULL AND start_dt > now), so a recurring event
reminded at most once ever — once the first occurrence passed, no future
occurrence qualified.

Now recurring events are evaluated every sweep against their next occurrence
(rrulestr.after(now)), and reminder_sent_at stores the start of the occurrence
last reminded about. Each new occurrence has a distinct marker, so it re-arms
and fires exactly once per occurrence. One-shot events keep the classic
NULL gate. Also adds the deleted_at filter so trashed events stop reminding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:50:45 -04:00
bvandeusen cf4962d7e8 chore(profile): drop dead curator columns from UserProfile
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 58s
Drift-audit Group 7: learned_summary, observations_raw, and
observations_updated_at were populated by the curator/LLM-profile machinery
removed in the Phase-8 pivot. Nothing has written them since and the profile
API returned permanently-empty fields. Remove them from the model + to_dict
and drop the columns (migration 0062). Verified zero frontend/backend/test
consumers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:48:37 -04:00
bvandeusen 64bc50c788 chore(frontend): drop dead settings toggle, types, and store list-surface
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Has been cancelled
Drift-audit Group 7 frontend cleanup (no behavioral change):

- SettingsView: remove the 'auto-consolidate task bodies' toggle and its
  saveAutoConsolidate handler. The auto_consolidate_tasks setting has zero
  backend readers (curator removed in Phase 8); the control did nothing.
- AppSettings type: drop the dead assistant_name / default_model hints (kept
  the open string index signature the store actually uses). Delete the fully
  orphaned types/chat.ts (zero importers).
- notes/tasks Pinia stores: remove the list/filter/sort/pagination surface
  that backed the removed /notes and /tasks list views (verified no consumer
  uses the tasks/notes arrays, refresh, or any filter/sort/pagination method).
  Kept currentNote/currentTask, loading, fetch/create/update/delete, convert,
  patchStatus, startPlanning, backlinks, tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:47:28 -04:00
bvandeusen c39d7356ed chore(dead-code): fix prod image, drop orphaned code, correct delete_rule doc
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m40s
Drift-audit Group 7 (renamed/removed lingers) + Group 5 #9:

- docker-compose.prod.yml pulled fabledassistant:latest, a tag CI stopped
  publishing after the rename. Point it at fabledscribe:latest (the name CI
  and quickstart use). The internal DB name stays fabledassistant by design.
- Remove the unused hard-delete delete_note imports from the notes and tasks
  route modules (they delete via trash; the import was an attractive nuisance
  that bypassed soft-delete).
- delete_rule MCP tool: docstring/warning said 'permanently delete' but the
  body moves the rule to recoverable trash. Corrected to match.
- Delete services/calendar_sync.py: fully orphaned (zero importers) and it
  read Config attrs that no longer exist, so any re-wiring would crash.
- Remove dead services: notes.search_notes_for_context and logging.log_generation
  (zero callers; log_generation wrote a 'generation' category no stats/UI surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:28:36 -04:00
bvandeusen 8d739c5da1 perf(search): offload cosine scoring off event loop; document best-effort feed
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Build & push image (push) Has been cancelled
CI & Build / Python tests (push) Has been cancelled
Drift-audit Group 9 (param-cliff / unbounded search work):

- semantic_search_notes: the O(rows) cosine-similarity scoring loop ran
  synchronously on the event loop, so every RAG injection / search stalled
  other requests proportional to the user's embedding count. Move the scoring
  into asyncio.to_thread (results unchanged). The deeper fix — bounding the
  candidate set via pgvector ORDER BY/LIMIT — is noted as separate infra work.
- _semantic_knowledge_search: documented the best-effort top-N semantics —
   is the capped candidate-window size (not the true match count),
  matches beyond the cap aren't page-reachable, and each page recomputes the
  full merge. Prevents the silent-truncation trap; cached ranked-id paging /
  pgvector is the fix if exhaustive pagination is ever required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:24:57 -04:00
bvandeusen 7ce5bb8450 fix(lifecycle): OAuth pw 500, invite lockout, reminder re-arm, partial-unique
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Has been cancelled
Drift-audit Group 8 (lifecycle gaps):

- change_password no longer 500s for OAuth-only users: short-circuit when
  password_hash is None (verify_password would crash on None) so the route
  returns a clean 4xx instead of a 500.
- register_with_invitation no longer locks the invitee out on a username
  collision: create the user FIRST, then mark the token used, so a failed
  creation (409) leaves the single-use invite valid for retry.
- update_event re-arms reminder_sent_at when start_dt/reminder_minutes change,
  so a rescheduled event fires again instead of being permanently suppressed.
- Migration 0061: uq_topic_per_rulebook / uq_rule_per_topic become PARTIAL
  unique indexes (WHERE deleted_at IS NULL). Trashing 'X' then recreating it
  no longer 500s on the dead row's title. Model __table_args__ updated to match.

Deferred: per-occurrence reminders for recurring events (event_scheduler) —
needs a per-occurrence reminder-state design, not a one-line gate tweak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:23:35 -04:00
bvandeusen 2fd9a2300a fix(caldav): point-event round-trip, recurrence push, delete propagation
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m16s
Drift-audit Group 5 #7/#8 + Group 7 (CalDAV write-path):

- Point events no longer fabricate a 60-min DTEND: caldav.create_event emits
  DTSTART-only when there's no end and no duration, so the next pull doesn't
  read it back as duration_minutes=60 and silently lengthen the event.
- Recurrence edits now propagate: caldav.update_event gains a recurrence param
  (sentinel = leave unchanged; value/empty = set/clear RRULE), and _push_update
  passes the local event's rule so a changed/cleared RRULE isn't overwritten
  by the stale remote rule on the next pull.
- Event deletions propagate to CalDAV: trash.delete captures an event's
  caldav_uid before soft-deleting and fires _push_delete, so a UI/MCP delete
  removes the remote copy instead of leaving it to linger. (delete_event the
  service primitive is kept — still tested/usable — rather than removed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:20:14 -04:00
bvandeusen c016bd664e fix(status-enum): add paused to ProjectStatus, validate, fix progress
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 46s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m14s
Drift-audit Group 6 + Group 5 #6 (enum-extension / status drift):

- ProjectStatus gains 'paused' — routes and frontend already treated it as
  first-class, but the enum (the source of truth) omitted it and the error
  strings lied. A future CHECK derived from the enum would have rejected
  existing paused rows.
- create_project/update_project now validate status via ProjectStatus at the
  service layer (canonical gate; notes.status has no DB CHECK), so the MCP
  create/update_project path can't persist a typo'd status. MCP docstrings
  realigned to the 4-value domain; route error strings corrected.
- get_milestone_progress: cancelled tasks are excluded from the percent
  denominator (and now reported in status_counts), so a milestone whose only
  open task was cancelled reaches 100% instead of stalling below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:16:45 -04:00
bvandeusen 4a220db513 test(mcp): import resolve_bearer at module level
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m32s
CI fix for aef5009: test_resolve_bearer_none_for_invalid referenced
resolve_bearer but the import lived inside an earlier test only. Hoist it
to the module import. Production code unaffected (1 failed / 284 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:12:14 -04:00
bvandeusen aef5009fc2 fix(contract-drift): MCP read-only scope, shared-note writes, event TZ
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 24s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
Drift-audit Group 5 (high-severity contract drift):

- MCP read-only keys could call every write tool: the Bearer resolver
  discarded api_key.scope and dispatch had no gate. Add resolve_bearer()
  (returns user_id + scope) and a scope gate in the /mcp ASGI wrapper that
  buffers the JSON-RPC body and rejects tools/call for any tool outside a
  read all-list when scope=='read' (default-deny for unknown/new tools).
- Shared project notes/tasks panel was empty for non-owners: get_project_notes_route
  now queries notes/milestones with the project OWNER's uid (mirrors the
  already-fixed milestones route).
- Shared editors couldn't save/delete shared NOTES (tasks worked): the three
  notes write routes now resolve via get_note_for_user, gate on can_write_note,
  and write as the owner — matching the tasks routes.
- Event timezone drift: naive datetimes from the MCP date+time split are now
  localized to the user's tz at a single canonical service point (create_event
  /update_event), so MCP- and UI-created events agree. tz-aware inputs
  (REST/CalDAV) pass through untouched.
- create_note validates status/priority (TaskStatus/TaskPriority), closing the
  MCP create_task path that let out-of-enum values persist (no DB CHECK).

Tests cover resolve_bearer scope + the write-tool classifier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:02:19 -04:00
bvandeusen c363a5a6df fix(retention): add cleanup sweeps + CalDAV orphan reconciliation
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m29s
Drift-audit Group 4 (retention / unbounded growth):

- CalDAV pull now reconciles deletions: a previously-synced event whose
  caldav_uid no longer appears remotely within the synced window is
  soft-deleted (one batch_id per run, restorable), so a remote delete
  propagates locally instead of orphaning forever. Guarded on a non-empty
  fetch so a spurious empty result can't wipe every local copy. Also wrap
  the blocking fetch in a 120s wait_for and log run duration.
- Notifications: hourly loop now purges read notifications older than 30d
  (unread kept). Table no longer grows without bound.
- Auth tokens: new daily sweep deletes password-reset / invitation tokens
  whose validity window ended >7d ago; wired via start_auth_token_retention_loop
  in app startup. Both tables previously only flipped used=True, never pruned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:55:23 -04:00
bvandeusen 5fe0fd126d fix(soft-delete): filter trashed rows across read/write paths
CI & Build / TypeScript typecheck (push) Has been cancelled
CI & Build / Python lint (push) Has been cancelled
CI & Build / Python tests (push) Has been cancelled
CI & Build / Build & push image (push) Has been cancelled
Drift-audit Group 3 (soft-delete lifecycle gaps). Trashed rows were
leaking into reads and being mutated/resurrected by writes:

- update SELECTs now exclude trashed rows: update_milestone,
  update_project, update_event, and get_milestone_in_project (the latter
  backs all four milestone routes). Mutating a trashed row silently
  persisted and reappeared on restore.
- MCP get_recent (notes/projects/events) and list_tags now filter
  deleted_at IS NULL, so trashed items stop surfacing in the agent's
  bootstrap context and tag counts.
- convert_task_to_note clears recurrence_rule + recurrence_next_spawn_at
  so a demoted note can't spawn children via the (now-live) sweep.
- caldav pull skips locally-trashed events (by caldav_uid) instead of
  resurrecting them via update or creating a duplicate live copy.
- trash _cascade now stamps the FULL sub-task subtree (iterative descent),
  not just direct children, so deeply nested sub-tasks restore as one
  batch. Test updated for the new descent query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:51:40 -04:00
bvandeusen 8b49ea896a fix(schedulers): wire recurring-task spawn + deliver event reminders
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m10s
Drift-audit Group 2 (Phase-8 amputation — live wiring, no consumer):

- Recurring tasks never recurred: spawn_recurring_tasks() had no caller.
  Register it as a 15-min interval job in the event scheduler (which
  app.py already starts/stops). Also add a deleted_at IS NULL guard to
  the spawn query in the same change, so a trashed recurring parent can
  never resurrect children once the sweep is live.
- Event reminders were stamped reminder_sent_at but never delivered.
  _fire_reminders now creates an 'event_reminder' in-app notification
  before stamping, so a delivery failure stays retryable. Frontend
  NotificationsPanel renders the new type ( + message); message logic
  pulled into a notifMessage() helper.
- Remove the dead _fire_push_notif no-op stub (push left in Phase 8) and
  its three create_task call sites — no more throwaway tasks per share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:47:54 -04:00
bvandeusen e70fe545cc fix(trash): owner-scope all trash ops — close cross-tenant IDOR/disclosure
CI & Build / Python lint (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 2m18s
Drift-audit Group 1 (authz/IDOR). Multi-user is live, so these were
exploitable ACL bypasses:

- trash.py: add _owner_clause() and apply it to _exists_alive, restore,
  purge, list_trash, and purge_expired. A batch_id is a bearer token;
  without an owner predicate a leaked/guessed id let one tenant read
  (list_trash), restore, or PERMANENTLY purge another's content. Topics
  and rules carried no owner check at all (_OWNER mapped them to None) —
  ownership now derives through the parent rulebook (or owning project,
  for project-scoped rules).
- purge_expired is now per-user; trash_scheduler iterates every user and
  applies that user's own trash_retention_days window, instead of
  applying user 1's window to everyone (early data loss for other users).
- rulebooks subscribe/unsubscribe_project now assert project ownership,
  matching the suppression endpoints.
- topic/rule DELETE routes return 404 when nothing owned was removed.

Regression test locks in that every model — including topics/rules —
gets a real owner clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:44:32 -04:00
bvandeusen 0e980ee4b0 Merge pull request 'Project rule + topic suppressions' (#54) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 22s
2026-06-01 08:01:59 -04:00
bvandeusen 7861607fb8 feat(rules): project rule + topic suppressions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m18s
Lets a project mute individual rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two new association
tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic),
4 REST endpoints, and an inline "× skip" affordance plus collapsed
"Suppressed (N)" section in the project's Rules tab.

get_applicable_rules now emits suppressed_rules and suppressed_topics
(detail objects with rulebook/topic context, not just IDs) so the UI
can render the suppressed list without a follow-up lookup. The main
rules projection grew topic_id and rulebook_id columns for the per-row
suppress affordance.

Project deletion cascades the suppression rows via hard DELETE — they
are pure associations with no soft-delete column, and restoring a
deleted project should start fresh, not inherit stale mutes.

Project-scoped rules (Rule.project_id) are deliberately not suppressible
— delete them with delete_rule instead.

Implements plan-task #187.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:26:20 -04:00
bvandeusen b5870d4694 Merge pull request 'Rules consolidation: Scribe-first check, project-scoped rules, enter_project handshake' (#53) from dev into main 2026-06-01 01:16:55 -04:00
bvandeusen c5469214e3 feat(rules): enter_project handshake (S4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 4s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Has been skipped
New enter_project(project_id) MCP tool composes get_project +
get_applicable_rules + get_project_milestone_summary + recent
open-tasks + recent notes into one round-trip, intended to be called
at session start (or whenever the active project changes) so Claude
has the full project context loaded before it starts mutating.

_INSTRUCTIONS now points Claude at enter_project for project-scoped
work, alongside the existing list_always_on_rules instruction. No
schema change; pure composition over existing services.

Closes the four-slice rules-consolidation plan (Scribe task #508):
S1+S2 (always_on flag + Scribe-first prompt, 658348f), S3 (project-
scoped rules, 43a860c), and now S4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:14:37 -04:00
bvandeusen 43a860c3ac feat(rules): project-scoped rules (S3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m7s
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.

- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
  topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
  project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
  with project_id filter UNIONs subscription-derived + project-scoped;
  get_applicable_rules adds a project_rules field; get_rule / update_rule
  / delete_rule fetch via a shared _fetch_owned_rule that handles both
  rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
  create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
  if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
  client; ProjectRulesTab.vue gains a "Project rules" section with inline
  create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
  fields, title derivation, explicit-title pass-through); applicable_rules
  shape tests now include project_rules; trash cascade test updated to
  expect 5 executions

S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:10:18 -04:00
bvandeusen 658348f208 feat(rules): always_on rulebook flag + Scribe-first prompt
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m1s
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules
MCP tool so a session-start eager pull can fetch standing rules without
needing an active-project notion. Updates _INSTRUCTIONS so Claude calls
the new tool at session start and codifies engineering rules in Scribe
rather than CLAUDE.md / auto-memory.

Seeds FabledSword family rulebook to always_on=true on migrate, matching
its design role as the cross-project standards rulebook.

Frontend: badge in RulebookListPane for always-on rulebooks; toggle in
RulebookDetailPane header bound to a new toggleAlwaysOn store action.

This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3
(project-scoped rules) and S4 (enter_project handshake) follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:56:08 -04:00
bvandeusen c810d63bee Merge pull request 'MCP plan-prompt tune + Flutter docs removal' (#52) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 24s
2026-06-01 00:13:31 -04:00
bvandeusen fd20b67b22 docs: remove Flutter companion app from project surface
CI & Build / Python lint (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 3m43s
The Flutter app (separate fabled_app repo) no longer adds value over
web/PWA access. Strip the in-repo surface that referenced it:

- delete docs/android-app.md
- drop README docs-table row and feature-list mention
- drop the two Flutter roadmap bullets from docs/features.md
- remove the Flutter port subsection from docs/design-system.md

The standalone fabled_app repo is untouched here; archival/deletion
of that repo is a separate decision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:37:35 -04:00
bvandeusen 7031e36670 chore(mcp): tighten plan-instruction to override .md plan habit
The prior wording ("not in local .md files") was a footer after a
how-to and lost to the much louder superpowers brainstorming /
writing-plans skill flow, which terminates by saving to
docs/superpowers/specs/*.md and docs/superpowers/plans/*.md.

Reorder so start_planning is named as the FIRST action, explicitly
override the .md skill paths, and extend the rule to cover specs as
well as plans (matches the rulebook's rule 27).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:31:46 -04:00
78 changed files with 3455 additions and 1084 deletions
+28 -21
View File
@@ -1,12 +1,19 @@
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# 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
# dev has already passed CI, and the release tag is the sole trigger
# for a production image. Re-running CI on the merge commit just burns
# runner time without changing the outcome.
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
# pushes publish only the immutable :<sha> image — no :main tag, because
# :latest (release-only) is the single production pointer and a :main alias
# 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:
# 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.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main
# still trigger the workflow, producing redundant runs on the same SHA
# 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).
# honor `on.push.branches` as a filter, so every job repeats the ref check
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
@@ -29,7 +33,7 @@ name: CI & Build
on:
push:
branches: [dev]
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
@@ -67,8 +71,8 @@ env:
jobs:
typecheck:
name: TypeScript typecheck
# Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
# Gate dev, main, and v* tags; reject any other ref (see header note).
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -92,7 +96,7 @@ jobs:
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
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -106,7 +110,7 @@ jobs:
test:
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
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -138,11 +142,9 @@ jobs:
build:
name: Build & push image
needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only.
# Mirrors the ref guard on the gate jobs above — main merge-commit
# pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -168,6 +170,11 @@ jobs:
refs/heads/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/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
+1 -2
View File
@@ -4,7 +4,7 @@ A self-hosted second brain and project management application with integrated LL
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app.
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
## Quick Start
@@ -36,7 +36,6 @@ Open `http://localhost:5000`. The first user to register becomes admin. Go to **
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
| [API Reference](docs/api-reference.md) | All REST API endpoints |
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
## License
@@ -0,0 +1,39 @@
"""rulebook always_on flag
Revision ID: 0058
Revises: 0057
Create Date: 2026-06-01
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
flagged always_on are loaded at session start by the new
`list_always_on_rules` MCP tool — they apply regardless of which project
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
because that's the cross-project standards rulebook by design.
"""
from alembic import op
import sqlalchemy as sa
revision = "0058"
down_revision = "0057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rulebooks",
sa.Column(
"always_on",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.execute(
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
)
def downgrade() -> None:
op.drop_column("rulebooks", "always_on")
@@ -0,0 +1,49 @@
"""project-scoped rules
Revision ID: 0059
Revises: 0058
Create Date: 2026-06-01
Rules can now belong to either a rulebook topic (cross-project standard) or
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
unique constraint on (topic_id, title) still applies because PostgreSQL
treats NULL as distinct — two project-scoped rules with the same title and
NULL topic_id remain unique.
"""
from alembic import op
import sqlalchemy as sa
revision = "0059"
down_revision = "0058"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rules",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=True,
),
)
op.alter_column("rules", "topic_id", nullable=True)
op.create_index("ix_rules_project_id", "rules", ["project_id"])
op.create_check_constraint(
"ck_rule_topic_xor_project",
"rules",
"(topic_id IS NULL) <> (project_id IS NULL)",
)
def downgrade() -> None:
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
op.drop_index("ix_rules_project_id", table_name="rules")
# Any rule with NULL topic_id will block re-tightening. Operator must
# migrate or delete project-scoped rules before downgrading.
op.alter_column("rules", "topic_id", nullable=False)
op.drop_column("rules", "project_id")
@@ -0,0 +1,73 @@
"""project rule + topic suppressions
Revision ID: 0060
Revises: 0059
Create Date: 2026-06-01
Lets a project mute specific rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
association tables; FKs CASCADE so removing a project / rule / topic
cleans the suppression rows automatically.
"""
from alembic import op
import sqlalchemy as sa
revision = "0060"
down_revision = "0059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_rule_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"rule_id",
sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_table(
"project_topic_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"topic_id",
sa.BigInteger(),
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("project_topic_suppressions")
op.drop_table("project_rule_suppressions")
@@ -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}"
-73
View File
@@ -1,73 +0,0 @@
# Android Companion App
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
## Stack
- Flutter + Dart
- Riverpod (state management)
- GoRouter (navigation)
- Dio (HTTP client)
- PersistCookieJar (session persistence)
- SSE streaming via `fetch` + `ReadableStream` bridge
## Architecture
```
lib/
app.dart # GoRouter + _Shell + _QuickCaptureBar
core/constants.dart # Routes.*
data/
models/ # note.dart, task.dart, project.dart
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
repositories/ # notes, tasks, projects repositories
providers/
api_client_provider.dart # all API + repository providers
notes_provider.dart # NotesNotifier
tasks_provider.dart # TasksNotifier
projects_provider.dart # ProjectsNotifier
screens/
notes/note_edit_screen.dart # chip tag input + ProjectSelector
tasks/task_edit_screen.dart # ProjectSelector
projects/project_list_screen.dart
widgets/
project_selector.dart # reusable DropdownButtonFormField
```
## Navigation
4-tab shell (Notes · Tasks · Projects · Chat):
- Phone: bottom `NavigationBar`
- Tablet/landscape: `NavigationRail`
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
## Feature Status
| Feature | Status | Notes |
|---------|--------|-------|
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
| Tasks CRUD | ✅ | Project selector in editor |
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
| Chat + SSE | ✅ | Full streaming |
| Quick Capture | ✅ | Offline queue with retry |
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
## API Compatibility Notes
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
- `POST /api/projects` returns the project dict directly (201).
- `PATCH /api/projects/:id` returns the updated project dict.
- Task body field is `body` (not `description`) — the app maps `description``body` on serialize.
## Self-Update
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
## CI
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
-11
View File
@@ -568,17 +568,6 @@ Items deliberately not addressed in this round; revisit when a real need surface
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
### Flutter app port — shipped 2026-04-28
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
### Open threads
*New threads will accumulate here as gaps surface in real use.*
-2
View File
@@ -132,8 +132,6 @@ Settings are tabbed:
- Email integration (read/send via IMAP/SMTP tools in chat)
- Session invalidation on user deletion
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
- Flutter milestone support in project view
## Keyboard Shortcuts
+51 -3
View File
@@ -5,6 +5,7 @@ export interface Rulebook {
owner_user_id: number;
title: string;
description: string;
always_on: boolean;
created_at: string | null;
updated_at: string | null;
}
@@ -21,7 +22,8 @@ export interface RulebookTopic {
export interface Rule {
id: number;
topic_id: number;
topic_id: number | null;
project_id: number | null;
title: string;
statement: string;
why: string;
@@ -35,7 +37,7 @@ export interface RuleHeader {
id: number;
title: string;
statement: string;
topic_id: number;
topic_id: number | null;
}
export interface ApplicableRules {
@@ -43,7 +45,28 @@ export interface ApplicableRules {
id: number;
title: string;
statement: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
project_rules: {
id: number;
title: string;
statement: string;
}[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
truncated: boolean;
@@ -65,7 +88,7 @@ export async function createRulebook(data: { title: string; description?: string
return apiPost("/api/rulebooks", data);
}
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data);
}
@@ -133,3 +156,28 @@ export async function unsubscribeProject(projectId: number, rulebookId: number):
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`);
}
export async function createProjectRule(
projectId: number,
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
+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>
+246 -19
View File
@@ -3,7 +3,9 @@ import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule,
listRulebooks, getRule, createProjectRule, deleteRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -16,6 +18,9 @@ const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
async function load() {
applicable.value = await getProjectApplicableRules(props.projectId);
}
@@ -59,18 +64,74 @@ function openInRulesView(rulebookId: number, ruleId?: number) {
router.push({ path: "/rules", query });
}
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
interface TopicGroup {
topic_id: number;
topic_title: string;
rules: ApplicableRules["rules"];
}
interface RulebookGroup {
rulebook_id: number;
rulebook_title: string;
topics: TopicGroup[];
}
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
const byRulebook = new Map<number, RulebookGroup>();
for (const r of rules) {
if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
grouped[r.rulebook_title][r.topic_title].push(r);
let rb = byRulebook.get(r.rulebook_id);
if (!rb) {
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
byRulebook.set(r.rulebook_id, rb);
}
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
if (!topic) {
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
rb.topics.push(topic);
}
topic.rules.push(r);
}
return grouped;
return Array.from(byRulebook.values());
}
function rulebookIdForTitle(title: string): number | undefined {
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
async function submitProjectRule() {
const statement = newProjectRule.value.statement.trim();
if (!statement) return;
await createProjectRule(props.projectId, {
statement,
title: newProjectRule.value.title.trim() || undefined,
why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
});
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
showProjectRuleForm.value = false;
await load();
}
async function removeProjectRule(ruleId: number) {
if (!confirm("Delete this project rule? It will move to the trash.")) return;
await deleteRule(ruleId);
await load();
}
const showSuppressed = ref(false);
async function suppressRule(ruleId: number) {
await suppressRuleForProject(props.projectId, ruleId);
await load();
}
async function unsuppressRule(ruleId: number) {
await unsuppressRuleForProject(props.projectId, ruleId);
await load();
}
async function suppressTopic(topicId: number) {
await suppressTopicForProject(props.projectId, topicId);
await load();
}
async function unsuppressTopic(topicId: number) {
await unsuppressTopicForProject(props.projectId, topicId);
await load();
}
onMounted(async () => {
@@ -111,6 +172,69 @@ watch(() => props.projectId, load);
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
<button
v-if="!showProjectRuleForm"
class="add"
@click="showProjectRuleForm = true"
>
+ New project rule
</button>
</div>
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
<input
v-model="newProjectRule.title"
placeholder="Title (optional — derived from statement if blank)"
/>
<textarea
v-model="newProjectRule.statement"
required
autofocus
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.how_to_apply"
placeholder="How to apply (optional) — when / where it kicks in"
rows="2"
></textarea>
<div class="form-buttons">
<button type="submit">Create</button>
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
</div>
</form>
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
<div class="rule-head" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
</div>
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div>
</li>
</ul>
<p
v-else-if="!showProjectRuleForm"
class="empty"
>
No project-only rules yet.
</p>
</section>
<section class="applicable">
<h3>Applicable rules</h3>
<p v-if="applicable.rules.length === 0" class="empty">
@@ -118,18 +242,32 @@ watch(() => props.projectId, load);
<a @click="router.push('/rules')">Rulebooks</a>.
</p>
<div
v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
:key="rbTitle"
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
:key="rb.rulebook_id"
class="rb-group"
>
<h4>{{ rbTitle }}</h4>
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
<h5>{{ topicTitle }}</h5>
<h4>{{ rb.rulebook_title }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
<h5>
<span>{{ topic.topic_title }}</span>
<button
class="skip-btn"
:title="`Skip the entire ${topic.topic_title} topic for this project`"
@click="suppressTopic(topic.topic_id)"
>× skip topic</button>
</h5>
<ul>
<li v-for="r in rules" :key="r.id" class="rule">
<div class="rule-head" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
<li v-for="r in topic.rules" :key="r.id" class="rule">
<div class="rule-head">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<button
class="skip-btn"
title="Skip this rule for this project"
@click.stop="suppressRule(r.id)"
>× skip</button>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
@@ -140,7 +278,7 @@ watch(() => props.projectId, load);
</div>
<button
class="edit-link"
@click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
@click="openInRulesView(r.rulebook_id, r.id)"
>
Edit in Rulebook
</button>
@@ -153,6 +291,32 @@ watch(() => props.projectId, load);
Truncated at 50 rules there are more applicable rules.
</p>
</section>
<section
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
class="suppressed"
>
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
</button>
<div v-if="showSuppressed" class="suppressed-body">
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
<span class="suppressed-kind">topic</span>
<span class="suppressed-path">{{ t.rulebook_title }} {{ t.title }}</span>
<button class="reenable-btn" @click="unsuppressTopic(t.id)"> re-enable</button>
</li>
</ul>
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
<span class="suppressed-kind">rule</span>
<span class="suppressed-path">{{ r.rulebook_title }} {{ r.topic_title }} {{ r.title }}</span>
<button class="reenable-btn" @click="unsuppressRule(r.id)"> re-enable</button>
</li>
</ul>
</div>
</section>
</div>
</template>
@@ -208,4 +372,67 @@ ul { list-style: none; padding: 0; margin: 0; }
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; }
.project-rules { margin-top: 1.5rem; }
.section-head { display: flex; justify-content: space-between; align-items: center; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--color-surface, #18181b); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.rule-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--color-muted, #888); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
display: flex; align-items: center; gap: 0.4rem;
background: none; border: none; cursor: pointer;
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
}
.suppressed-toggle:hover { opacity: 1; }
.suppressed-toggle .caret { font-size: 0.7em; }
.suppressed-body { margin-top: 0.5rem; }
.suppressed-list { padding-left: 0; }
.suppressed-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.25rem 0; opacity: 0.75;
}
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, computed, onMounted, watch } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
@@ -18,6 +18,10 @@ const store = useRulebooksStore();
const isCreating = ref(false);
const newTitle = ref("");
const currentRulebook = computed(() =>
store.rulebooks.find((rb) => rb.id === props.rulebookId),
);
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>>
@@ -68,7 +72,17 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<template>
<section class="pane">
<header><h2>Topics</h2></header>
<header>
<h2>Topics</h2>
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
<input
type="checkbox"
:checked="currentRulebook.always_on"
@change="store.toggleAlwaysOn(currentRulebook.id)"
/>
<span>Always on</span>
</label>
</header>
<ul>
<li
v-for="t in topics"
@@ -109,7 +123,14 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
user-select: none;
}
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
@@ -31,6 +31,7 @@ async function submitNew() {
@click="emit('select', rb.id)"
>
<span class="title">{{ rb.title }}</span>
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li>
</ul>
<div class="new-rulebook">
@@ -50,9 +51,19 @@ async function submitNew() {
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--color-accent, rgba(91,74,138,0.25));
color: var(--color-accent-fg, inherit);
margin-left: auto;
}
.new-rulebook { margin-top: 1rem; }
.new-rulebook input {
width: 100%; margin-bottom: 0.5rem;
+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,
+8 -2
View File
@@ -54,13 +54,19 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return rb;
}
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
const rb = await api.updateRulebook(id, data);
const idx = rulebooks.value.findIndex((r) => r.id === id);
if (idx >= 0) rulebooks.value[idx] = rb;
return rb;
}
async function toggleAlwaysOn(id: number) {
const current = rulebooks.value.find((r) => r.id === id);
if (!current) return;
return updateRulebook(id, { always_on: !current.always_on });
}
async function deleteRulebook(id: number) {
await api.deleteRulebook(id);
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
@@ -121,7 +127,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule,
};
+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")
+161 -17
View File
@@ -36,32 +36,155 @@ Mechanics:
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates.
- 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
an actionable statement plus optional Why and How-to-apply context. When you
start work on a project, get_project(id) returns applicable_rules (the rules
from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
these before making decisions about workflow, conventions, or scope. Full
text (Why / How-to-apply) is available via get_rule(id). You may create new
rules via create_rule when you notice a pattern worth codifying — coordinate
with the operator on whether it belongs in an existing rulebook+topic or a
new one.
an actionable statement plus optional Why and How-to-apply context. At the
start of any session that touches Scribe, call list_always_on_rules() to
load the standing rules — treat them as binding. When you also have a project
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
project subscribes to) and subscribed_rulebooks; consult those too. Full text
(Why / How-to-apply) is available via get_rule(id).
Plans are tasks with kind=plan. Begin a plan with start_planning(project_id,
title) — that seeds a plan template and returns the project's applicable_rules.
Maintain the plan with the normal task tools (update_task for the body,
add_task_log for progress); its work-logs are the build record. Build plans in
Scribe via start_planning, not in local .md files.
Engineering and workflow rules live in Scribe. When you notice a pattern
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
— those stores are reserved for facts about the user (preferences, role,
communication style) and codebase onboarding pointers, respectively. Before
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
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)
ONCE at session start (or whenever the active project changes). It returns the
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
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
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.
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
before any brainstorming, design, or plan-writing skill runs. start_planning
seeds the plan body, returns the project's applicable_rules, and gives you the
task id you'll write into. If a skill or habit tells you to save a plan or spec
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
superseded here: put the spec/plan content in the kind=plan task's body via
update_task, and record progress with add_task_log. Local .md files are not
the record — the task is.
Deletes are recoverable: every delete_* tool moves the entity (and its
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.
@@ -109,7 +232,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()
@@ -132,8 +255,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,
@@ -147,6 +270,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)
+75 -3
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import milestones as milestones_svc
from fabledassistant.services import notes as notes_svc
from fabledassistant.services import projects as projects_svc
from fabledassistant.services import rulebooks as rulebooks_svc
from fabledassistant.services import trash as trash_svc
@@ -26,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()
@@ -34,6 +35,73 @@ async def list_projects() -> dict:
return {"projects": [p.to_dict() for p in rows]}
async def enter_project(project_id: int) -> dict:
"""Session-start handshake: load full context for working on a project.
Call this FIRST whenever you're about to do project-scoped work
(start_planning, create_task, update_*, anything that takes a project_id).
One round-trip returns the project, its applicable rules (both rulebook-
subscribed and project-scoped), milestone progress, open tasks, and
recently-updated notes — everything you need to know the lay of the land
before mutating.
No persistent server state: this is a read snapshot. Re-call if the
session goes idle long enough that the data feels stale.
Args:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
milestone_summary = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
open_tasks, _ = await notes_svc.list_notes(
uid, is_task=True, project_id=project_id,
status=["todo", "in_progress"], sort="updated_at", limit=10,
)
recent_notes, _ = await notes_svc.list_notes(
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
)
return {
"project": project.to_dict(),
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
"priority": t.priority, "task_kind": t.task_kind,
"milestone_id": t.milestone_id,
}
for t in open_tasks
],
"recent_notes": [
{
"id": n.id, "title": n.title,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in recent_notes
],
}
async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID.
@@ -55,6 +123,9 @@ async def get_project(project_id: int) -> dict:
data["applicable_rules"] = applicable["rules"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data
@@ -71,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()
@@ -101,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()
@@ -136,6 +207,7 @@ async def delete_project(project_id: int) -> dict:
def register(mcp) -> None:
for fn in (
list_projects,
enter_project,
get_project,
create_project,
update_project,
+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:
+131 -6
View File
@@ -54,14 +54,26 @@ async def create_rulebook(title: str, description: str = "") -> dict:
async def update_rulebook(
rulebook_id: int, title: str = "", description: str = "",
always_on: bool | None = None,
) -> dict:
"""Update an existing rulebook. Only non-empty fields are changed."""
"""Update an existing rulebook. Only non-empty fields are changed.
Args:
rulebook_id: Rulebook to update.
title: New title. Empty string leaves unchanged.
description: New description. Empty string leaves unchanged.
always_on: When True, rules in this rulebook are loaded at session
start by list_always_on_rules regardless of project context.
Pass None to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if always_on is not None:
fields["always_on"] = always_on
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found")
@@ -200,6 +212,28 @@ async def list_rules(
}
async def list_always_on_rules() -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session — they apply regardless of which project (if any) is in scope.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
async def get_rule(rule_id: int) -> dict:
"""Fetch a rule by id — full statement + why + how_to_apply."""
uid = current_user_id()
@@ -213,7 +247,7 @@ async def create_rule(
topic_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a new rule under a topic.
"""Create a new rule under a topic (cross-project rulebook rule).
Args:
topic_id: The topic to attach the rule to.
@@ -222,6 +256,9 @@ async def create_rule(
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the topic (default 0).
For a rule that applies to a single project only, use create_project_rule
instead — no rulebook+topic ceremony required.
"""
uid = current_user_id()
rule = await rulebooks_svc.create_rule(
@@ -232,6 +269,35 @@ async def create_rule(
return rule.to_dict()
async def create_project_rule(
project_id: int, statement: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
Use this when a rule only applies to one project — it bypasses the
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
applicable_rules (under project_rules) and in list_rules(project_id=...).
Args:
project_id: The project to attach the rule to.
statement: The actionable instruction (required). 1-2 sentences.
title: Short imperative title. If empty, derived from the first ~50
characters of statement.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the project's rule list (default 0).
"""
uid = current_user_id()
derived_title = title.strip() or statement.strip().split(".")[0][:50]
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return rule.to_dict()
async def update_rule(
rule_id: int, title: str = "", statement: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1,
@@ -256,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:
@@ -264,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,
}
@@ -298,11 +364,70 @@ async def unsubscribe_project_from_rulebook(
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible — delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, get_rule, create_rule, update_rule, delete_rule,
list_rules, list_always_on_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
):
mcp.tool(name=fn.__name__)(fn)
+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)
+19 -6
View File
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- 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
@@ -82,6 +82,9 @@ async def get_task(task_id: int) -> dict:
data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data
@@ -140,10 +143,14 @@ async def update_task(
task_id: ID of the task to update.
title: New title, 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.
project_id: New project. Omit (0) to leave unchanged.
milestone_id: New milestone. Omit (0) to leave unchanged.
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
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()
fields: dict = {}
@@ -155,9 +162,15 @@ async def update_task(
fields["status"] = status
if 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
if milestone_id:
if milestone_id == -1:
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
+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"
+49 -5
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import BigInteger, 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
@@ -16,6 +16,9 @@ class Rulebook(Base, SoftDeleteMixin):
)
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -31,6 +34,7 @@ class Rulebook(Base, SoftDeleteMixin):
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -38,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)
@@ -72,13 +81,28 @@ 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)
topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
# Exactly one of topic_id / project_id is set — enforced by CHECK
# constraint ck_rule_topic_xor_project (migration 0059).
topic_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
nullable=True,
)
project_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
title: Mapped[str] = mapped_column(Text)
statement: Mapped[str] = mapped_column(Text)
@@ -98,6 +122,7 @@ class Rule(Base, SoftDeleteMixin):
return {
"id": self.id,
"topic_id": self.topic_id,
"project_id": self.project_id,
"title": self.title,
"statement": self.statement,
"why": self.why or "",
@@ -116,3 +141,22 @@ project_rulebook_subscriptions = Table(
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# Suppressions — let a project mute individual rules or whole topics from
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
# FKs CASCADE so the row vanishes when its parent is removed.
project_rule_suppressions = Table(
"project_rule_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
+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,
+77 -3
View File
@@ -55,7 +55,7 @@ async def get_rulebook(rulebook_id: int):
@login_required
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description")}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
@@ -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
@@ -239,3 +241,75 @@ async def get_project_rules(project_id: int):
project_id=project_id, user_id=_uid(),
)
return jsonify(result)
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
"""Create a rule scoped to a single project. Frontend fast path."""
data = await request.get_json() or {}
statement = (data.get("statement") or "").strip()
if not statement:
return jsonify({"error": "statement is required"}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
try:
rule = await rulebooks_svc.create_project_rule(
project_id=project_id,
user_id=_uid(),
title=title,
statement=statement,
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
-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))
+3
View File
@@ -58,6 +58,9 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
+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),
)
)
)
+333 -32
View File
@@ -72,7 +72,7 @@ async def update_rulebook(
rb = result.scalar_one_or_none()
if rb is None:
return None
allowed = {"title", "description"}
allowed = {"title", "description", "always_on"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rb, key, value)
@@ -242,6 +242,44 @@ async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
raise ValueError(f"topic {topic_id} not found")
async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
"""Raise ValueError if project doesn't exist or isn't owned by user."""
from fabledassistant.models.project import Project
result = await session.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"project {project_id} not found")
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
"""Raise ValueError if rule isn't a rulebook rule the user owns.
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
suppressible — they belong to the project; delete them instead. This
helper deliberately excludes them.
"""
from fabledassistant.models.rulebook import Rule
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
async def create_rule(
topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
@@ -262,6 +300,32 @@ async def create_rule(
return rule
async def create_project_rule(
project_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't
propagate via rulebook subscriptions. Topic_id is left NULL — the
CHECK constraint enforces exactly-one of (topic_id, project_id).
"""
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
rule = Rule(
project_id=project_id,
title=title,
statement=statement,
why=why or None,
how_to_apply=how_to_apply or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
return rule
async def list_rules(
user_id: int,
rulebook_id: int | None = None,
@@ -270,7 +334,12 @@ async def list_rules(
) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
project_id resolves rules through project_rulebook_subscriptions.
When project_id is set, the result includes both rulebook rules reached via
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
When rulebook_id or topic_id is set, project-scoped rules are excluded by
construction (they have neither). With no filter, only rulebook rules are
returned — adding all of a user's project-scoped rules unprompted would
surprise existing callers.
"""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
@@ -302,38 +371,101 @@ async def list_rules(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
result = await session.execute(stmt)
return list(result.scalars().all())
rulebook_rules = list(result.scalars().all())
if not project_id:
return rulebook_rules
# Project-scoped rules (topic_id IS NULL, project_id matches).
# Verifies ownership by joining Project on user_id.
from fabledassistant.models.project import Project
proj_stmt = (
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_result = await session.execute(proj_stmt)
return rulebook_rules + list(proj_result.scalars().all())
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
"""Return all rules from rulebooks flagged always_on for the user.
Called by the MCP tool of the same name at session start to load the
standing rules that apply regardless of which project (if any) is in
scope. Ordering matches list_rules so results are stable across calls.
"""
async with async_session() as session:
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rulebook.always_on.is_(True),
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit)
)
return result.scalar_one_or_none()
return list(result.scalars().all())
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
"""Fetch a rule by id, scoped to user owning either its rulebook
(via topic) or its project (via project_id). Honors soft-delete.
Returns None when not found or not owned.
"""
from fabledassistant.models.project import Project
# Path A — rulebook rule.
rulebook_rule = (await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)).scalar_one_or_none()
if rulebook_rule is not None:
return rulebook_rule
# Path B — project-scoped rule.
project_rule = (await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Rule.id == rule_id,
Project.user_id == user_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)).scalar_one_or_none()
return project_rule
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
async with async_session() as session:
return await _fetch_owned_rule(session, rule_id, user_id)
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async with async_session() as session:
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
)
)
rule = result.scalar_one_or_none()
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
@@ -347,16 +479,7 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async def delete_rule(rule_id: int, user_id: int) -> None:
async with async_session() as session:
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
)
)
rule = result.scalar_one_or_none()
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return
await session.delete(rule)
@@ -375,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:
@@ -394,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(
@@ -404,19 +529,115 @@ async def unsubscribe_project(
await session.commit()
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Mute one rulebook rule for one project. Idempotent."""
from fabledassistant.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_rule_owned(session, rule_id, user_id)
try:
await session.execute(
insert(project_rule_suppressions).values(
project_id=project_id, rule_id=rule_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already suppressed; fine.
async def unsuppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Unmute one rulebook rule for one project. Idempotent."""
from fabledassistant.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rule_suppressions).where(
project_rule_suppressions.c.project_id == project_id,
project_rule_suppressions.c.rule_id == rule_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Mute every rule under one topic for one project. Idempotent."""
from fabledassistant.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_topic_owned(session, topic_id, user_id)
try:
await session.execute(
insert(project_topic_suppressions).values(
project_id=project_id, topic_id=topic_id,
)
)
await session.commit()
except Exception:
await session.rollback()
async def unsuppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Unmute a topic for one project. Idempotent."""
from fabledassistant.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_topic_suppressions).where(
project_topic_suppressions.c.project_id == project_id,
project_topic_suppressions.c.topic_id == topic_id,
)
)
await session.commit()
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project via its subscriptions.
"""Return rules applicable to a project — both via rulebook subscriptions
and project-scoped rules (Rule.project_id matches), with suppressed rules
and suppressed topics filtered out.
Shape:
{
"rules": [{id, title, statement, topic_title, rulebook_title}, ...],
"rules": [{id, title, statement,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"project_rules": [{id, title, statement}, ...],
"suppressed_rules": [{id, title,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"suppressed_topics": [{id, title,
rulebook_id, rulebook_title}, ...],
"truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
}
`rules` is the subscription-derived set with project-level suppressions
applied. `project_rules` is the project-scoped set (never suppressed —
delete instead). `suppressed_rules` / `suppressed_topics` carry the
titles + rulebook context callers need to display what was filtered
without round-tripping for names.
"""
from fabledassistant.models.rulebook import project_rulebook_subscriptions
from fabledassistant.models.rulebook import (
project_rulebook_subscriptions,
project_rule_suppressions,
project_topic_suppressions,
)
async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
@@ -438,11 +659,64 @@ async def get_applicable_rules(
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Applicable rules (limit + 1 so we can detect truncation).
# Suppressed rules — joined to topic + rulebook so callers can render
# context without a follow-up lookup. Ownership-scoped via rulebook.
suppressed_rules_q = (
select(
Rule.id, Rule.title,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_rule_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
)
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
suppressed_rules = [
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt}
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
]
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
# Suppressed topics — joined to rulebook for context.
suppressed_topics_q = (
select(
RulebookTopic.id, RulebookTopic.title,
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_topic_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title)
)
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
suppressed_topics = [
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
for tid, tt, rbi, rbt in suppressed_topic_rows
]
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
# Applicable rules (limit + 1 so we can detect truncation). Filter
# in SQL so truncation reflects the post-suppression count, not the
# raw subscription count.
rules_q = (
select(
Rule.id, Rule.title, Rule.statement,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
@@ -463,18 +737,45 @@ async def get_applicable_rules(
)
.limit(limit + 1)
)
if suppressed_rule_ids:
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
if suppressed_topic_ids:
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
rule_rows = (await session.execute(rules_q)).all()
truncated = len(rule_rows) > limit
rules = [
{
"id": rid, "title": rtitle, "statement": stmt,
"topic_title": tt, "rulebook_title": rbt,
"topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt,
}
for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
]
# Project-scoped rules — verifies ownership via Project.user_id.
from fabledassistant.models.project import Project
proj_rules_q = (
select(Rule.id, Rule.title, Rule.statement)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [
{"id": rid, "title": rtitle, "statement": stmt}
for rid, rtitle, stmt in proj_rule_rows
]
return {
"rules": rules,
"project_rules": project_rules,
"suppressed_rules": suppressed_rules,
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}
+110 -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
@@ -53,13 +82,45 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
if etype == "project":
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
# Project-scoped rules cascade with the project they're attached to.
await _set(session, Rule, [Rule.project_id == eid], batch, now)
# Suppressions are pure associations (no deleted_at) — hard-delete
# them here so restoring the project doesn't bring stale mutes back.
# FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged.
from sqlalchemy import delete as _sql_delete
from fabledassistant.models.rulebook import (
project_rule_suppressions, project_topic_suppressions,
)
await session.execute(
_sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid)
)
await session.execute(
_sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid)
)
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
elif etype == "milestone":
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":
@@ -88,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
@@ -116,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
@@ -131,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()
@@ -144,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(
@@ -166,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
@@ -181,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",
}
+73 -1
View File
@@ -6,7 +6,7 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.projects import (
list_projects, get_project, create_project,
update_project,
update_project, enter_project,
)
@@ -130,3 +130,75 @@ async def test_update_project_raises_when_not_found():
):
with pytest.raises(ValueError, match="project 999 not found"):
await update_project(project_id=999, title="x")
@pytest.mark.asyncio
async def test_enter_project_composes_full_context():
"""enter_project pulls project + rules + milestone summary + open tasks +
recent notes in one composed call."""
p = _fake_project(id=5, title="P")
applicable_payload = {
"rules": [{"id": 1, "title": "r1", "statement": "s",
"topic_title": "t", "rulebook_title": "rb"}],
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
}
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
task1 = MagicMock()
task1.id = 100; task1.title = "T1"; task1.status = "in_progress"
task1.priority = "high"; task1.task_kind = "work"; task1.milestone_id = 10
note1 = MagicMock()
note1.id = 200; note1.title = "N1"
note1.updated_at = None # avoids datetime mocking
with patch(
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable_payload),
), patch(
"fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary),
), patch(
"fabledassistant.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([task1], 1), ([note1], 1)]),
):
out = await enter_project(project_id=5)
assert out["project"]["id"] == 5
assert out["milestone_summary"] == milestone_summary
assert out["applicable_rules"][0]["title"] == "r1"
assert out["project_rules"][0]["id"] == 99
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
assert out["open_tasks"][0]["id"] == 100
assert out["open_tasks"][0]["status"] == "in_progress"
assert out["recent_notes"][0]["id"] == 200
@pytest.mark.asyncio
async def test_enter_project_raises_when_project_not_found():
with patch(
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="project 999 not found"):
await enter_project(project_id=999)
def test_enter_project_registered_in_register():
"""register(mcp) registers enter_project alongside the existing tools."""
from fabledassistant.mcp.tools.projects import register
registered: list[str] = []
class FakeMCP:
def tool(self, name=None):
def decorator(fn):
registered.append(name)
return fn
return decorator
register(FakeMCP())
assert "enter_project" in registered
+149 -1
View File
@@ -179,8 +179,156 @@ def test_register_attaches_all_sixteen_tools():
return decorator
register(FakeMCP())
assert len(registered) == 16
assert len(registered) == 22
# spot-check a few names
assert "list_rulebooks" in registered
assert "create_rule" in registered
assert "subscribe_project_to_rulebook" in registered
assert "list_always_on_rules" in registered
assert "create_project_rule" in registered
assert "suppress_rule_for_project" in registered
assert "unsuppress_rule_for_project" in registered
assert "suppress_topic_for_project" in registered
assert "unsuppress_topic_for_project" in registered
@pytest.mark.asyncio
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
with patch(
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[]),
):
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
out = await list_always_on_rules()
assert out == {"rules": [], "total": 0}
@pytest.mark.asyncio
async def test_list_always_on_rules_projects_each_rule():
rules = [_fake_rule(id=100), _fake_rule(id=101)]
with patch(
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules),
):
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
out = await list_always_on_rules()
assert out["total"] == 2
assert {r["id"] for r in out["rules"]} == {100, 101}
assert all("topic_id" in r for r in out["rules"])
@pytest.mark.asyncio
async def test_update_rulebook_forwards_always_on_when_set():
rb = _fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from fabledassistant.mcp.tools.rulebooks import update_rulebook
await update_rulebook(rulebook_id=1, always_on=True)
kwargs = mock.call_args.kwargs
assert kwargs.get("always_on") is True
assert "title" not in kwargs
assert "description" not in kwargs
@pytest.mark.asyncio
async def test_update_rulebook_omits_always_on_when_none():
rb = _fake_rulebook(id=1, title="t")
mock = AsyncMock(return_value=rb)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
from fabledassistant.mcp.tools.rulebooks import update_rulebook
await update_rulebook(rulebook_id=1, title="new title")
kwargs = mock.call_args.kwargs
assert "always_on" not in kwargs
assert kwargs["title"] == "new title"
@pytest.mark.asyncio
async def test_create_project_rule_passes_required_fields():
rule = _fake_rule()
mock = AsyncMock(return_value=rule)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from fabledassistant.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Always run migrations through alembic, not raw SQL.",
why="audit trail",
)
kwargs = mock.call_args.kwargs
assert kwargs["user_id"] == 7
assert kwargs["project_id"] == 42
assert kwargs["statement"].startswith("Always run migrations")
assert kwargs["why"] == "audit trail"
@pytest.mark.asyncio
async def test_create_project_rule_derives_title_from_statement():
rule = _fake_rule()
mock = AsyncMock(return_value=rule)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from fabledassistant.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
)
kwargs = mock.call_args.kwargs
# Title should be derived from the first sentence, capped at 50 chars
assert kwargs["title"] == "Avoid auto-generated docstrings"
@pytest.mark.asyncio
async def test_create_project_rule_uses_explicit_title_when_given():
rule = _fake_rule()
mock = AsyncMock(return_value=rule)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
from fabledassistant.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="anything",
title="no auto-docstrings",
)
kwargs = mock.call_args.kwargs
assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from fabledassistant.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
+40
View File
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
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
async def test_add_task_log_returns_log_dict():
log = MagicMock()
+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)
+62 -1
View File
@@ -44,13 +44,74 @@ def test_service_signatures_require_user_id():
"create_rulebook", "list_rulebooks", "get_rulebook",
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
"create_rule", "list_rules", "get_rule", "update_rule", "delete_rule",
"create_rule", "create_project_rule",
"list_rules", "list_always_on_rules",
"get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
):
sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
def test_rule_model_carries_project_id_and_topic_id_nullable():
"""Migration 0059 made topic_id nullable and added project_id."""
from fabledassistant.models.rulebook import Rule
assert "project_id" in Rule.__table__.columns
assert Rule.__table__.columns["topic_id"].nullable is True
assert Rule.__table__.columns["project_id"].nullable is True
def test_create_project_rule_route_exists():
"""POST /api/projects/<id>/rules — the frontend fast-path endpoint."""
from fabledassistant.routes import rulebooks as rb_routes
assert callable(getattr(rb_routes, "create_project_rule"))
def test_suppression_route_handlers_exist():
"""The 4 suppression endpoint handlers are registered as Python callables."""
from fabledassistant.routes import rulebooks as rb_routes
for name in (
"suppress_project_rule", "unsuppress_project_rule",
"suppress_project_topic", "unsuppress_project_topic",
):
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
def test_suppression_association_tables_declared():
"""Migration 0060 created two new association tables; the models module
must declare matching Table() objects so the rest of the service layer
can reference them via .c.<column>."""
from fabledassistant.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
tbl = getattr(rb_models, tbl_name, None)
assert tbl is not None, f"models.rulebook missing {tbl_name}"
cols = {c.name for c in tbl.columns}
assert "project_id" in cols
assert "rule_id" in cols or "topic_id" in cols
def test_rulebook_model_carries_always_on():
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
from fabledassistant.models.rulebook import Rulebook
assert "always_on" in Rulebook.__table__.columns
col = Rulebook.__table__.columns["always_on"]
assert col.nullable is False
def test_update_rulebook_route_accepts_always_on():
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
The handler filters body keys against a whitelist; that whitelist needs to
include always_on or toggling from the UI silently drops the field.
"""
import inspect as _inspect
from fabledassistant.routes import rulebooks as rb_routes
src = _inspect.getsource(rb_routes.update_rulebook)
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
def test_rule_and_subscription_handlers_callable():
from fabledassistant.routes import rulebooks as rb_routes
for name in (
+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 == []
+83 -7
View File
@@ -241,18 +241,31 @@ async def test_subscribe_project_requires_owned_rulebook():
)
def _empty():
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
r = MagicMock()
r.all.return_value = []
r.scalars.return_value.all.return_value = []
return r
@pytest.mark.asyncio
async def test_get_applicable_rules_returns_shape():
"""get_applicable_rules returns {rules, truncated, subscribed_rulebooks}."""
"""get_applicable_rules returns the full projection — including the
new suppression fields and rulebook/topic IDs on each rule."""
mock_session = _make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = [(1, "FabledSword family")]
rules_result = MagicMock()
rules_result.all.return_value = [
(i, f"Rule {i}", f"Statement {i}", "git-workflow", "FabledSword family")
# (rule_id, title, statement, topic_id, topic_title, rulebook_id, rulebook_title)
(i, f"Rule {i}", f"Statement {i}", 2, "git-workflow", 1, "FabledSword family")
for i in range(50)
]
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), rules_result, _empty(),
])
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
@@ -260,11 +273,19 @@ async def test_get_applicable_rules_returns_shape():
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
assert "rules" in result
assert "project_rules" in result
assert "suppressed_rules" in result
assert "suppressed_topics" in result
assert "truncated" in result
assert "subscribed_rulebooks" in result
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
assert len(result["rules"]) == 50
assert result["truncated"] is False # exactly 50, not over
assert result["rules"][0]["topic_id"] == 2
assert result["rules"][0]["rulebook_id"] == 1
assert result["project_rules"] == []
assert result["suppressed_rules"] == []
assert result["suppressed_topics"] == []
assert result["truncated"] is False
@pytest.mark.asyncio
@@ -274,11 +295,12 @@ async def test_get_applicable_rules_truncates_when_over_limit():
sub_result = MagicMock()
sub_result.all.return_value = []
rules_result = MagicMock()
# 51 rows means truncation detected
rules_result.all.return_value = [
(i, f"r{i}", "stmt", "topic", "rb") for i in range(51)
(i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51)
]
mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), rules_result, _empty(),
])
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
@@ -287,3 +309,57 @@ async def test_get_applicable_rules_truncates_when_over_limit():
assert result["truncated"] is True
assert len(result["rules"]) == 50
@pytest.mark.asyncio
async def test_get_applicable_rules_includes_project_scoped_rules():
"""Project-scoped rules surface in the project_rules field."""
mock_session = _make_mock_session()
proj_rules_result = MagicMock()
proj_rules_result.all.return_value = [
(100, "Use alembic", "Always run migrations via alembic, never raw SQL."),
(101, "PR-bound", "Land schema changes in their own PR."),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
])
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7)
assert len(result["project_rules"]) == 2
assert result["project_rules"][0]["title"] == "Use alembic"
assert result["project_rules"][1]["id"] == 101
@pytest.mark.asyncio
async def test_get_applicable_rules_surfaces_suppressed_with_context():
"""Suppressed rules and topics come back with full title + rulebook context
so the UI can render them without an extra round-trip."""
mock_session = _make_mock_session()
suppressed_rules_result = MagicMock()
suppressed_rules_result.all.return_value = [
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title)
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"),
]
suppressed_topics_result = MagicMock()
suppressed_topics_result.all.return_value = [
# (topic_id, topic_title, rulebook_id, rulebook_title)
(22, "design-system", 1, "FabledSword family"),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), suppressed_rules_result, suppressed_topics_result, _empty(), _empty(),
])
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7)
assert len(result["suppressed_rules"]) == 1
assert result["suppressed_rules"][0]["id"] == 17
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
assert len(result["suppressed_topics"]) == 1
assert result["suppressed_topics"][0]["title"] == "design-system"
+41 -9
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
@@ -48,18 +51,24 @@ async def test_delete_returns_none_when_not_found():
@pytest.mark.asyncio
async def test_delete_project_cascades_three_tables():
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions():
session = _make_mock_session()
# exists-check + 3 cascade updates (notes, milestones, project)
# exists-check + 6 cascade ops:
# notes (soft) → milestones (soft) → project-scoped rules (soft) →
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
# project (soft)
session.execute = AsyncMock(side_effect=[
_exists_result(True), MagicMock(), MagicMock(), MagicMock(),
_exists_result(True),
MagicMock(), MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
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="project", entity_id=3)
assert isinstance(batch, str)
assert session.execute.await_count == 4
assert session.execute.await_count == 7
@pytest.mark.asyncio
@@ -106,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
@@ -118,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()