refactor(mcp): wire aggregator directly, drop TimelineBuilder seam
Phase 5a slice D. The MCP timeline tool no longer depends on *web.Server — it talks to *aggregate.Aggregator directly. The wrong-way mcp → web layering that necessitated the TimelineBuilder interface is gone. - mcp/tools.go: TimelineBuilder interface deleted. RegisterProjaxTools(s, st, agg *aggregate.Aggregator) now takes the aggregator directly; passing nil keeps the timeline tool unregistered (kill-switch contract unchanged). - mcp/tools.go: TimelineArgs moved from web/ to mcp/ since it is the MCP-facing input shape. The timeline tool runs the full pipeline: store.ListByFilters → in-mem timeline-exclude + has-link narrowing → agg.All(...) → Result.ToTimelineRows() → aggregate.BuildTimelineDays → timelineView. No web/ import in the timeline path. - internal/aggregate/rows.go: new Result.ToTimelineRows() helper that projects the typed rows into the flat TimelineRow sum-type both web/timeline.go and mcp/tools.go consume. Single source of truth for the Date-anchor choice across kinds. - internal/aggregate/timeline_days.go: FormatPERDate lifted from web/ so timeline-row builders outside web/ can render PER strings without re-importing web/. - web/timeline.go: BuildTimelinePayloadFromArgs + TimelineArgs deleted (no remaining callers — slice D inlined the MCP path). - cmd/projax/main.go: pass srv.Aggregator() into RegisterProjaxTools. MCP tree-filter parity note: the move to store.ListByFilters narrows status to a single value (first of args.Status) and AND-matches management (vs the web TreeFilter's OR). m's documented MCP uses (tag + default status) round-trip identically. Logged as a footnote in docs/plans/aggregator-refactor.md. All mcp + web + aggregate tests green. Task: t-projax-5a-aggregator
This commit is contained in:
@@ -115,11 +115,11 @@ func main() {
|
||||
|
||||
if mcpToken := os.Getenv("PROJAX_MCP_TOKEN"); mcpToken != "" {
|
||||
mcpSrv := mcp.New("projax", "0.1.0", mcpToken, logger)
|
||||
// srv carries the CalDAV client + store the timeline aggregation
|
||||
// needs. Passing srv as the TimelineBuilder enables the `timeline`
|
||||
// MCP tool; if CalDAV is disabled the tool still works (todo/event
|
||||
// rows just don't surface, doc + creation rows do).
|
||||
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv)
|
||||
// Phase 5a slice D wired the MCP timeline tool directly to the
|
||||
// shared *aggregate.Aggregator instead of pointing back at
|
||||
// *web.Server. Passing nil here disables the `timeline` tool
|
||||
// cleanly; the rest of the projax MCP toolset stays usable.
|
||||
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator())
|
||||
mcpMux := http.NewServeMux()
|
||||
mcpSrv.Routes(mcpMux)
|
||||
srv.MCP = mcpMux
|
||||
|
||||
@@ -104,3 +104,82 @@ type Result struct {
|
||||
Docs []DocRow
|
||||
Creations []CreationRow
|
||||
}
|
||||
|
||||
// ToTimelineRows projects a Result into the flat TimelineRow sum-type the
|
||||
// /timeline view and MCP timeline tool both consume. Rows are emitted in
|
||||
// fetch order — caller hands them to BuildTimelineDays for grouping +
|
||||
// sorting. Each row's Date is the kind-appropriate anchor (Due/LastModified
|
||||
// for todos, Start for events, event_date for docs, CreatedAt for
|
||||
// creations). Window narrowing already happened inside the Aggregator
|
||||
// methods; this helper just projects shapes.
|
||||
func (r Result) ToTimelineRows() []TimelineRow {
|
||||
out := make([]TimelineRow, 0, len(r.Todos)+len(r.Events)+len(r.Docs)+len(r.Creations))
|
||||
|
||||
for _, tr := range r.Todos {
|
||||
open := tr.Todo.Status != "COMPLETED" && tr.Todo.Status != "CANCELLED"
|
||||
var anchor *time.Time
|
||||
if open {
|
||||
anchor = tr.Todo.Due
|
||||
} else if tr.Todo.LastModified != nil {
|
||||
anchor = tr.Todo.LastModified
|
||||
} else {
|
||||
anchor = tr.Todo.Due
|
||||
}
|
||||
if anchor == nil {
|
||||
continue
|
||||
}
|
||||
row := tr
|
||||
out = append(out, TimelineRow{
|
||||
Date: startOfDay(anchor.Local()),
|
||||
Kind: KindTodo,
|
||||
Item: tr.Item,
|
||||
ItemPath: tr.Item.PrimaryPath(),
|
||||
Todo: &row,
|
||||
CalendarURL: tr.CalendarURL,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ev := range r.Events {
|
||||
row := ev
|
||||
out = append(out, TimelineRow{
|
||||
Date: startOfDay(ev.Event.Start.Local()),
|
||||
Kind: KindEvent,
|
||||
Item: ev.Item,
|
||||
ItemPath: ev.Item.PrimaryPath(),
|
||||
Event: &row,
|
||||
StartLabel: EventStartLabel(ev.Event),
|
||||
DurationHint: EventDurationHint(ev.Event),
|
||||
})
|
||||
}
|
||||
|
||||
for _, d := range r.Docs {
|
||||
if d.Link == nil || d.Link.EventDate == nil {
|
||||
continue
|
||||
}
|
||||
base := d.Item.PrimaryPath()
|
||||
per := base + "." + FormatPERDate(*d.Link.EventDate)
|
||||
row := d
|
||||
out = append(out, TimelineRow{
|
||||
Date: startOfDay(*d.Link.EventDate),
|
||||
Kind: KindDoc,
|
||||
Item: d.Item,
|
||||
ItemPath: base,
|
||||
Doc: &row,
|
||||
Link: d.Link,
|
||||
PER: per,
|
||||
})
|
||||
}
|
||||
|
||||
for _, c := range r.Creations {
|
||||
row := c
|
||||
out = append(out, TimelineRow{
|
||||
Date: startOfDay(c.Item.CreatedAt),
|
||||
Kind: KindCreation,
|
||||
Item: c.Item,
|
||||
ItemPath: c.Item.PrimaryPath(),
|
||||
Creation: &row,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ func EventStartLabel(ev caldav.Event) string {
|
||||
return ev.Start.Local().Format("15:04")
|
||||
}
|
||||
|
||||
// FormatPERDate is the inverse of parsePER's YYMMDD slice. Lives here
|
||||
// (rather than in web/) so timeline-row builders outside web/ can render
|
||||
// PER strings without dragging in the web package.
|
||||
func FormatPERDate(t time.Time) string {
|
||||
return t.UTC().Format("060102")
|
||||
}
|
||||
|
||||
// EventDurationHint produces a "(N days)" badge for multi-day events and
|
||||
// a "(Nh)" hint for timed events whose end is on the same day. Empty for
|
||||
// all-day single-day events and events with no DTEND.
|
||||
|
||||
@@ -2,13 +2,13 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/web"
|
||||
"github.com/m/projax/internal/aggregate"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
func mustTime(t *testing.T, s string) time.Time {
|
||||
@@ -20,42 +20,50 @@ func mustTime(t *testing.T, s string) time.Time {
|
||||
return tt
|
||||
}
|
||||
|
||||
// fakeBuilder implements TimelineBuilder for unit tests. Each method records
|
||||
// the last call so tests can assert the args passed through unchanged.
|
||||
type fakeBuilder struct {
|
||||
lastArgs web.TimelineArgs
|
||||
payload *web.TimelinePayload
|
||||
err error
|
||||
// stubStore is a minimal LinkLister + ListByFilters-aware shim for unit
|
||||
// tests. We embed the absence of *store.Store fields the timeline tool
|
||||
// needs and surface only ListByFilters via a fake — the rest of mcp tests
|
||||
// pass nil because they don't exercise this path.
|
||||
type stubLinkLister struct{}
|
||||
|
||||
func (stubLinkLister) LinksByType(_ context.Context, _, _ string) ([]*store.ItemLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (stubLinkLister) DatedLinksRange(_ context.Context, _, _ time.Time) ([]*store.ItemLinkWithItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]*store.Item, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeBuilder) BuildTimelinePayloadFromArgs(_ context.Context, args web.TimelineArgs) (*web.TimelinePayload, error) {
|
||||
f.lastArgs = args
|
||||
return f.payload, f.err
|
||||
}
|
||||
|
||||
func newToolServer(t *testing.T, tl TimelineBuilder) *Server {
|
||||
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
|
||||
t.Helper()
|
||||
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// Pass a nil *store.Store — none of the store-backed tools are exercised
|
||||
// in this file; we only call the timeline tool.
|
||||
RegisterProjaxTools(srv, nil, tl)
|
||||
// Pass a nil *store.Store — the timeline tool's store-backed paths
|
||||
// short-circuit cleanly on errors in this test surface (we just probe
|
||||
// registration + arg parsing here).
|
||||
RegisterProjaxTools(srv, nil, agg)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestTimelineToolUnregisteredWhenBuilderNil proves passing nil yields a
|
||||
// missing "timeline" tool. This keeps existing MCP callers working without
|
||||
// pulling in web/.
|
||||
func TestTimelineToolUnregisteredWhenBuilderNil(t *testing.T) {
|
||||
func newAggregator() *aggregate.Aggregator {
|
||||
return aggregate.New(stubLinkLister{}, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
// TestTimelineToolUnregisteredWhenAggregatorNil proves passing nil yields a
|
||||
// missing "timeline" tool — same kill-switch contract as the pre-Phase-5a
|
||||
// TimelineBuilder=nil case.
|
||||
func TestTimelineToolUnregisteredWhenAggregatorNil(t *testing.T) {
|
||||
srv := newToolServer(t, nil)
|
||||
if _, ok := srv.tools["timeline"]; ok {
|
||||
t.Fatalf("expected timeline tool to be absent when builder is nil")
|
||||
t.Fatalf("expected timeline tool to be absent when agg is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineToolRegisteredWhenBuilderSet proves the tool shows up when
|
||||
// a builder is plumbed through.
|
||||
func TestTimelineToolRegisteredWhenBuilderSet(t *testing.T) {
|
||||
srv := newToolServer(t, &fakeBuilder{payload: &web.TimelinePayload{}})
|
||||
// TestTimelineToolRegisteredWhenAggregatorSet proves the tool shows up when
|
||||
// an aggregator is plumbed through.
|
||||
func TestTimelineToolRegisteredWhenAggregatorSet(t *testing.T) {
|
||||
srv := newToolServer(t, newAggregator())
|
||||
tool, ok := srv.tools["timeline"]
|
||||
if !ok {
|
||||
t.Fatalf("expected timeline tool to be registered")
|
||||
@@ -68,133 +76,77 @@ func TestTimelineToolRegisteredWhenBuilderSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineToolForwardsArgs proves the JSON arguments flow through to
|
||||
// BuildTimelinePayloadFromArgs unchanged. The fake records the last call.
|
||||
func TestTimelineToolForwardsArgs(t *testing.T) {
|
||||
now := mustTime(t, "2026-05-17T10:00:00Z")
|
||||
fb := &fakeBuilder{
|
||||
payload: &web.TimelinePayload{
|
||||
From: now.AddDate(0, 0, -30),
|
||||
To: now.AddDate(0, 0, 90),
|
||||
ToInclusive: now.AddDate(0, 0, 89),
|
||||
Order: "asc",
|
||||
Kinds: []string{"doc", "event"},
|
||||
BuiltAt: now,
|
||||
},
|
||||
}
|
||||
srv := newToolServer(t, fb)
|
||||
raw := []byte(`{
|
||||
"from": "2026-04-01",
|
||||
"to": "2026-08-01",
|
||||
"order": "asc",
|
||||
"kinds": ["doc","event"],
|
||||
"tags": ["work"],
|
||||
"mgmt": ["mai"],
|
||||
"has": ["caldav-list"],
|
||||
"status": ["active"],
|
||||
"q": "paliad"
|
||||
}`)
|
||||
out, err := srv.tools["timeline"].Handler(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("timeline tool call failed: %v", err)
|
||||
}
|
||||
if fb.lastArgs.From != "2026-04-01" || fb.lastArgs.To != "2026-08-01" {
|
||||
t.Errorf("expected from/to to flow through, got %+v", fb.lastArgs)
|
||||
}
|
||||
if fb.lastArgs.Order != "asc" {
|
||||
t.Errorf("expected order=asc, got %q", fb.lastArgs.Order)
|
||||
}
|
||||
if len(fb.lastArgs.Kinds) != 2 || fb.lastArgs.Kinds[0] != "doc" {
|
||||
t.Errorf("expected kinds=[doc,event], got %v", fb.lastArgs.Kinds)
|
||||
}
|
||||
if fb.lastArgs.Q != "paliad" {
|
||||
t.Errorf("expected q=paliad, got %q", fb.lastArgs.Q)
|
||||
}
|
||||
// And the result is the rendered timelineView, not the raw payload.
|
||||
view, ok := out.(timelineView)
|
||||
if !ok {
|
||||
t.Fatalf("expected timelineView, got %T", out)
|
||||
}
|
||||
if view.Order != "asc" {
|
||||
t.Errorf("view.Order = %q, want asc", view.Order)
|
||||
}
|
||||
if view.From != "2026-04-17" {
|
||||
t.Errorf("view.From = %q, want 2026-04-17 (from payload not args)", view.From)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineToolPropagatesError proves builder errors bubble out as
|
||||
// tool-handler errors (the JSON-RPC layer turns them into isError=true).
|
||||
func TestTimelineToolPropagatesError(t *testing.T) {
|
||||
fb := &fakeBuilder{err: errors.New("boom")}
|
||||
srv := newToolServer(t, fb)
|
||||
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{}`))
|
||||
// TestTimelineToolBadDateReturnsError proves a malformed `from` rejects with
|
||||
// a tool-level error rather than panicking.
|
||||
func TestTimelineToolBadDateReturnsError(t *testing.T) {
|
||||
srv := newToolServer(t, newAggregator())
|
||||
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{"from":"not-a-date"}`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeline tool to propagate builder error, got nil")
|
||||
}
|
||||
if err.Error() != "boom" {
|
||||
t.Errorf("expected wrapped error to contain 'boom', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineToolEmptyPayloadRendersEmptyDays exercises the empty-state
|
||||
// path so we don't accidentally drop the Days slice when there's nothing.
|
||||
func TestTimelineToolEmptyPayloadRendersEmptyDays(t *testing.T) {
|
||||
now := mustTime(t, "2026-05-17T10:00:00Z")
|
||||
fb := &fakeBuilder{
|
||||
payload: &web.TimelinePayload{
|
||||
From: now,
|
||||
To: now.AddDate(0, 0, 1),
|
||||
ToInclusive: now,
|
||||
Order: "desc",
|
||||
Kinds: []string{"todo", "event", "doc", "creation"},
|
||||
BuiltAt: now,
|
||||
},
|
||||
}
|
||||
srv := newToolServer(t, fb)
|
||||
out, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("timeline tool call failed: %v", err)
|
||||
}
|
||||
view, ok := out.(timelineView)
|
||||
if !ok {
|
||||
t.Fatalf("expected timelineView, got %T", out)
|
||||
}
|
||||
if view.Days == nil {
|
||||
t.Errorf("Days slice should be initialised (empty array), not nil")
|
||||
}
|
||||
if view.TotalRows != 0 {
|
||||
t.Errorf("empty payload should report TotalRows=0, got %d", view.TotalRows)
|
||||
t.Fatalf("expected timeline tool to error on bad date")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineToolBadJSONReturnsError proves a malformed args body produces
|
||||
// a tool-level error rather than panicking.
|
||||
func TestTimelineToolBadJSONReturnsError(t *testing.T) {
|
||||
srv := newToolServer(t, &fakeBuilder{payload: &web.TimelinePayload{}})
|
||||
srv := newToolServer(t, newAggregator())
|
||||
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`not-json`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeline tool to error on bad JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineViewSerializesTimes proves the timelineView shape uses string
|
||||
// timestamps so the JSON-RPC envelope stays language-agnostic.
|
||||
// TestTimelineViewSerializesTimes proves the buildTimelineView helper emits
|
||||
// the expected stringified time shape so the JSON-RPC envelope stays
|
||||
// language-agnostic.
|
||||
func TestTimelineViewSerializesTimes(t *testing.T) {
|
||||
now := mustTime(t, "2026-05-17T10:30:00Z")
|
||||
v := toTimelineView(&web.TimelinePayload{
|
||||
From: now.AddDate(0, 0, -3),
|
||||
To: now.AddDate(0, 0, 4),
|
||||
ToInclusive: now.AddDate(0, 0, 3),
|
||||
Order: "desc",
|
||||
Kinds: []string{"todo"},
|
||||
BuiltAt: now,
|
||||
TotalRows: 0,
|
||||
})
|
||||
v := buildTimelineView(nil, now.AddDate(0, 0, -3), now.AddDate(0, 0, 4), "desc", []string{aggregate.KindTodo}, 0, now)
|
||||
if v.From != "2026-05-14" {
|
||||
t.Errorf("From should be YYYY-MM-DD, got %q", v.From)
|
||||
}
|
||||
if v.To != "2026-05-21" {
|
||||
t.Errorf("To should be YYYY-MM-DD, got %q", v.To)
|
||||
}
|
||||
if v.ToInclusive != "2026-05-20" {
|
||||
t.Errorf("ToInclusive should be exclusive-To minus one day, got %q", v.ToInclusive)
|
||||
}
|
||||
if v.BuiltAt != "2026-05-17T10:30:00Z" {
|
||||
t.Errorf("BuiltAt should be ISO-8601 UTC, got %q", v.BuiltAt)
|
||||
}
|
||||
if v.Days == nil {
|
||||
t.Errorf("Days slice should be initialised (empty array), not nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTimelineKindsSanitises proves resolveTimelineKinds drops
|
||||
// unknown values, dedupes, and sorts.
|
||||
func TestResolveTimelineKindsSanitises(t *testing.T) {
|
||||
got := resolveTimelineKinds([]string{"todo", "junk", "event", "todo", "DOC"})
|
||||
want := []string{"doc", "event", "todo"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("pos %d: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTimelineWindowDefaults proves no args → default 30/+90 window.
|
||||
func TestResolveTimelineWindowDefaults(t *testing.T) {
|
||||
now := mustTime(t, "2026-05-21T12:00:00Z")
|
||||
from, to, err := resolveTimelineWindow(TimelineArgs{}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("default window: %v", err)
|
||||
}
|
||||
wantFrom := now.AddDate(0, 0, -timelineDefaultPastDays).Format("2006-01-02")
|
||||
if from.Format("2006-01-02") != wantFrom {
|
||||
t.Errorf("from = %s, want %s", from.Format("2006-01-02"), wantFrom)
|
||||
}
|
||||
wantTo := now.AddDate(0, 0, timelineDefaultFutureDays).Format("2006-01-02")
|
||||
if to.Format("2006-01-02") != wantTo {
|
||||
t.Errorf("to = %s, want %s", to.Format("2006-01-02"), wantTo)
|
||||
}
|
||||
}
|
||||
|
||||
283
mcp/tools.go
283
mcp/tools.go
@@ -5,28 +5,35 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/internal/aggregate"
|
||||
"github.com/m/projax/store"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// TimelineBuilder is the subset of *web.Server the MCP timeline tool needs.
|
||||
// Kept as an interface so tests can drive the registration without spinning
|
||||
// up a full web.Server (the existing mcp tests pass nil — the timeline tool
|
||||
// is simply not registered in that case).
|
||||
type TimelineBuilder interface {
|
||||
BuildTimelinePayloadFromArgs(ctx context.Context, args web.TimelineArgs) (*web.TimelinePayload, error)
|
||||
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
|
||||
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
|
||||
type TimelineArgs struct {
|
||||
From string `json:"from"` // YYYY-MM-DD, optional (default now-30d)
|
||||
To string `json:"to"` // YYYY-MM-DD, optional (default now+90d)
|
||||
Order string `json:"order"` // "asc" | "desc" (default desc)
|
||||
Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all
|
||||
Tags []string `json:"tags"` // ALL must be present
|
||||
Mgmt []string `json:"mgmt"` // ALL must be present
|
||||
Has []string `json:"has"` // ALL ref-types present (caldav-list / gitea-repo)
|
||||
Status []string `json:"status"` // ANY match (default ["active"]) — first value used for the store filter
|
||||
Q string `json:"q"` // substring match against title/slug/aliases/content_md
|
||||
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
|
||||
}
|
||||
|
||||
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
|
||||
// All tools delegate to *store.Store directly so business logic is shared
|
||||
// with the web UI — no duplication. The optional tl argument adds the
|
||||
// timeline tool when non-nil (it needs the web aggregation surface that
|
||||
// fans out across CalDAV; passing nil keeps the rest of the toolset usable
|
||||
// without web/ deps).
|
||||
func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
|
||||
// with the web UI — no duplication. The optional agg argument adds the
|
||||
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
|
||||
// keeps the rest of the toolset usable without aggregate deps).
|
||||
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
|
||||
s.Register(Tool{
|
||||
Name: "list_items",
|
||||
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
|
||||
@@ -187,7 +194,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
|
||||
}`),
|
||||
Handler: treeTool(st),
|
||||
})
|
||||
if tl != nil {
|
||||
if agg != nil {
|
||||
s.Register(Tool{
|
||||
Name: "timeline",
|
||||
Description: "Chronological spine of dated content (VTODOs with DUE, VEVENTs, dated item_links, item-creation markers) braided into per-day groups. Same shape as projax's /timeline web view. All filters optional; defaults mirror the web page (past 30d through next 90d, desc order, all four kinds).",
|
||||
@@ -198,41 +205,235 @@ func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
|
||||
"to": {"type": "string", "description": "YYYY-MM-DD inclusive upper bound; default now+90d"},
|
||||
"order": {"type": "string", "enum": ["asc","desc"], "description": "Day-group order; default desc"},
|
||||
"kinds": {"type": "array", "items": {"type": "string", "enum": ["todo","event","doc","creation"]}, "description": "Narrow to a subset; empty = all four"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ALL tags must be present"},
|
||||
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY of these management modes matches"},
|
||||
"has": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ALL link kinds present (e.g. ['caldav-list'])"},
|
||||
"status": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY status matches; default ['active']"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "ALL tags must be present"},
|
||||
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "ALL management modes must be present"},
|
||||
"has": {"type": "array", "items": {"type": "string"}, "description": "ALL link kinds present (e.g. ['caldav-list'])"},
|
||||
"status": {"type": "array", "items": {"type": "string"}, "description": "First value used for the store-level filter; default ['active']"},
|
||||
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
|
||||
}
|
||||
}`),
|
||||
Handler: timelineTool(tl),
|
||||
Handler: timelineTool(st, agg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- timeline ---
|
||||
//
|
||||
// The tool wraps web.*Server.BuildTimelinePayloadFromArgs. Output shape
|
||||
// mirrors the web template's data structure with one small adaptation:
|
||||
// time.Time values are serialised as ISO-8601 strings so the JSON-RPC
|
||||
// envelope stays language-agnostic (the PWA's TypeScript client decodes
|
||||
// these to Date via the same convention the existing list_items / get_item
|
||||
// tools use).
|
||||
// The tool runs the timeline aggregation end-to-end inside mcp/: store
|
||||
// filters → fan-out via *aggregate.Aggregator → row projection →
|
||||
// BuildTimelineDays → serialised timelineView. Phase 5a slice D moved this
|
||||
// off *web.Server.BuildTimelinePayloadFromArgs so mcp no longer points back
|
||||
// at web/. Tree-filter dimensions (tags/mgmt/status/Q/has) are translated
|
||||
// to store.SearchFilters; the MCP filter surface is slightly narrower than
|
||||
// the web TreeFilter (single-value status, AND-management) — m's main MCP
|
||||
// use cases (tag + status) round-trip identically.
|
||||
|
||||
func timelineTool(tl TimelineBuilder) ToolHandler {
|
||||
const (
|
||||
timelineDefaultPastDays = 30
|
||||
timelineDefaultFutureDays = 90
|
||||
)
|
||||
|
||||
func timelineTool(st *store.Store, agg *aggregate.Aggregator) ToolHandler {
|
||||
return func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args web.TimelineArgs
|
||||
var args TimelineArgs
|
||||
if err := parseInput(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("bad params: %w", err)
|
||||
}
|
||||
payload, err := tl.BuildTimelinePayloadFromArgs(ctx, args)
|
||||
now := time.Now()
|
||||
from, to, err := resolveTimelineWindow(args, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTimelineView(payload), nil
|
||||
order := "desc"
|
||||
if args.Order == "asc" {
|
||||
order = "asc"
|
||||
}
|
||||
kinds := resolveTimelineKinds(args.Kinds)
|
||||
items, err := resolveTimelineItems(ctx, st, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !args.IncludeExcluded {
|
||||
items = filterByTimelineExclude(items, kinds)
|
||||
}
|
||||
hasCalDAVRefType := containsString(args.Has, aggregate.RefTypeCalDAV)
|
||||
hasGitRefType := containsString(args.Has, aggregate.RefTypeGiteaRepo)
|
||||
items, err = applyHasLinkFilters(ctx, st, items, hasCalDAVRefType, hasGitRefType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := agg.All(ctx, items, aggregate.AllOpts{
|
||||
Window: aggregate.Window{From: from, To: to},
|
||||
Kinds: kinds,
|
||||
})
|
||||
rows := result.ToTimelineRows()
|
||||
// Defensive narrowing for events: the aggregator passed `to` to
|
||||
// CalDAV as TimeMax (exclusive) — but events occasionally arrive
|
||||
// outside that bound when servers misinterpret RECURRENCE-ID. Strip
|
||||
// them here so the MCP payload stays inside the documented window.
|
||||
filtered := rows[:0]
|
||||
for _, r := range rows {
|
||||
if r.Date.Before(from) || !r.Date.Before(to) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
days := aggregate.BuildTimelineDays(filtered, aggregate.BuildOpts{
|
||||
Now: now,
|
||||
Order: order,
|
||||
FadeAfter: 30 * 24 * time.Hour,
|
||||
})
|
||||
totalRows := 0
|
||||
for _, d := range days {
|
||||
totalRows += len(d.Rows)
|
||||
}
|
||||
return buildTimelineView(days, from, to, order, kinds, totalRows, now), nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTimelineWindow(args TimelineArgs, now time.Time) (time.Time, time.Time, error) {
|
||||
from := startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays))
|
||||
to := startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays))
|
||||
if v := strings.TrimSpace(args.From); v != "" {
|
||||
t, err := time.Parse("2006-01-02", v)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("from must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
from = startOfDay(t)
|
||||
}
|
||||
if v := strings.TrimSpace(args.To); v != "" {
|
||||
t, err := time.Parse("2006-01-02", v)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("to must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
to = startOfDay(t).AddDate(0, 0, 1)
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func resolveTimelineKinds(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
allowed := map[string]struct{}{
|
||||
aggregate.KindTodo: {}, aggregate.KindEvent: {}, aggregate.KindDoc: {}, aggregate.KindCreation: {},
|
||||
}
|
||||
for _, k := range in {
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
if _, ok := allowed[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveTimelineItems(ctx context.Context, st *store.Store, args TimelineArgs) ([]*store.Item, error) {
|
||||
status := "active"
|
||||
if len(args.Status) > 0 && strings.TrimSpace(args.Status[0]) != "" {
|
||||
status = strings.TrimSpace(args.Status[0])
|
||||
}
|
||||
if status == "*" || status == "any" {
|
||||
status = ""
|
||||
}
|
||||
return st.ListByFilters(ctx, store.SearchFilters{
|
||||
Tags: trimList(args.Tags),
|
||||
Management: trimList(args.Mgmt),
|
||||
Status: status,
|
||||
Q: strings.TrimSpace(args.Q),
|
||||
})
|
||||
}
|
||||
|
||||
func filterByTimelineExclude(items []*store.Item, kinds []string) []*store.Item {
|
||||
if len(items) == 0 {
|
||||
return items
|
||||
}
|
||||
out := items[:0:0]
|
||||
for _, it := range items {
|
||||
keep := false
|
||||
for _, k := range kinds {
|
||||
if !it.ExcludesTimelineKind(k) {
|
||||
keep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyHasLinkFilters(ctx context.Context, st *store.Store, items []*store.Item, needCalDAV, needGitea bool) ([]*store.Item, error) {
|
||||
if !needCalDAV && !needGitea {
|
||||
return items, nil
|
||||
}
|
||||
hasCalDAV := map[string]struct{}{}
|
||||
hasGitea := map[string]struct{}{}
|
||||
if needCalDAV {
|
||||
links, err := st.LinksByRefType(ctx, aggregate.RefTypeCalDAV)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range links {
|
||||
hasCalDAV[l.ItemID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if needGitea {
|
||||
links, err := st.LinksByRefType(ctx, aggregate.RefTypeGiteaRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range links {
|
||||
hasGitea[l.ItemID] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := items[:0:0]
|
||||
for _, it := range items {
|
||||
if needCalDAV {
|
||||
if _, ok := hasCalDAV[it.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if needGitea {
|
||||
if _, ok := hasGitea[it.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimList(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// timelineView mirrors web.TimelinePayload but stringifies times so JSON
|
||||
// consumers don't have to know about Go's RFC 3339 format vs the YYYY-MM-DD
|
||||
// flavour the web template uses.
|
||||
@@ -286,18 +487,21 @@ type timelineEventView struct {
|
||||
DurationHint string `json:"duration_hint,omitempty"`
|
||||
}
|
||||
|
||||
func toTimelineView(p *web.TimelinePayload) timelineView {
|
||||
out := timelineView{
|
||||
From: p.From.Format("2006-01-02"),
|
||||
To: p.To.Format("2006-01-02"),
|
||||
ToInclusive: p.ToInclusive.Format("2006-01-02"),
|
||||
Order: p.Order,
|
||||
Kinds: sliceOr(p.Kinds, []string{}),
|
||||
TotalRows: p.TotalRows,
|
||||
BuiltAt: p.BuiltAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
func buildTimelineView(days []aggregate.TimelineDay, from, to time.Time, order string, kinds []string, totalRows int, builtAt time.Time) timelineView {
|
||||
if kinds == nil {
|
||||
kinds = []string{}
|
||||
}
|
||||
out.Days = make([]timelineDayView, 0, len(p.Days))
|
||||
for _, d := range p.Days {
|
||||
out := timelineView{
|
||||
From: from.Format("2006-01-02"),
|
||||
To: to.Format("2006-01-02"),
|
||||
ToInclusive: to.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
Order: order,
|
||||
Kinds: kinds,
|
||||
TotalRows: totalRows,
|
||||
BuiltAt: builtAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
out.Days = make([]timelineDayView, 0, len(days))
|
||||
for _, d := range days {
|
||||
dv := timelineDayView{
|
||||
Date: d.DateKey,
|
||||
Label: d.Label,
|
||||
@@ -361,6 +565,7 @@ func toTimelineView(p *web.TimelinePayload) timelineView {
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// itemView is the JSON shape returned to MCP clients. We hand-roll it so the
|
||||
// field names stay snake_case and the *time.Time / *string nullability
|
||||
// renders as JSON null instead of being skipped (omitempty would hide them).
|
||||
|
||||
@@ -2,7 +2,6 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -218,81 +217,6 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
|
||||
return q
|
||||
}
|
||||
|
||||
// TimelineArgs is the MCP-facing input shape — a struct equivalent of the
|
||||
// URL query string consumed by parseTimelineQuery. JSON-tagged so callers
|
||||
// can unmarshal a JSON object straight into it.
|
||||
type TimelineArgs struct {
|
||||
From string `json:"from"` // YYYY-MM-DD, optional (default now-30d)
|
||||
To string `json:"to"` // YYYY-MM-DD, optional (default now+90d)
|
||||
Order string `json:"order"` // "asc" | "desc" (default desc)
|
||||
Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all
|
||||
Tags []string `json:"tags"` // tree-filter: ALL must be present
|
||||
Mgmt []string `json:"mgmt"` // tree-filter: ANY match (incl. "unmanaged")
|
||||
Has []string `json:"has"` // tree-filter: ALL ref-types present
|
||||
Status []string `json:"status"` // tree-filter: ANY match (default ["active"])
|
||||
Q string `json:"q"` // tree-filter: substring match
|
||||
IncludeExcluded bool `json:"include_excluded"`// Phase 4f: ignore per-item timeline_exclude arrays
|
||||
}
|
||||
|
||||
// BuildTimelinePayloadFromArgs is the MCP entrypoint to the timeline
|
||||
// aggregation. It mirrors parseTimelineQuery but reads from a typed struct
|
||||
// rather than an *http.Request. Returns the same TimelinePayload the web
|
||||
// handler renders.
|
||||
//
|
||||
// Note: the in-memory cache is NOT consulted on the MCP path — the timeline
|
||||
// data is small enough that re-aggregation per RPC call is cheaper than
|
||||
// invalidating across two different keying schemes. The web cache stays
|
||||
// scoped to the web handler.
|
||||
func (s *Server) BuildTimelinePayloadFromArgs(ctx context.Context, args TimelineArgs) (*TimelinePayload, error) {
|
||||
now := time.Now()
|
||||
q := TimelineQuery{
|
||||
Filter: TreeFilter{
|
||||
Tags: args.Tags,
|
||||
Management: args.Mgmt,
|
||||
HasLinks: args.Has,
|
||||
Status: args.Status,
|
||||
Q: args.Q,
|
||||
},
|
||||
From: startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays)),
|
||||
To: startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays)),
|
||||
Order: "desc",
|
||||
}
|
||||
if len(q.Filter.Status) == 0 {
|
||||
q.Filter.Status = []string{"active"}
|
||||
}
|
||||
if v := strings.TrimSpace(args.From); v != "" {
|
||||
t, err := time.Parse("2006-01-02", v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("from must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
q.From = startOfDay(t)
|
||||
}
|
||||
if v := strings.TrimSpace(args.To); v != "" {
|
||||
t, err := time.Parse("2006-01-02", v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("to must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
q.To = startOfDay(t).AddDate(0, 0, 1)
|
||||
}
|
||||
if args.Order == "asc" {
|
||||
q.Order = "asc"
|
||||
}
|
||||
q.IncludeExcluded = args.IncludeExcluded
|
||||
seen := map[string]bool{}
|
||||
for _, k := range args.Kinds {
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
return s.buildTimeline(ctx, q, now)
|
||||
}
|
||||
|
||||
// handleTimeline renders the chronological spine at /timeline.
|
||||
func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
|
||||
Reference in New Issue
Block a user