From 599e5cae0824ed2e98a03e50882f475c1e50feac Mon Sep 17 00:00:00 2001 From: david <58231312+davseby@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:18:33 +0300 Subject: [PATCH] add hooks to mcp and assistant --- server/CLAUDE.md | 32 +- server/core/cmd/core/main.go | 2 + server/core/internal/assistant/manager.go | 32 +- .../core/internal/assistant/manager_test.go | 28 +- server/core/internal/assistant/prompt.go | 4 +- server/core/internal/assistant/prompt_test.go | 2 + .../internal/assistant/tools/0mock_db_test.go | 260 ++++ .../tools/0mock_hook_notifier_test.go | 89 ++ .../core/internal/assistant/tools/_mock/db.go | 260 ++++ server/core/internal/assistant/tools/hook.go | 774 ++++++++++++ .../internal/assistant/tools/hook_test.go | 1087 +++++++++++++++++ server/core/internal/assistant/tools/input.go | 265 +++- .../internal/assistant/tools/input_test.go | 623 +++++++++- .../tools/testdata/tool_schemas.golden | 2 +- server/core/internal/assistant/tools/tool.go | 38 + server/core/internal/assistant/tools/tools.go | 12 +- .../internal/assistant/tools/tools_test.go | 11 +- .../server/internal/document/hook/http.go | 26 + .../internal/document/hook/http_test.go | 84 +- .../server/internal/document/hook/ws.go | 51 + .../server/internal/document/hook/ws_test.go | 67 + .../internal/server/internal/mcp/http_test.go | 3 + .../server/internal/mcp/server_test.go | 5 + server/core/internal/server/router.go | 6 + server/core/internal/server/server.go | 1 + server/core/internal/server/server_test.go | 8 +- .../DocumentContainer.client.nuxt.test.ts | 90 +- .../editor/DocumentContainer.client.vue | 34 + web/app/utils/api/document.test.ts | 7 + web/app/utils/api/document.ts | 10 + 30 files changed, 3874 insertions(+), 39 deletions(-) create mode 100644 server/core/internal/assistant/tools/0mock_hook_notifier_test.go create mode 100644 server/core/internal/assistant/tools/hook.go create mode 100644 server/core/internal/assistant/tools/hook_test.go create mode 100644 server/core/internal/server/internal/document/hook/ws.go create mode 100644 server/core/internal/server/internal/document/hook/ws_test.go diff --git a/server/CLAUDE.md b/server/CLAUDE.md index a58d426b..3dddffc3 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -67,7 +67,7 @@ So from a frontend's point of view: auth + realtime is `:8080/auth-realtime/...` - `/api/x/...` (internal): no auth at all — reverse proxy must firewall. Used by auth-realtime to fetch/store branch content (`/x/documents/{id}/branches`, `/x/documents/{id}/branch/{branchId}`), trigger emails, and initialize or tear down orgs. - `/api/apps/...` (public, sessionless): where GitHub and Slack deliver. `POST /apps/github/events` and `POST /apps/slack/{events,commands,slash}` are gated by the provider's request signature; `GET /apps/slack/install` completes the direct-install OAuth exchange. These must stay outside `/api/x` — third parties reach core through the same front door as browsers, and the proxy 403s the internal subtree. - `/api/mcp` (public, bearer-authed): the MCP surface (`internal/server/internal/mcp`), a streamable HTTP MCP server bridging the assistant's ungated tool registry (`tools.Set.Entries`) plus documents-as-resources. Bearer tokens are JWTs issued by auth-realtime's `@better-auth/mcp` OAuth provider; core validates each request against auth-realtime's internal `GET /api/internal/mcp/session` (JWKS verify + consent-row check, so revoking a client 401s immediately) and scopes the tool list by the token's `documents:read` / `documents:write` / `data-sources:read` scopes. Tokens are org-bound via an `org_id` claim minted at issuance. `SERVER_MCP_SESSION_URL` and `SERVER_MCP_RESOURCE_URL` are required env; the Caddyfile routes `/.well-known/oauth-*` and `/api/auth/*` at the front door to auth-realtime for OAuth discovery. -- WebSocket topics under `/api/ws` (routed by `wetsocks/wsserver` from the first-party `github.com/oxynote/wetsocks` library): `change@document-tree`, `change@tag-tree`, `change@documents.{documentId}.comments|metadata|reviewers|maintainers|tags`, `post@slack.messages`, `creation@notifications`, `ping@version`. Topic binders live on the per-domain handler types under `internal/server/internal/...` (`Handler.BindXxx`). +- WebSocket topics under `/api/ws` (routed by `wetsocks/wsserver` from the first-party `github.com/oxynote/wetsocks` library): `change@document-tree`, `change@tag-tree`, `change@documents.{documentId}.comments|metadata|reviewers|maintainers|tags|hooks`, `post@slack.messages`, `creation@notifications`, `ping@version`. Topic binders live on the per-domain handler types under `internal/server/internal/...` (`Handler.BindXxx`). Most public routes in the README (`/api/documents`, `/api/documents/tree`, etc.) are served by core; the README is the closest thing to a contract spec — when changing handlers, update it. @@ -174,7 +174,7 @@ Writing a rule: Tools are grouped by what they act on, a few per file: `document.go` (the tree, one document read whole, and a document's name, icon and place), `block.go` (reading one block and editing content), `tag.go` (the organisation's tags and which branch -carries them), `search.go`, +carries them), `hook.go` (the freshness hooks on a branch), `search.go`, `datasource.go` (reads against the organisation's outbound connections). `eino.go` is the odd one out — it holds the agent-framework adapter and `read_tool_output`, the one tool that exists because of eino rather than because of the domain. `tools.Set` is the @@ -191,8 +191,10 @@ and `Entry.Tool`, a `Runner`, to run it. **A call reports what it changed; it is never asked.** `Runner.Run` returns a `Result` carrying the output and `Documents`. Every document write goes through one of the `Input` methods — `ApplyEdit`, `CreateDocument`, `MoveDocument`, `DeleteDocument`, -`AssignTag`, `UnassignTag` — and all but the delete record the document as the mutation -happens. A delete records nothing: the document it names is gone. The tag-only writes +`AssignTag`, `UnassignTag`, `CreateHook`, `UpdateHook`, `ResetHook`, `DeleteHook` — and +all but the document delete record the document as the mutation happens. A document +delete records nothing: the document it names is gone. A hook delete does record the +branch: it stays, and the editor showing it has to redraw. The tag-only writes (`CreateTag`, `UpdateTag`, `DeleteTag`, `MoveTag`) change no document and record none. **Tag writes announce themselves like tree writes.** Every tag tool's `Execute` ends @@ -208,6 +210,26 @@ own tags. read carrying name, colour and documents together), refusing an unknown id with `ErrUnknownTag`. +**Hook writes share the HTTP handler's lifecycle and announce themselves the same +way.** `Input.CreateHook`, `UpdateHook`, `ResetHook` and `DeleteHook` drive +`hook.NewHook`, `Hook.ApplyUpdate`, `Hook.Reset` and `Hook.Delete` with a `hook.Input` +built from the GitHub manager and changedetection client `Deps` carries, so a watcher +created or torn down by the assistant is the same watcher the HTTP handler would have +made. Each write then calls the hook handler's exported `NotifyHooksChange` through +`tools.HookNotifier` (`server.NewServer` wires it with `SetHookNotifier`, beside the tag +one), which publishes on `change@documents.{documentId}.hooks` so an open editor redraws +its hook indicators; the HTTP handler publishes on the same topic after its own writes. +`CreateHook` refuses a `block_uid` the branch does not hold with `errUnknownBlock` and a +github-tracking or url-watcher hook on a deployment without that integration, before +anything is created; `Input.FetchHook` scopes the lookup to the organisation and refuses +a hook of another document, or of a deleted one, with `errUnknownHook` naming the +document addressed. Settings arrive as one `settings` object whose shape the hook type +decides, the way a block's type decides its fields: `decodeHookSettings` switches on +the type and unmarshals into the processor's own struct, requiring that type's fields +and refusing any other type's naming its owner (`hookSettingOwner`). A scheduled +reminder is stored with scale `linear` and duration `custom`, which is how the editor +labels a concrete date. + **A block write reports what it wrote from the operation, never from a re-read.** The realtime service applies an edit to the live document and persists it on a debounce, so content read back right after a write may not show it. A write therefore expands its @@ -281,7 +303,7 @@ interface, which would be a second fact to keep in step. What a tool is, it stat kind ever asked for one; `Test_New` checks that a write produces one and nothing else does. - `Destructive` keeps it outside an "approve all" answer. Only `delete_document`, - `delete_block` and `delete_tag`. + `delete_block`, `delete_tag` and `delete_hook`. - `Overwrites` says the write replaces content the caller did not name — the target's nested blocks, and the uids comments and hooks hang off, go with it. `update_block_text` and `replace_block`. It is what MCP's destructive hint reports diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go index 4c14506a..2c3b015a 100644 --- a/server/core/cmd/core/main.go +++ b/server/core/cmd/core/main.go @@ -296,6 +296,8 @@ func main() { //nolint:maintidx // main performs linear wiring of all components searchClient, searchJobs, datasourceMan, + githubMan, + webchangeClient, string(assistantOpts.Provider), ) diff --git a/server/core/internal/assistant/manager.go b/server/core/internal/assistant/manager.go index b5debf10..c9d9f679 100644 --- a/server/core/internal/assistant/manager.go +++ b/server/core/internal/assistant/manager.go @@ -23,6 +23,8 @@ import ( "github.com/cloudwego/eino/schema" "github.com/gomodule/redigo/redis" "github.com/jellydator/xync" + "github.com/oxynote/oxynote/server/core/internal/apps/github" + "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/assistant/persist" "github.com/oxynote/oxynote/server/core/internal/assistant/protocol" "github.com/oxynote/oxynote/server/core/internal/assistant/tools" @@ -54,6 +56,10 @@ type Manager struct { applier tools.EditApplier tree tools.TreeNotifier tags tools.TagNotifier + hooks tools.HookNotifier + + githubMan *github.Manager + webchangeClient *webchange.Client history *persist.History checkpoints *persist.Checkpoints @@ -84,10 +90,12 @@ type Manager struct { // is the edit pipe to the Node hocuspocus service; the search client // backs the search_documents tool and searchJobs is the queue document // writes announce themselves to; providerName labels token metrics so -// usage stays readable across a provider change; the tree notifier -// broadcasts sidebar refresh events after document tree mutations and is -// wired post-construction via SetTreeNotifier because the document -// handler that satisfies it is built later, inside server.NewServer. +// usage stays readable across a provider change; githubMan and +// webchangeClient are the integrations the hook tools create watchers +// through; the tree notifier broadcasts sidebar refresh events after +// document tree mutations and is wired post-construction via +// SetTreeNotifier because the document handler that satisfies it is +// built later, inside server.NewServer. func NewManager( log *slog.Logger, db tools.DB, @@ -99,6 +107,8 @@ func NewManager( searcher tools.Searcher, searchJobs *search.Jobs, runners tools.DataSourceRunners, + githubMan *github.Manager, + webchangeClient *webchange.Client, providerName string, ) *Manager { if summaryModel == nil { @@ -136,6 +146,9 @@ func NewManager( summary: summaryModel, applier: editClient, + githubMan: githubMan, + webchangeClient: webchangeClient, + history: persist.NewHistory(log, history), checkpoints: persist.NewCheckpoints(log, blobs), pendings: persist.NewPendings(log, pendings), @@ -188,6 +201,14 @@ func (m *Manager) SetTagNotifier(tags tools.TagNotifier) { m.tags = tags } +// SetHookNotifier wires the hook-change notifier the assistant uses to +// tell an open editor its hook indicators changed after hook mutations. +// Like SetTagNotifier, call it once during startup, before serving +// traffic. +func (m *Manager) SetHookNotifier(hooks tools.HookNotifier) { + m.hooks = hooks +} + // ToolSet builds the tool registry for one (organization, user) pair // from the manager's shared wiring. The MCP surface uses it to serve // the same tools the assistant's sessions get, scoped the same way. @@ -198,9 +219,12 @@ func (m *Manager) ToolSet(orgID, userID string) *tools.Set { m.search, m.jobs, m.runners, + m.githubMan, + m.webchangeClient, m.applier, m.tree, m.tags, + m.hooks, m.offload, orgID, userID, diff --git a/server/core/internal/assistant/manager_test.go b/server/core/internal/assistant/manager_test.go index 5b9018d9..93bcac29 100644 --- a/server/core/internal/assistant/manager_test.go +++ b/server/core/internal/assistant/manager_test.go @@ -12,6 +12,8 @@ import ( "github.com/cloudwego/eino/schema" "github.com/gomodule/redigo/redis" mock "github.com/oxynote/oxynote/server/core/internal/_mock" + "github.com/oxynote/oxynote/server/core/internal/apps/github" + "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/assistant/persist" persistMock "github.com/oxynote/oxynote/server/core/internal/assistant/persist/_mock" protocolMock "github.com/oxynote/oxynote/server/core/internal/assistant/protocol/_mock" @@ -234,7 +236,9 @@ func Test_NewManager(t *testing.T) { t.Parallel() fc := metricutil.NewFactory("test", prometheus.NewRegistry()) - m := NewManager(discardLog(), nil, c.Pool, nil, nil, fc, nil, nil, search.NewJobs(false), nil, "claude") + gh := &github.Manager{} + wc := &webchange.Client{} + m := NewManager(discardLog(), nil, c.Pool, nil, nil, fc, nil, nil, search.NewJobs(false), nil, gh, wc, "claude") require.NotNil(t, m) assert.NotNil(t, m.log) @@ -244,7 +248,11 @@ func Test_NewManager(t *testing.T) { assert.NotNil(t, m.offload) assert.NotNil(t, m.metrics) assert.NotNil(t, m.turns.m) + assert.Same(t, gh, m.githubMan) + assert.Same(t, wc, m.webchangeClient) assert.Nil(t, m.tree) + assert.Nil(t, m.tags) + assert.Nil(t, m.hooks) // the checkpoint and the offloaded results share one byte // store, so three stores back the four persist types and @@ -331,6 +339,16 @@ func Test_Manager_SetTagNotifier(t *testing.T) { assert.Nil(t, m.tags) } +func Test_Manager_SetHookNotifier(t *testing.T) { + t.Parallel() + + m := testManager() + require.Nil(t, m.hooks) + + m.SetHookNotifier(nil) + assert.Nil(t, m.hooks) +} + func Test_Manager_ToolSet(t *testing.T) { t.Parallel() @@ -340,10 +358,10 @@ func Test_Manager_ToolSet(t *testing.T) { require.NotNil(t, s) // the set carries the full registry — the thirteen document tools, - // the seven tag tools, the nine data-source tools and the - // offloaded-result reader — wired from the manager's shared - // dependencies and scoped to the requested pair. - assert.Len(t, s.Tools(), 30) + // the seven tag tools, the five hook tools, the nine data-source + // tools and the offloaded-result reader — wired from the manager's + // shared dependencies and scoped to the requested pair. + assert.Len(t, s.Tools(), 35) } func Test_Manager_claimTurn(t *testing.T) { diff --git a/server/core/internal/assistant/prompt.go b/server/core/internal/assistant/prompt.go index 8d911bc7..1534a5dd 100644 --- a/server/core/internal/assistant/prompt.go +++ b/server/core/internal/assistant/prompt.go @@ -38,6 +38,8 @@ Read before you write: find the document, read it with get_document, and read a Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads. +Hooks are freshness watchers on a branch or on one of its blocks: a scheduled reminder, a GitHub repository, a web page or a container image, each scoring the document down as what it watches changes. list_hooks shows the hooks of a branch with their ids, and create_hook takes the branch_id like the content tools and a block_uid from get_document to anchor to a block. + The data-source tools are read-only. Answer a question about the data from the query results; add a metric block only when the user asks for a chart, a metric or a dashboard in a document. Discover metric, label and table names with the metadata tools instead of guessing them, and run the query with the chart_type you intend before writing a metric block, so you know it renders. ` @@ -171,7 +173,7 @@ const _basePrompt = _personaSection + _blockModelSection + _etiquetteSection + _ // instead of waiting on a confirmation. const _mcpIntroSection = `You are connected to Oxynote, a collaborative product for writing technical documentation. The tools operate on the documents of one organisation, which are also listed as resources. Blocks are the unit of content, addressed by uid; comments, hooks and files hang off those uids. Documents are written for humans first, balancing prose with technical detail where it sharpens meaning. -Read before you write: find the document, read it with get_document, and read a block in full only when you are about to edit its inner structure. Documents have branches, and every read or write of content names one by branch_id: list_documents carries each document's default_branch_id, a search hit carries the branch_id it was found on, and get_document lists every branch of a document with its id. Write tools apply immediately, and a protected branch is read-only, so write to another branch of the document. Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads. The data-source tools are read-only; discover metric, label and table names with the metadata tools instead of guessing them, and run a query with the chart_type you intend before writing a metric block, so you know it renders. +Read before you write: find the document, read it with get_document, and read a block in full only when you are about to edit its inner structure. Documents have branches, and every read or write of content names one by branch_id: list_documents carries each document's default_branch_id, a search hit carries the branch_id it was found on, and get_document lists every branch of a document with its id. Write tools apply immediately, and a protected branch is read-only, so write to another branch of the document. Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads. Hooks are freshness watchers on a branch or on one of its blocks; list_hooks shows them with their ids, and create_hook takes the branch_id like the content tools and a block_uid from get_document to anchor to a block. The data-source tools are read-only; discover metric, label and table names with the metadata tools instead of guessing them, and run a query with the chart_type you intend before writing a metric block, so you know it renders. ` diff --git a/server/core/internal/assistant/prompt_test.go b/server/core/internal/assistant/prompt_test.go index ff43367b..8815909a 100644 --- a/server/core/internal/assistant/prompt_test.go +++ b/server/core/internal/assistant/prompt_test.go @@ -35,6 +35,7 @@ func Test_buildSystemPrompt(t *testing.T) { // documents have branches, and the prompt says so. assert.Contains(t, got, "Documents have branches") assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id") + assert.Contains(t, got, "create_hook takes the branch_id like the content tools") // width is the one metric enum the prompt is still the only source // for: attrs is a single field shared by every block type, and @@ -106,6 +107,7 @@ func Test_MCPInstructions(t *testing.T) { // an MCP client learns about branches and the protected rule here. assert.Contains(t, got, "Documents have branches") assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id") + assert.Contains(t, got, "create_hook takes the branch_id like the content tools") assert.Contains(t, got, "protected branch is read-only") // the persona and its confirmation flow are the chat surface's; diff --git a/server/core/internal/assistant/tools/0mock_db_test.go b/server/core/internal/assistant/tools/0mock_db_test.go index 434d5e4d..99bcf0b9 100644 --- a/server/core/internal/assistant/tools/0mock_db_test.go +++ b/server/core/internal/assistant/tools/0mock_db_test.go @@ -10,6 +10,7 @@ import ( "github.com/guregu/null/v5" "github.com/oxynote/oxynote/server/core/internal/datasource" "github.com/oxynote/oxynote/server/core/internal/document" + hookCore "github.com/oxynote/oxynote/server/core/internal/document/hook" "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/rs/xid" ) @@ -101,6 +102,16 @@ type DBMock struct { UnassignBranchTagFunc func(ctx context.Context, organizationID string, documentID xid.ID, branchID xid.ID, tagID xid.ID) error // UpdateTagTreeFunc mocks the UpdateTagTree method. UpdateTagTreeFunc func(ctx context.Context, tree tag.Summaries, organizationID string) error + // FetchDocumentHooksByBranchIDFunc mocks the FetchDocumentHooksByBranchID method. + FetchDocumentHooksByBranchIDFunc func(ctx context.Context, branchID xid.ID, organizationID string) ([]hookCore.Hook, error) + // FetchDocumentHookFunc mocks the FetchDocumentHook method. + FetchDocumentHookFunc func(ctx context.Context, id xid.ID, organizationID string) (*hookCore.Hook, error) + // InsertDocumentHookFunc mocks the InsertDocumentHook method. + InsertDocumentHookFunc func(ctx context.Context, hk hookCore.Hook) error + // UpdateDocumentHookFunc mocks the UpdateDocumentHook method. + UpdateDocumentHookFunc func(ctx context.Context, hk hookCore.Hook) error + // DeleteDocumentHookFunc mocks the DeleteDocumentHook method. + DeleteDocumentHookFunc func(ctx context.Context, id xid.ID) error // calls tracks calls to the methods. calls struct { @@ -285,6 +296,45 @@ type DBMock struct { // OrganizationID is the organizationID argument value. OrganizationID string } + // FetchDocumentHooksByBranchID holds details about calls to the FetchDocumentHooksByBranchID method. + FetchDocumentHooksByBranchID []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // BranchID is the branchID argument value. + BranchID xid.ID + // OrganizationID is the organizationID argument value. + OrganizationID string + } + // FetchDocumentHook holds details about calls to the FetchDocumentHook method. + FetchDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID xid.ID + // OrganizationID is the organizationID argument value. + OrganizationID string + } + // InsertDocumentHook holds details about calls to the InsertDocumentHook method. + InsertDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Hk is the hk argument value. + Hk hookCore.Hook + } + // UpdateDocumentHook holds details about calls to the UpdateDocumentHook method. + UpdateDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Hk is the hk argument value. + Hk hookCore.Hook + } + // DeleteDocumentHook holds details about calls to the DeleteDocumentHook method. + DeleteDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID xid.ID + } } lockBeginTx sync.RWMutex lockCheckDocumentCycle sync.RWMutex @@ -305,6 +355,11 @@ type DBMock struct { lockAssignBranchTag sync.RWMutex lockUnassignBranchTag sync.RWMutex lockUpdateTagTree sync.RWMutex + lockFetchDocumentHooksByBranchID sync.RWMutex + lockFetchDocumentHook sync.RWMutex + lockInsertDocumentHook sync.RWMutex + lockUpdateDocumentHook sync.RWMutex + lockDeleteDocumentHook sync.RWMutex } // BeginTx calls BeginTxFunc. @@ -1153,3 +1208,208 @@ func (mock *DBMock) UpdateTagTreeCalls() []struct { mock.lockUpdateTagTree.RUnlock() return calls } + +// FetchDocumentHooksByBranchID calls FetchDocumentHooksByBranchIDFunc. +func (mock *DBMock) FetchDocumentHooksByBranchID(ctx context.Context, branchID xid.ID, organizationID string) ([]hookCore.Hook, error) { + callInfo := struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string + }{ + Ctx: ctx, + BranchID: branchID, + OrganizationID: organizationID, + } + mock.lockFetchDocumentHooksByBranchID.Lock() + mock.calls.FetchDocumentHooksByBranchID = append(mock.calls.FetchDocumentHooksByBranchID, callInfo) + mock.lockFetchDocumentHooksByBranchID.Unlock() + if mock.FetchDocumentHooksByBranchIDFunc == nil { + var ( + hooksOut []hookCore.Hook + errOut error + ) + return hooksOut, errOut + } + return mock.FetchDocumentHooksByBranchIDFunc(ctx, branchID, organizationID) +} + +// FetchDocumentHooksByBranchIDCalls gets all the calls that were made to FetchDocumentHooksByBranchID. +// Check the length with: +// +// len(mockedDB.FetchDocumentHooksByBranchIDCalls()) +func (mock *DBMock) FetchDocumentHooksByBranchIDCalls() []struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string +} { + var calls []struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string + } + mock.lockFetchDocumentHooksByBranchID.RLock() + calls = mock.calls.FetchDocumentHooksByBranchID + mock.lockFetchDocumentHooksByBranchID.RUnlock() + return calls +} + +// FetchDocumentHook calls FetchDocumentHookFunc. +func (mock *DBMock) FetchDocumentHook(ctx context.Context, id xid.ID, organizationID string) (*hookCore.Hook, error) { + callInfo := struct { + Ctx context.Context + ID xid.ID + OrganizationID string + }{ + Ctx: ctx, + ID: id, + OrganizationID: organizationID, + } + mock.lockFetchDocumentHook.Lock() + mock.calls.FetchDocumentHook = append(mock.calls.FetchDocumentHook, callInfo) + mock.lockFetchDocumentHook.Unlock() + if mock.FetchDocumentHookFunc == nil { + var ( + hookOut *hookCore.Hook + errOut error + ) + return hookOut, errOut + } + return mock.FetchDocumentHookFunc(ctx, id, organizationID) +} + +// FetchDocumentHookCalls gets all the calls that were made to FetchDocumentHook. +// Check the length with: +// +// len(mockedDB.FetchDocumentHookCalls()) +func (mock *DBMock) FetchDocumentHookCalls() []struct { + Ctx context.Context + ID xid.ID + OrganizationID string +} { + var calls []struct { + Ctx context.Context + ID xid.ID + OrganizationID string + } + mock.lockFetchDocumentHook.RLock() + calls = mock.calls.FetchDocumentHook + mock.lockFetchDocumentHook.RUnlock() + return calls +} + +// InsertDocumentHook calls InsertDocumentHookFunc. +func (mock *DBMock) InsertDocumentHook(ctx context.Context, hk hookCore.Hook) error { + callInfo := struct { + Ctx context.Context + Hk hookCore.Hook + }{ + Ctx: ctx, + Hk: hk, + } + mock.lockInsertDocumentHook.Lock() + mock.calls.InsertDocumentHook = append(mock.calls.InsertDocumentHook, callInfo) + mock.lockInsertDocumentHook.Unlock() + if mock.InsertDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.InsertDocumentHookFunc(ctx, hk) +} + +// InsertDocumentHookCalls gets all the calls that were made to InsertDocumentHook. +// Check the length with: +// +// len(mockedDB.InsertDocumentHookCalls()) +func (mock *DBMock) InsertDocumentHookCalls() []struct { + Ctx context.Context + Hk hookCore.Hook +} { + var calls []struct { + Ctx context.Context + Hk hookCore.Hook + } + mock.lockInsertDocumentHook.RLock() + calls = mock.calls.InsertDocumentHook + mock.lockInsertDocumentHook.RUnlock() + return calls +} + +// UpdateDocumentHook calls UpdateDocumentHookFunc. +func (mock *DBMock) UpdateDocumentHook(ctx context.Context, hk hookCore.Hook) error { + callInfo := struct { + Ctx context.Context + Hk hookCore.Hook + }{ + Ctx: ctx, + Hk: hk, + } + mock.lockUpdateDocumentHook.Lock() + mock.calls.UpdateDocumentHook = append(mock.calls.UpdateDocumentHook, callInfo) + mock.lockUpdateDocumentHook.Unlock() + if mock.UpdateDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.UpdateDocumentHookFunc(ctx, hk) +} + +// UpdateDocumentHookCalls gets all the calls that were made to UpdateDocumentHook. +// Check the length with: +// +// len(mockedDB.UpdateDocumentHookCalls()) +func (mock *DBMock) UpdateDocumentHookCalls() []struct { + Ctx context.Context + Hk hookCore.Hook +} { + var calls []struct { + Ctx context.Context + Hk hookCore.Hook + } + mock.lockUpdateDocumentHook.RLock() + calls = mock.calls.UpdateDocumentHook + mock.lockUpdateDocumentHook.RUnlock() + return calls +} + +// DeleteDocumentHook calls DeleteDocumentHookFunc. +func (mock *DBMock) DeleteDocumentHook(ctx context.Context, id xid.ID) error { + callInfo := struct { + Ctx context.Context + ID xid.ID + }{ + Ctx: ctx, + ID: id, + } + mock.lockDeleteDocumentHook.Lock() + mock.calls.DeleteDocumentHook = append(mock.calls.DeleteDocumentHook, callInfo) + mock.lockDeleteDocumentHook.Unlock() + if mock.DeleteDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.DeleteDocumentHookFunc(ctx, id) +} + +// DeleteDocumentHookCalls gets all the calls that were made to DeleteDocumentHook. +// Check the length with: +// +// len(mockedDB.DeleteDocumentHookCalls()) +func (mock *DBMock) DeleteDocumentHookCalls() []struct { + Ctx context.Context + ID xid.ID +} { + var calls []struct { + Ctx context.Context + ID xid.ID + } + mock.lockDeleteDocumentHook.RLock() + calls = mock.calls.DeleteDocumentHook + mock.lockDeleteDocumentHook.RUnlock() + return calls +} diff --git a/server/core/internal/assistant/tools/0mock_hook_notifier_test.go b/server/core/internal/assistant/tools/0mock_hook_notifier_test.go new file mode 100644 index 00000000..dcd3fbe5 --- /dev/null +++ b/server/core/internal/assistant/tools/0mock_hook_notifier_test.go @@ -0,0 +1,89 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package tools + +import ( + "sync" + + "github.com/guregu/null/v5" + "github.com/rs/xid" +) + +// Ensure, that HookNotifierMock does implement HookNotifier. +// If this is not the case, regenerate this file with moq. +var _ HookNotifier = &HookNotifierMock{} + +// HookNotifierMock is a mock implementation of HookNotifier. +// +// func TestSomethingThatUsesHookNotifier(t *testing.T) { +// +// // make and configure a mocked HookNotifier +// mockedHookNotifier := &HookNotifierMock{ +// NotifyHooksChangeFunc: func(organizationID string, documentID null.Value[xid.ID], branchID null.Value[xid.ID]) { +// panic("mock out the NotifyHooksChange method") +// }, +// } +// +// // use mockedHookNotifier in code that requires HookNotifier +// // and then make assertions. +// +// } +type HookNotifierMock struct { + // NotifyHooksChangeFunc mocks the NotifyHooksChange method. + NotifyHooksChangeFunc func(organizationID string, documentID null.Value[xid.ID], branchID null.Value[xid.ID]) + + // calls tracks calls to the methods. + calls struct { + // NotifyHooksChange holds details about calls to the NotifyHooksChange method. + NotifyHooksChange []struct { + // OrganizationID is the organizationID argument value. + OrganizationID string + // DocumentID is the documentID argument value. + DocumentID null.Value[xid.ID] + // BranchID is the branchID argument value. + BranchID null.Value[xid.ID] + } + } + lockNotifyHooksChange sync.RWMutex +} + +// NotifyHooksChange calls NotifyHooksChangeFunc. +func (mock *HookNotifierMock) NotifyHooksChange(organizationID string, documentID null.Value[xid.ID], branchID null.Value[xid.ID]) { + callInfo := struct { + OrganizationID string + DocumentID null.Value[xid.ID] + BranchID null.Value[xid.ID] + }{ + OrganizationID: organizationID, + DocumentID: documentID, + BranchID: branchID, + } + mock.lockNotifyHooksChange.Lock() + mock.calls.NotifyHooksChange = append(mock.calls.NotifyHooksChange, callInfo) + mock.lockNotifyHooksChange.Unlock() + if mock.NotifyHooksChangeFunc == nil { + return + } + mock.NotifyHooksChangeFunc(organizationID, documentID, branchID) +} + +// NotifyHooksChangeCalls gets all the calls that were made to NotifyHooksChange. +// Check the length with: +// +// len(mockedHookNotifier.NotifyHooksChangeCalls()) +func (mock *HookNotifierMock) NotifyHooksChangeCalls() []struct { + OrganizationID string + DocumentID null.Value[xid.ID] + BranchID null.Value[xid.ID] +} { + var calls []struct { + OrganizationID string + DocumentID null.Value[xid.ID] + BranchID null.Value[xid.ID] + } + mock.lockNotifyHooksChange.RLock() + calls = mock.calls.NotifyHooksChange + mock.lockNotifyHooksChange.RUnlock() + return calls +} diff --git a/server/core/internal/assistant/tools/_mock/db.go b/server/core/internal/assistant/tools/_mock/db.go index 2180c894..6910bbbb 100644 --- a/server/core/internal/assistant/tools/_mock/db.go +++ b/server/core/internal/assistant/tools/_mock/db.go @@ -11,6 +11,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/assistant/tools" "github.com/oxynote/oxynote/server/core/internal/datasource" "github.com/oxynote/oxynote/server/core/internal/document" + hookCore "github.com/oxynote/oxynote/server/core/internal/document/hook" "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/rs/xid" ) @@ -102,6 +103,16 @@ type DB struct { UnassignBranchTagFunc func(ctx context.Context, organizationID string, documentID xid.ID, branchID xid.ID, tagID xid.ID) error // UpdateTagTreeFunc mocks the UpdateTagTree method. UpdateTagTreeFunc func(ctx context.Context, tree tag.Summaries, organizationID string) error + // FetchDocumentHooksByBranchIDFunc mocks the FetchDocumentHooksByBranchID method. + FetchDocumentHooksByBranchIDFunc func(ctx context.Context, branchID xid.ID, organizationID string) ([]hookCore.Hook, error) + // FetchDocumentHookFunc mocks the FetchDocumentHook method. + FetchDocumentHookFunc func(ctx context.Context, id xid.ID, organizationID string) (*hookCore.Hook, error) + // InsertDocumentHookFunc mocks the InsertDocumentHook method. + InsertDocumentHookFunc func(ctx context.Context, hk hookCore.Hook) error + // UpdateDocumentHookFunc mocks the UpdateDocumentHook method. + UpdateDocumentHookFunc func(ctx context.Context, hk hookCore.Hook) error + // DeleteDocumentHookFunc mocks the DeleteDocumentHook method. + DeleteDocumentHookFunc func(ctx context.Context, id xid.ID) error // calls tracks calls to the methods. calls struct { @@ -286,6 +297,45 @@ type DB struct { // OrganizationID is the organizationID argument value. OrganizationID string } + // FetchDocumentHooksByBranchID holds details about calls to the FetchDocumentHooksByBranchID method. + FetchDocumentHooksByBranchID []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // BranchID is the branchID argument value. + BranchID xid.ID + // OrganizationID is the organizationID argument value. + OrganizationID string + } + // FetchDocumentHook holds details about calls to the FetchDocumentHook method. + FetchDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID xid.ID + // OrganizationID is the organizationID argument value. + OrganizationID string + } + // InsertDocumentHook holds details about calls to the InsertDocumentHook method. + InsertDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Hk is the hk argument value. + Hk hookCore.Hook + } + // UpdateDocumentHook holds details about calls to the UpdateDocumentHook method. + UpdateDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Hk is the hk argument value. + Hk hookCore.Hook + } + // DeleteDocumentHook holds details about calls to the DeleteDocumentHook method. + DeleteDocumentHook []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID xid.ID + } } lockBeginTx sync.RWMutex lockCheckDocumentCycle sync.RWMutex @@ -306,6 +356,11 @@ type DB struct { lockAssignBranchTag sync.RWMutex lockUnassignBranchTag sync.RWMutex lockUpdateTagTree sync.RWMutex + lockFetchDocumentHooksByBranchID sync.RWMutex + lockFetchDocumentHook sync.RWMutex + lockInsertDocumentHook sync.RWMutex + lockUpdateDocumentHook sync.RWMutex + lockDeleteDocumentHook sync.RWMutex } // BeginTx calls BeginTxFunc. @@ -1154,3 +1209,208 @@ func (mock *DB) UpdateTagTreeCalls() []struct { mock.lockUpdateTagTree.RUnlock() return calls } + +// FetchDocumentHooksByBranchID calls FetchDocumentHooksByBranchIDFunc. +func (mock *DB) FetchDocumentHooksByBranchID(ctx context.Context, branchID xid.ID, organizationID string) ([]hookCore.Hook, error) { + callInfo := struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string + }{ + Ctx: ctx, + BranchID: branchID, + OrganizationID: organizationID, + } + mock.lockFetchDocumentHooksByBranchID.Lock() + mock.calls.FetchDocumentHooksByBranchID = append(mock.calls.FetchDocumentHooksByBranchID, callInfo) + mock.lockFetchDocumentHooksByBranchID.Unlock() + if mock.FetchDocumentHooksByBranchIDFunc == nil { + var ( + hooksOut []hookCore.Hook + errOut error + ) + return hooksOut, errOut + } + return mock.FetchDocumentHooksByBranchIDFunc(ctx, branchID, organizationID) +} + +// FetchDocumentHooksByBranchIDCalls gets all the calls that were made to FetchDocumentHooksByBranchID. +// Check the length with: +// +// len(mockedDB.FetchDocumentHooksByBranchIDCalls()) +func (mock *DB) FetchDocumentHooksByBranchIDCalls() []struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string +} { + var calls []struct { + Ctx context.Context + BranchID xid.ID + OrganizationID string + } + mock.lockFetchDocumentHooksByBranchID.RLock() + calls = mock.calls.FetchDocumentHooksByBranchID + mock.lockFetchDocumentHooksByBranchID.RUnlock() + return calls +} + +// FetchDocumentHook calls FetchDocumentHookFunc. +func (mock *DB) FetchDocumentHook(ctx context.Context, id xid.ID, organizationID string) (*hookCore.Hook, error) { + callInfo := struct { + Ctx context.Context + ID xid.ID + OrganizationID string + }{ + Ctx: ctx, + ID: id, + OrganizationID: organizationID, + } + mock.lockFetchDocumentHook.Lock() + mock.calls.FetchDocumentHook = append(mock.calls.FetchDocumentHook, callInfo) + mock.lockFetchDocumentHook.Unlock() + if mock.FetchDocumentHookFunc == nil { + var ( + hookOut *hookCore.Hook + errOut error + ) + return hookOut, errOut + } + return mock.FetchDocumentHookFunc(ctx, id, organizationID) +} + +// FetchDocumentHookCalls gets all the calls that were made to FetchDocumentHook. +// Check the length with: +// +// len(mockedDB.FetchDocumentHookCalls()) +func (mock *DB) FetchDocumentHookCalls() []struct { + Ctx context.Context + ID xid.ID + OrganizationID string +} { + var calls []struct { + Ctx context.Context + ID xid.ID + OrganizationID string + } + mock.lockFetchDocumentHook.RLock() + calls = mock.calls.FetchDocumentHook + mock.lockFetchDocumentHook.RUnlock() + return calls +} + +// InsertDocumentHook calls InsertDocumentHookFunc. +func (mock *DB) InsertDocumentHook(ctx context.Context, hk hookCore.Hook) error { + callInfo := struct { + Ctx context.Context + Hk hookCore.Hook + }{ + Ctx: ctx, + Hk: hk, + } + mock.lockInsertDocumentHook.Lock() + mock.calls.InsertDocumentHook = append(mock.calls.InsertDocumentHook, callInfo) + mock.lockInsertDocumentHook.Unlock() + if mock.InsertDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.InsertDocumentHookFunc(ctx, hk) +} + +// InsertDocumentHookCalls gets all the calls that were made to InsertDocumentHook. +// Check the length with: +// +// len(mockedDB.InsertDocumentHookCalls()) +func (mock *DB) InsertDocumentHookCalls() []struct { + Ctx context.Context + Hk hookCore.Hook +} { + var calls []struct { + Ctx context.Context + Hk hookCore.Hook + } + mock.lockInsertDocumentHook.RLock() + calls = mock.calls.InsertDocumentHook + mock.lockInsertDocumentHook.RUnlock() + return calls +} + +// UpdateDocumentHook calls UpdateDocumentHookFunc. +func (mock *DB) UpdateDocumentHook(ctx context.Context, hk hookCore.Hook) error { + callInfo := struct { + Ctx context.Context + Hk hookCore.Hook + }{ + Ctx: ctx, + Hk: hk, + } + mock.lockUpdateDocumentHook.Lock() + mock.calls.UpdateDocumentHook = append(mock.calls.UpdateDocumentHook, callInfo) + mock.lockUpdateDocumentHook.Unlock() + if mock.UpdateDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.UpdateDocumentHookFunc(ctx, hk) +} + +// UpdateDocumentHookCalls gets all the calls that were made to UpdateDocumentHook. +// Check the length with: +// +// len(mockedDB.UpdateDocumentHookCalls()) +func (mock *DB) UpdateDocumentHookCalls() []struct { + Ctx context.Context + Hk hookCore.Hook +} { + var calls []struct { + Ctx context.Context + Hk hookCore.Hook + } + mock.lockUpdateDocumentHook.RLock() + calls = mock.calls.UpdateDocumentHook + mock.lockUpdateDocumentHook.RUnlock() + return calls +} + +// DeleteDocumentHook calls DeleteDocumentHookFunc. +func (mock *DB) DeleteDocumentHook(ctx context.Context, id xid.ID) error { + callInfo := struct { + Ctx context.Context + ID xid.ID + }{ + Ctx: ctx, + ID: id, + } + mock.lockDeleteDocumentHook.Lock() + mock.calls.DeleteDocumentHook = append(mock.calls.DeleteDocumentHook, callInfo) + mock.lockDeleteDocumentHook.Unlock() + if mock.DeleteDocumentHookFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.DeleteDocumentHookFunc(ctx, id) +} + +// DeleteDocumentHookCalls gets all the calls that were made to DeleteDocumentHook. +// Check the length with: +// +// len(mockedDB.DeleteDocumentHookCalls()) +func (mock *DB) DeleteDocumentHookCalls() []struct { + Ctx context.Context + ID xid.ID +} { + var calls []struct { + Ctx context.Context + ID xid.ID + } + mock.lockDeleteDocumentHook.RLock() + calls = mock.calls.DeleteDocumentHook + mock.lockDeleteDocumentHook.RUnlock() + return calls +} diff --git a/server/core/internal/assistant/tools/hook.go b/server/core/internal/assistant/tools/hook.go new file mode 100644 index 00000000..0b329241 --- /dev/null +++ b/server/core/internal/assistant/tools/hook.go @@ -0,0 +1,774 @@ +package tools + +import ( + "bytes" + "encoding/json" + jsonv2 "encoding/json/v2" + "errors" + "fmt" + "time" + + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" + "github.com/rs/xid" + "github.com/shopspring/decimal" +) + +// Hook argument names and descriptions. +const ( + // _keyHookID is the shared hook-id property name. + _keyHookID = "hook_id" + + // _descHookID describes the hook-id property every hook write takes. + _descHookID = "The hook id, as list_hooks reports it." + + // _keySchedule is the scheduled-reminder timestamp property name. + _keySchedule = "schedule" + + // _keyRepository is the github-tracking repository property name. + _keyRepository = "repository" + + // _keyBranch is the github-tracking branch property name. + _keyBranch = "branch" + + // _keyPaths is the github-tracking paths property name. + _keyPaths = "paths" + + // _keyURL is the url-watcher address property name. + _keyURL = "url" + + // _keyImage is the container-image-watcher image property name. + _keyImage = "image" + + // _durationCustom is the duration a scheduled reminder stores when + // its schedule is a concrete date rather than a preset, so the + // editor labels it as such. + _durationCustom = "custom" +) + +// _keySettings is the settings property name create_hook and +// update_hook take. +const _keySettings = "settings" + +// hookSettingsSchema builds the settings argument's schema for the hook +// writes. Which fields apply is decided by the hook type, the way a +// block's type decides its fields, and that rule is checked server-side. +func hookSettingsSchema() map[string]any { + return map[string]any{ + _keyType: _typeObject, + _keyDescription: "The hook's settings. Which fields apply depends on the hook type; supply only that type's own.", + "properties": map[string]any{ + _keySchedule: stringProp("scheduled-reminder: the RFC 3339 timestamp at which the reminder is due."), + _keyRepository: stringProp("github-tracking: the repository to follow, as owner/name."), + _keyBranch: stringProp("github-tracking: the repository branch to follow."), + _keyPaths: map[string]any{ + _keyType: _typeArray, + _keyDescription: "github-tracking: the repository paths whose changes mark the document stale; at least one.", + _keyItems: map[string]any{_keyType: _typeString}, + }, + _keyURL: stringProp("url-watcher: the web address to watch for changes."), + _keyImage: stringProp("container-image-watcher: the container image reference to watch for new digests."), + }, + } +} + +// decodeHookSettings reads the settings the model supplied as the +// processor settings of the hook type: each type decodes into its own +// struct, requires its own fields, and refuses a field of another type +// naming the type it belongs to. +func decodeHookSettings(tp hook.Type, raw json.RawMessage) (processor.Settings, error) { + var v any + + switch tp { + case hook.TypeScheduledReminder: + var sr processor.ScheduledReminder + + if err := decodeSettings(raw, &sr); err != nil { + return nil, err + } + + if sr.Schedule.IsZero() { + return nil, errRequired(_keySchedule) + } + + // a concrete date is what the editor calls a custom duration. + sr.Scale = processor.ScaleTypeLinear + sr.Duration = null.StringFrom(_durationCustom) + v = sr + case hook.TypeGithubTracking: + var gt processor.GithubTracking + + if err := decodeSettings(raw, >); err != nil { + return nil, err + } + + switch { + case gt.Repository == "": + return nil, errRequired(_keyRepository) + case gt.Branch == "": + return nil, errRequired(_keyBranch) + case len(gt.Paths) == 0: + return nil, errRequired(_keyPaths) + } + + v = gt + case hook.TypeURLWatcher: + var uw processor.URLWatcher + + if err := decodeSettings(raw, &uw); err != nil { + return nil, err + } + + if uw.URL == "" { + return nil, errRequired(_keyURL) + } + + v = uw + case hook.TypeContainerImageWatcher: + var ciw processor.ContainerImageWatcher + + if err := decodeSettings(raw, &ciw); err != nil { + return nil, err + } + + if ciw.Image == "" { + return nil, errRequired(_keyImage) + } + + v = ciw + default: + return nil, fmt.Errorf("type %q: %w", tp, hook.ErrInvalidType) + } + + out, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshalling settings: %w", err) + } + + return processor.Settings(out), nil +} + +// decodeSettings unmarshals the settings into the processor struct, +// refusing a field the struct does not have. An absent payload is an +// empty one, so the type's own check reports what is missing. +func decodeSettings(raw json.RawMessage, dst any) error { + if len(bytes.TrimSpace(raw)) == 0 { + raw = json.RawMessage("{}") + } + + err := jsonv2.Unmarshal(raw, dst, jsonv2.RejectUnknownMembers(true)) + if err == nil { + return nil + } + + var serr *jsonv2.SemanticError + + if errors.As(err, &serr) && errors.Is(serr.Err, jsonv2.ErrUnknownName) { + key := serr.JSONPointer.LastToken() + + if owner, ok := hookSettingOwner(key); ok { + return fmt.Errorf("%s applies to %s only", key, owner) + } + + return fmt.Errorf("%s is not a hook setting", key) + } + + return fmt.Errorf("invalid settings: %w", err) +} + +// hookSettingOwner names the hook type a setting belongs to. +func hookSettingOwner(key string) (hook.Type, bool) { + switch key { + case _keySchedule: + return hook.TypeScheduledReminder, true + case _keyRepository, _keyBranch, _keyPaths: + return hook.TypeGithubTracking, true + case _keyURL: + return hook.TypeURLWatcher, true + case _keyImage: + return hook.TypeContainerImageWatcher, true + default: + return "", false + } +} + +// _descHookTypes explains the four hook types once, for the descriptions +// that list them. +const _descHookTypes = "scheduled-reminder takes schedule; github-tracking takes repository, branch and paths; url-watcher takes url; container-image-watcher takes image. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing." + +// listHooksArgs is what list_hooks is called with. +type listHooksArgs struct { + docTarget +} + +// Validate checks the arguments name a branch. +func (a listHooksArgs) Validate() error { + return a.validate() +} + +// listHooks returns every hook on one branch of a document. +type listHooks struct { + plainSummary + plainTraits + plainTitle +} + +// Info returns the tool's model-facing description. +func (listHooks) Info() Info { + return Info{ + Name: NameListHooks, + Description: "List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.", + Properties: map[string]any{ + _keyDocumentID: stringProp(_descDocumentID), + _keyBranchID: stringProp("The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists."), + }, + Required: []string{_keyDocumentID, _keyBranchID}, + } +} + +// Execute lists the branch's hooks. +func (listHooks) Execute(inp Input) (string, error) { + var in listHooksArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + hooks, err := inp.FetchHooks(in.DocumentID, in.BranchID) + if err != nil { + return "", fmt.Errorf("list_hooks: %w", err) + } + + out := hookListResult{Hooks: make([]hookRow, 0, len(hooks))} + + for i := range hooks { + out.Hooks = append(out.Hooks, newHookRow(&hooks[i])) + } + + return result(out) +} + +// hookListResult is what list_hooks returns. +type hookListResult struct { + // Hooks is every hook on the branch. + Hooks []hookRow `json:"hooks"` +} + +// hookRow describes one hook to the model. +type hookRow struct { + // ID is the hook id, which is what a tool's hook_id takes. + ID xid.ID `json:"id"` + + // Type is the hook type. + Type hook.Type `json:"type"` + + // BlockUID is the block the hook is anchored to, or null for a hook + // on the document as a whole. + BlockUID null.String `json:"block_uid"` + + // Settings is the hook's configuration, in its type's own shape. + Settings json.RawMessage `json:"settings"` + + // Score is the hook's freshness score, from 100 down to 0. + Score decimal.Decimal `json:"score"` + + // State is what the hook has recorded so far, in its type's own + // shape. + State json.RawMessage `json:"state"` + + // CreatedAt is when the hook was created. + CreatedAt time.Time `json:"created_at"` + + // UpdatedAt is when the hook's settings last changed, or null. + UpdatedAt null.Time `json:"updated_at"` +} + +// newHookRow converts a hook into the model's shape. +func newHookRow(hk *hook.Hook) hookRow { + return hookRow{ + ID: hk.ID, + Type: hk.Type, + BlockUID: hk.BlockID, + Settings: json.RawMessage(hk.Settings), + Score: hk.Score, + State: json.RawMessage(hk.State), + CreatedAt: hk.CreatedAt, + UpdatedAt: hk.UpdatedAt, + } +} + +// createHookArgs is what create_hook is called with. +type createHookArgs struct { + docTarget + + // Type is the hook type. + Type hook.Type `json:"type"` + + // BlockUID anchors the hook to a block; empty puts it on the + // document as a whole. + BlockUID string `json:"block_uid"` + + // Settings is the hook's settings in the shape its type takes. + Settings json.RawMessage `json:"settings"` +} + +// Validate checks the arguments name a branch and a hook type, and +// carry exactly that type's settings. +func (a createHookArgs) Validate() error { + if err := a.validate(); err != nil { + return err + } + + if a.Type == "" { + return errRequired(_keyType) + } + + if err := a.Type.Validate(); err != nil { + return fmt.Errorf("type %q: %w", a.Type, err) + } + + _, err := decodeHookSettings(a.Type, a.Settings) + + return err +} + +// createHook adds a hook to one branch of a document. +type createHook struct{} + +// Info returns the tool's model-facing description. +func (createHook) Info() Info { + return Info{ + Name: NameCreateHook, + Description: "Add a freshness hook to one branch of a document and return it as list_hooks would. Supply type and, in settings, only that type's fields: " + _descHookTypes + " block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.", + Properties: map[string]any{ + _keyDocumentID: stringProp(_descDocumentID), + _keyBranchID: stringProp("The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists."), + _keyType: map[string]any{ + _keyType: _typeString, + _keyDescription: "The hook type, which decides the fields settings takes.", + _keyEnum: hookTypes(), + }, + _keyBlockUID: stringProp("Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole."), + _keySettings: hookSettingsSchema(), + }, + Required: []string{_keyDocumentID, _keyBranchID, _keyType, _keySettings}, + } +} + +// Traits reports a write. +func (createHook) Traits() Traits { + return Traits{Write: true} +} + +// Title announces which document gets the hook. +func (createHook) Title(inp DescribeInput) (string, error) { + var in createHookArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + doc, err := inp.FetchBranch(in.DocumentID, in.BranchID) + if err != nil { + return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err) + } + + return "Adding a hook to " + docLabel(doc), nil +} + +// Summary names the hook type and where it goes. +func (createHook) Summary(inp DescribeInput) (ActionSummary, error) { + var in createHookArgs + + if err := inp.Decode(&in); err != nil { + return ActionSummary{}, err + } + + doc, err := inp.FetchBranch(in.DocumentID, in.BranchID) + if err != nil { + return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameCreateHook, err) + } + + return ActionSummary{ + Tool: NameCreateHook, + DocumentID: doc.ID, + DocumentName: doc.DocumentName, + Summary: "Add a " + hookLabel(in.Type, null.NewString(in.BlockUID, in.BlockUID != ""), doc), + }, nil +} + +// Execute creates the hook. +func (createHook) Execute(inp Input) (string, error) { + var in createHookArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + // Validate already accepted the settings, so this cannot fail. + settings, err := decodeHookSettings(in.Type, in.Settings) + if err != nil { + return "", fmt.Errorf("create_hook: %w", err) + } + + hk, err := inp.CreateHook(in.DocumentID, in.BranchID, in.BlockUID, in.Type, settings) + if err != nil { + return "", fmt.Errorf("create_hook: %w", err) + } + + return result(newHookRow(hk)) +} + +// updateHookArgs is what update_hook is called with. +type updateHookArgs struct { + hookRefArgs + + // Settings is the hook's new settings in the shape its type takes. + Settings json.RawMessage `json:"settings"` +} + +// Validate checks the arguments name a hook and carry settings. The +// settings are checked against the hook's type once it is fetched, since +// the type is the hook's own and not an argument. +func (a updateHookArgs) Validate() error { + if err := a.hookRefArgs.Validate(); err != nil { + return err + } + + if len(bytes.TrimSpace(a.Settings)) == 0 { + return errRequired(_keySettings) + } + + return nil +} + +// updateHook replaces a hook's settings. +type updateHook struct{} + +// Info returns the tool's model-facing description. +func (updateHook) Info() Info { + return Info{ + Name: NameUpdateHook, + Description: "Replace the settings of a hook and reset its score and state, returning it as list_hooks would. The hook keeps its type, so give exactly that type's fields in settings: " + _descHookTypes + " To change the type, delete the hook and create another.", + Properties: map[string]any{ + _keyDocumentID: stringProp(_descDocumentID), + _keyHookID: stringProp(_descHookID), + _keySettings: hookSettingsSchema(), + }, + Required: []string{_keyDocumentID, _keyHookID, _keySettings}, + } +} + +// Traits reports a write. +func (updateHook) Traits() Traits { + return Traits{Write: true} +} + +// Title announces which hook is being updated. +func (updateHook) Title(inp DescribeInput) (string, error) { + var in updateHookArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + doc, err := inp.FetchDocument(in.DocumentID) + if err != nil { + return "", fmt.Errorf("%s: fetch document: %w", NameUpdateHook, err) + } + + return "Updating a hook on " + doc.DocumentName, nil +} + +// Summary names the hook and checks the settings fit its type, so the +// user is not asked to approve a call that cannot run. +func (updateHook) Summary(inp DescribeInput) (ActionSummary, error) { + var in updateHookArgs + + if err := inp.Decode(&in); err != nil { + return ActionSummary{}, err + } + + doc, hk, err := describeHook(inp, NameUpdateHook, in.hookRefArgs) + if err != nil { + return ActionSummary{}, err + } + + if _, err := decodeHookSettings(hk.Type, in.Settings); err != nil { + return ActionSummary{}, fmt.Errorf("%s: %w", NameUpdateHook, err) + } + + return ActionSummary{ + Tool: NameUpdateHook, + DocumentID: doc.ID, + DocumentName: doc.DocumentName, + Summary: "Update the " + hookLabel(hk.Type, hk.BlockID, doc), + }, nil +} + +// Execute replaces the settings and resets the hook. +func (updateHook) Execute(inp Input) (string, error) { + var in updateHookArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + hk, err := inp.FetchHook(in.DocumentID, in.HookID) + if err != nil { + return "", fmt.Errorf("update_hook: %w", err) + } + + settings, err := decodeHookSettings(hk.Type, in.Settings) + if err != nil { + return "", fmt.Errorf("update_hook: %w", err) + } + + if err := inp.UpdateHook(hk, settings); err != nil { + return "", fmt.Errorf("update_hook: %w", err) + } + + return result(newHookRow(hk)) +} + +// hookRefArgs is what reset_hook and delete_hook are called with: a +// document and one of its hooks. +type hookRefArgs struct { + // DocumentID names the document the hook is on. + DocumentID xid.ID `json:"document_id"` + + // HookID names the hook. + HookID xid.ID `json:"hook_id"` +} + +// Validate checks the arguments name a document and a hook. +func (a hookRefArgs) Validate() error { + if a.DocumentID.IsNil() { + return errRequired(_keyDocumentID) + } + + if a.HookID.IsNil() { + return errRequired(_keyHookID) + } + + return nil +} + +// hookRefProps builds the argument schema reset_hook and delete_hook +// share. +func hookRefProps() map[string]any { + return map[string]any{ + _keyDocumentID: stringProp(_descDocumentID), + _keyHookID: stringProp(_descHookID), + } +} + +// resetHook restores a hook's score and state without touching its +// settings. +type resetHook struct{} + +// Info returns the tool's model-facing description. +func (resetHook) Info() Info { + return Info{ + Name: NameResetHook, + Description: "Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.", + Properties: hookRefProps(), + Required: []string{_keyDocumentID, _keyHookID}, + } +} + +// Traits reports a write. +func (resetHook) Traits() Traits { + return Traits{Write: true} +} + +// Title announces which hook is being reset. +func (resetHook) Title(inp DescribeInput) (string, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + doc, err := inp.FetchDocument(in.DocumentID) + if err != nil { + return "", fmt.Errorf("%s: fetch document: %w", NameResetHook, err) + } + + return "Resetting a hook on " + doc.DocumentName, nil +} + +// Summary names the hook being reset. +func (resetHook) Summary(inp DescribeInput) (ActionSummary, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return ActionSummary{}, err + } + + doc, hk, err := describeHook(inp, NameResetHook, in) + if err != nil { + return ActionSummary{}, err + } + + return ActionSummary{ + Tool: NameResetHook, + DocumentID: doc.ID, + DocumentName: doc.DocumentName, + Summary: "Reset the " + hookLabel(hk.Type, hk.BlockID, doc), + }, nil +} + +// Execute resets the hook. +func (resetHook) Execute(inp Input) (string, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + hk, err := inp.FetchHook(in.DocumentID, in.HookID) + if err != nil { + return "", fmt.Errorf("reset_hook: %w", err) + } + + if err := inp.ResetHook(hk); err != nil { + return "", fmt.Errorf("reset_hook: %w", err) + } + + return result(newHookRow(hk)) +} + +// deleteHook removes a hook from its document. +type deleteHook struct{} + +// Info returns the tool's model-facing description. +func (deleteHook) Info() Info { + return Info{ + Name: NameDeleteHook, + Description: "Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.", + Properties: hookRefProps(), + Required: []string{_keyDocumentID, _keyHookID}, + } +} + +// Traits reports a destructive write, which stays outside any "approve +// all" answer. +func (deleteHook) Traits() Traits { + return Traits{Write: true, Destructive: true} +} + +// Title announces which hook is being deleted. +func (deleteHook) Title(inp DescribeInput) (string, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + doc, err := inp.FetchDocument(in.DocumentID) + if err != nil { + return "", fmt.Errorf("%s: fetch document: %w", NameDeleteHook, err) + } + + return "Deleting a hook on " + doc.DocumentName, nil +} + +// Summary names the hook being deleted. +func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return ActionSummary{}, err + } + + doc, hk, err := describeHook(inp, NameDeleteHook, in) + if err != nil { + return ActionSummary{}, err + } + + return ActionSummary{ + Tool: NameDeleteHook, + DocumentID: doc.ID, + DocumentName: doc.DocumentName, + Summary: "Delete the " + hookLabel(hk.Type, hk.BlockID, doc), + }, nil +} + +// Execute deletes the hook. +func (deleteHook) Execute(inp Input) (string, error) { + var in hookRefArgs + + if err := inp.Decode(&in); err != nil { + return "", err + } + + hk, err := inp.FetchHook(in.DocumentID, in.HookID) + if err != nil { + return "", fmt.Errorf("delete_hook: %w", err) + } + + if err := inp.DeleteHook(hk); err != nil { + return "", fmt.Errorf("delete_hook: %w", err) + } + + return result(deletedHookResult{HookID: hk.ID, Deleted: true}) +} + +// deletedHookResult is what delete_hook returns. +type deletedHookResult struct { + // HookID is the hook that was removed. + HookID xid.ID `json:"hook_id"` + + // Deleted confirms the removal happened, so the model reads an + // outcome rather than an empty result. + Deleted bool `json:"deleted"` +} + +// describeHook resolves the hook a description names and the document +// on the branch the hook is on, so the label can name that branch. A +// hook whose branch is gone is named on the document's default one. +func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Document, *hook.Hook, error) { + hk, err := inp.FetchHook(ref.DocumentID, ref.HookID) + if err != nil { + return nil, nil, fmt.Errorf("%s: fetch hook: %w", name, err) + } + + var doc *document.Document + + if hk.BranchID.Valid { + doc, err = inp.FetchBranch(ref.DocumentID, hk.BranchID.V) + } else { + doc, err = inp.FetchDocument(ref.DocumentID) + } + + if err != nil { + return nil, nil, fmt.Errorf("%s: fetch document: %w", name, err) + } + + return doc, hk, nil +} + +// hookLabel names a hook by its type, the document it is on and, when +// anchored, its block, in the words the confirm card uses. +func hookLabel(tp hook.Type, blockUID null.String, doc *document.Document) string { + label := tp.HumanizedString() + " hook on " + docLabel(doc) + + if blockUID.Valid { + label += ", block " + blockUID.String + } + + return label +} + +// hookTypes lists the hook types the schema publishes as an enum. +func hookTypes() []string { + return []string{ + string(hook.TypeScheduledReminder), + string(hook.TypeGithubTracking), + string(hook.TypeURLWatcher), + string(hook.TypeContainerImageWatcher), + } +} diff --git a/server/core/internal/assistant/tools/hook_test.go b/server/core/internal/assistant/tools/hook_test.go new file mode 100644 index 00000000..4d13fcc3 --- /dev/null +++ b/server/core/internal/assistant/tools/hook_test.go @@ -0,0 +1,1087 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/apps/github" + "github.com/oxynote/oxynote/server/core/internal/apps/webchange" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hookDeps builds session wiring over the given DB with a hook notifier +// attached, so a test can assert the broadcast a hook write ends with. +func hookDeps(db *DBMock) (*Deps, *HookNotifierMock) { + d := testDeps(db, nil, nil) + hooks := &HookNotifierMock{} + d.hooks = hooks + + return d, hooks +} + +// stubHookDB answers every document and hook lookup: the branch content +// holds the stub paragraph, the draft branch carries the test hook, and +// every hook write is accepted. +func stubHookDB() *DBMock { + db := stubContentDB(nil) + db.FetchDocumentHooksByBranchIDFunc = func(_ context.Context, branchID xid.ID, _ string) ([]hook.Hook, error) { + if branchID != _stubBranchID { + return []hook.Hook{}, nil + } + + return []hook.Hook{*stubHook()}, nil + } + + return db +} + +// hookArgs renders the document_id and hook_id arguments every hook +// write takes. +func hookArgs(hookID xid.ID) string { + return `"document_id":"` + _testDocID.String() + `","hook_id":"` + hookID.String() + `"` +} + +// _stubScheduleTime is _stubSchedule as the settings carry it. +var _stubScheduleTime = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + +// decodeHookRow reads a hook tool's result back into its row. +func decodeHookRow(t *testing.T, res string) hookRow { + t.Helper() + + var row hookRow + + require.NoError(t, json.Unmarshal([]byte(res), &row)) + + return row +} + +func Test_hookSettingsSchema(t *testing.T) { + t.Parallel() + + got := hookSettingsSchema() + + assert.Equal(t, _typeObject, got[_keyType]) + + // every setting of every type is published, so a client can fill any + // of them without the prose, and every one names a type that owns it. + props, ok := got["properties"].(map[string]any) + require.True(t, ok) + + for _, key := range []string{_keySchedule, _keyRepository, _keyBranch, _keyPaths, _keyURL, _keyImage} { + assert.Contains(t, props, key) + + _, owned := hookSettingOwner(key) + assert.True(t, owned, key) + } +} + +func Test_decodeHookSettings(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Type hook.Type + Raw string + Result string + Err error + }{ + "Unknown type": {Type: "bogus", Raw: `{}`, Err: fmt.Errorf("type %q: %w", "bogus", hook.ErrInvalidType)}, + "Malformed settings": { + Type: hook.TypeScheduledReminder, + Raw: `{`, + Err: assert.AnError, + }, + // the editor labels a linear reminder with a custom duration as + // a date, which is what a tool call sets. + "Scheduled reminder": { + Type: hook.TypeScheduledReminder, + Raw: `{"schedule":"2030-01-01T00:00:00Z"}`, + Result: `{"scale":"linear","duration":"custom","schedule":"2030-01-01T00:00:00Z"}`, + }, + "Scheduled reminder without a schedule": { + Type: hook.TypeScheduledReminder, + Raw: `{}`, + Err: errRequired(_keySchedule), + }, + "Scheduled reminder without settings": { + Type: hook.TypeScheduledReminder, + Err: errRequired(_keySchedule), + }, + "GitHub tracking": { + Type: hook.TypeGithubTracking, + Raw: `{"repository":"o/r","branch":"main","paths":["a","b"]}`, + Result: `{"repository":"o/r","branch":"main","paths":["a","b"]}`, + }, + "GitHub tracking without a repository": { + Type: hook.TypeGithubTracking, + Raw: `{"branch":"main","paths":["a"]}`, + Err: errRequired(_keyRepository), + }, + "GitHub tracking without a branch": { + Type: hook.TypeGithubTracking, + Raw: `{"repository":"o/r","paths":["a"]}`, + Err: errRequired(_keyBranch), + }, + "GitHub tracking without paths": { + Type: hook.TypeGithubTracking, + Raw: `{"repository":"o/r","branch":"main","paths":[]}`, + Err: errRequired(_keyPaths), + }, + "URL watcher": { + Type: hook.TypeURLWatcher, + Raw: `{"url":"https://example.com"}`, + Result: `{"url":"https://example.com"}`, + }, + "URL watcher without a url": { + Type: hook.TypeURLWatcher, + Raw: `{}`, + Err: errRequired(_keyURL), + }, + "Container image watcher": { + Type: hook.TypeContainerImageWatcher, + Raw: `{"image":"nginx:1"}`, + Result: `{"image":"nginx:1"}`, + }, + "Container image watcher without an image": { + Type: hook.TypeContainerImageWatcher, + Raw: `{}`, + Err: errRequired(_keyImage), + }, + // another type's field is refused naming the type it belongs to. + "Scheduled reminder with a url": { + Type: hook.TypeScheduledReminder, + Raw: `{"schedule":"2030-01-01T00:00:00Z","url":"https://example.com"}`, + Err: fmt.Errorf("%s applies to %s only", _keyURL, hook.TypeURLWatcher), + }, + "URL watcher with github paths": { + Type: hook.TypeURLWatcher, + Raw: `{"url":"https://example.com","paths":["a"]}`, + Err: fmt.Errorf("%s applies to %s only", _keyPaths, hook.TypeGithubTracking), + }, + "Container image watcher with a schedule": { + Type: hook.TypeContainerImageWatcher, + Raw: `{"image":"nginx:1","schedule":"2030-01-01T00:00:00Z"}`, + Err: fmt.Errorf("%s applies to %s only", _keySchedule, hook.TypeScheduledReminder), + }, + "Setting of no type": { + Type: hook.TypeURLWatcher, + Raw: `{"url":"https://example.com","colour":"red"}`, + Err: fmt.Errorf("%s is not a hook setting", "colour"), + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + got, err := decodeHookSettings(c.Type, json.RawMessage(c.Raw)) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + assert.Nil(t, got) + return + } + + assert.JSONEq(t, c.Result, string(got)) + }) + } +} + +func Test_decodeSettings(t *testing.T) { + t.Parallel() + + var uw processor.URLWatcher + + // an absent payload decodes as an empty one. + require.NoError(t, decodeSettings(nil, &uw)) + assert.Empty(t, uw.URL) + + require.NoError(t, decodeSettings(json.RawMessage(`{"url":"https://example.com"}`), &uw)) + assert.Equal(t, "https://example.com", uw.URL) + + err := decodeSettings(json.RawMessage(`{"image":"nginx:1"}`), &uw) + assert.Equal(t, fmt.Errorf("%s applies to %s only", _keyImage, hook.TypeContainerImageWatcher), err) + + err = decodeSettings(json.RawMessage(`{"colour":"red"}`), &uw) + assert.Equal(t, fmt.Errorf("%s is not a hook setting", "colour"), err) + + // a value of the wrong shape is reported with its path. + err = decodeSettings(json.RawMessage(`{"url":1}`), &uw) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid settings") + + require.Error(t, decodeSettings(json.RawMessage(`{`), &uw)) +} + +func Test_hookSettingOwner(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Key string + Result hook.Type + Owned bool + }{ + "Schedule": {Key: _keySchedule, Result: hook.TypeScheduledReminder, Owned: true}, + "Repository": {Key: _keyRepository, Result: hook.TypeGithubTracking, Owned: true}, + "Branch": {Key: _keyBranch, Result: hook.TypeGithubTracking, Owned: true}, + "Paths": {Key: _keyPaths, Result: hook.TypeGithubTracking, Owned: true}, + "URL": {Key: _keyURL, Result: hook.TypeURLWatcher, Owned: true}, + "Image": {Key: _keyImage, Result: hook.TypeContainerImageWatcher, Owned: true}, + "Unknown": {Key: "colour"}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + got, owned := hookSettingOwner(c.Key) + assert.Equal(t, c.Owned, owned) + assert.Equal(t, c.Result, got) + }) + } +} + +func Test_listHooksArgs_Validate(t *testing.T) { + t.Parallel() + + assertValidate(t, + listHooksArgs{docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}}, + map[string]Args{ + _keyDocumentID: listHooksArgs{docTarget{BranchID: _stubMainBranchID}}, + _keyBranchID: listHooksArgs{docTarget{DocumentID: _testDocID}}, + }, + ) +} + +func Test_listHooks_Info(t *testing.T) { + t.Parallel() + + info := listHooks{}.Info() + + assert.Equal(t, NameListHooks, info.Name) + assert.NotEmpty(t, info.Description) + assert.Equal(t, []string{_keyDocumentID, _keyBranchID}, info.Required) + assert.Contains(t, info.Properties, _keyBranchID) +} + +func Test_listHooks_Traits(t *testing.T) { + t.Parallel() + + assert.Equal(t, Traits{}, listHooks{}.Traits()) +} + +func Test_listHooks_Title(t *testing.T) { + t.Parallel() + + got, err := listHooks{}.Title(testInput(testDeps(nil, nil, nil), NameListHooks, `{}`)) + require.NoError(t, err) + assert.Empty(t, got) +} + +func Test_listHooks_Execute(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hook.Hook, error) { + return nil, assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Args string + Result string + Err error + }{ + "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError}, + "Branch id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError}, + "Unknown branch": {DB: stubHookDB(), Args: `{` + targetArgs(_unknownBranchID) + `}`, Err: assert.AnError}, + "Error returned by db.FetchDocumentHooksByBranchID": { + DB: failing, + Args: `{` + targetArgs(_stubBranchID) + `}`, + Err: assert.AnError, + }, + "No hooks": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubMainBranchID) + `}`, + Result: `{"hooks":[]}`, + }, + "Hooks with their settings and state": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `}`, + Result: `{"hooks":[{"id":"` + _testHookID.String() + `","type":"scheduled-reminder","block_uid":null,` + + `"settings":{"scale":"linear","duration":"custom","schedule":"2030-01-01T00:00:00Z"},` + + `"score":"100","state":{"startedAt":"2026-01-01T00:00:00Z"},` + + `"created_at":"2026-01-01T00:00:00Z","updated_at":null}]}`, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + res, err := listHooks{}.Execute(testInput(testDeps(c.DB, nil, nil), NameListHooks, c.Args)) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.JSONEq(t, c.Result, res) + }) + } +} + +func Test_newHookRow(t *testing.T) { + t.Parallel() + + hk := stubHook() + hk.BlockID = null.StringFrom("b") + hk.UpdatedAt = null.TimeFrom(_stubScheduleTime) + + assert.Equal(t, hookRow{ + ID: _testHookID, + Type: hook.TypeScheduledReminder, + BlockUID: null.StringFrom("b"), + Settings: json.RawMessage(hk.Settings), + Score: hk.Score, + State: json.RawMessage(hk.State), + CreatedAt: hk.CreatedAt, + UpdatedAt: null.TimeFrom(_stubScheduleTime), + }, newHookRow(hk)) +} + +func Test_createHookArgs_Validate(t *testing.T) { + t.Parallel() + + scheduled := json.RawMessage(`{"schedule":"` + _stubSchedule + `"}`) + + ok := createHookArgs{ + DocumentID: _testDocID, BranchID: _stubMainBranchID, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + } + + assertValidate(t, ok, map[string]Args{ + _keyDocumentID: createHookArgs{ + docTarget: docTarget{BranchID: _stubMainBranchID}, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + }, + _keyBranchID: createHookArgs{ + docTarget: docTarget{DocumentID: _testDocID}, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + }, + _keyType: createHookArgs{ + docTarget: ok.docTarget, + Settings: scheduled, + }, + _keySchedule: createHookArgs{ + docTarget: ok.docTarget, + Type: hook.TypeScheduledReminder, + }, + }) + + // an unknown type is refused before its settings are looked at. + err := createHookArgs{docTarget: ok.docTarget, Type: "bogus"}.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), `type "bogus"`) + + // another type's field is refused naming the type it belongs to. + err = createHookArgs{docTarget: ok.docTarget, Type: hook.TypeURLWatcher, Settings: json.RawMessage(`{"url":"https://example.com","image":"x"}`)}.Validate() + require.Error(t, err) + assert.Equal(t, fmt.Errorf("%s applies to %s only", _keyImage, hook.TypeContainerImageWatcher), err) +} + +func Test_createHook_Info(t *testing.T) { + t.Parallel() + + info := createHook{}.Info() + + assert.Equal(t, NameCreateHook, info.Name) + assert.NotEmpty(t, info.Description) + assert.Equal(t, []string{_keyDocumentID, _keyBranchID, _keyType, _keySettings}, info.Required) + + for _, key := range []string{_keyDocumentID, _keyBranchID, _keyType, _keyBlockUID, _keySettings} { + assert.Contains(t, info.Properties, key) + } + + assert.Equal(t, hookSettingsSchema(), info.Properties[_keySettings]) + + // the type is an enum of the four hook types, so a client can refuse + // a bad one itself. + assert.Equal(t, hookTypes(), info.Properties[_keyType].(map[string]any)[_keyEnum]) + + // the two types that need an integration say so, since a deployment + // may lack it. + assert.Contains(t, info.Description, "integrations") +} + +func Test_createHook_Traits(t *testing.T) { + t.Parallel() + + assert.Equal(t, Traits{Write: true}, createHook{}.Traits()) +} + +func Test_createHook_Title(t *testing.T) { + t.Parallel() + + scheduled := `,"type":"scheduled-reminder","settings":{"schedule":"` + _stubSchedule + `"}` + + got, err := createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubMainBranchID)+scheduled+`}`)) + require.NoError(t, err) + assert.Equal(t, "Adding a hook to Runbook", got) + + got, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubBranchID)+scheduled+`}`)) + require.NoError(t, err) + assert.Equal(t, "Adding a hook to Runbook on branch draft", got) + + _, err = createHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameCreateHook, requiredArgs(t, NameCreateHook))) + require.Error(t, err) + + _, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`)) + require.Error(t, err) +} + +func Test_createHook_Summary(t *testing.T) { + t.Parallel() + + scheduled := `,"type":"scheduled-reminder","settings":{"schedule":"` + _stubSchedule + `"}` + + got, err := createHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubMainBranchID)+scheduled+`}`)) + require.NoError(t, err) + assert.Equal(t, ActionSummary{ + Tool: NameCreateHook, + DocumentID: _testDocID, + DocumentName: "Runbook", + Summary: "Add a Scheduled Reminder hook on Runbook", + }, got) + + // an anchored hook names its block, so the user approves the + // placement as well as the hook. + got, err = createHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubBranchID)+scheduled+`,"block_uid":"b"}`)) + require.NoError(t, err) + assert.Equal(t, "Add a Scheduled Reminder hook on Runbook on branch draft, block b", got.Summary) + + _, err = createHook{}.Summary(testInput(testDeps(failingDocumentDB(), nil, nil), NameCreateHook, requiredArgs(t, NameCreateHook))) + require.Error(t, err) + + _, err = createHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`)) + require.Error(t, err) +} + +func Test_createHook_Execute(t *testing.T) { + t.Parallel() + + scheduled := `"type":"scheduled-reminder","settings":{"schedule":"` + _stubSchedule + `"}` + + failing := stubHookDB() + failing.InsertDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Args string + BlockUID null.String + Notify int + Err error + }{ + "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError}, + "Type is required": {DB: stubHookDB(), Args: `{` + targetArgs(_stubBranchID) + `}`, Err: assert.AnError}, + "Schedule is required": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,"type":"scheduled-reminder","settings":{}}`, + Err: assert.AnError, + }, + "Another type's field is refused": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,"type":"scheduled-reminder","settings":{"schedule":"` + _stubSchedule + `","url":"https://example.com"}}`, + Err: fmt.Errorf("%s: %w", NameCreateHook, fmt.Errorf("%s applies to %s only", _keyURL, hook.TypeURLWatcher)), + }, + "Unknown branch": { + DB: stubHookDB(), + Args: `{` + targetArgs(_unknownBranchID) + `,` + scheduled + `}`, + Err: assert.AnError, + }, + "Unknown block": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `,"block_uid":"nope"}`, + Err: fmt.Errorf("create_hook: %w", fmt.Errorf("block %s: %w", "nope", errUnknownBlock)), + }, + "GitHub tracking without the app": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,"type":"github-tracking","settings":{"repository":"o/r","branch":"main","paths":["a"]}}`, + Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured)), + }, + "URL watcher without changedetection": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,"type":"url-watcher","settings":{"url":"https://example.com"}}`, + Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured)), + }, + "Error returned by db.InsertDocumentHook": { + DB: failing, + Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `}`, + Err: assert.AnError, + }, + "Created on the document": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `}`, + Notify: 1, + }, + "Created on a block": { + DB: stubHookDB(), + Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `,"block_uid":"` + _stubContentUID + `"}`, + BlockUID: null.StringFrom(_stubContentUID), + Notify: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameCreateHook, c.Args) + + res, err := createHook{}.Execute(inp) + testutil.AssertEqualError(t, c.Err, err) + + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + + if err != nil { + return + } + + ff := c.DB.InsertDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, hook.TypeScheduledReminder, ff[0].Hk.Type) + assert.Equal(t, null.ValueFrom(_testDocID), ff[0].Hk.DocumentID) + assert.Equal(t, null.ValueFrom(_stubBranchID), ff[0].Hk.BranchID) + assert.Equal(t, null.StringFrom("org"), ff[0].Hk.OrganizationID) + assert.Equal(t, c.BlockUID, ff[0].Hk.BlockID) + + row := decodeHookRow(t, res) + assert.Equal(t, ff[0].Hk.ID, row.ID) + assert.Equal(t, hook.TypeScheduledReminder, row.Type) + assert.Equal(t, c.BlockUID, row.BlockUID) + assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"`+_stubSchedule+`"}`, string(row.Settings)) + assert.Equal(t, "100", row.Score.String()) + assert.Contains(t, string(row.State), "startedAt") + + bf := hooks.NotifyHooksChangeCalls() + require.Len(t, bf, 1) + assert.Equal(t, "org", bf[0].OrganizationID) + assert.Equal(t, null.ValueFrom(_testDocID), bf[0].DocumentID) + assert.Equal(t, null.ValueFrom(_stubBranchID), bf[0].BranchID) + + assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched) + }) + } +} + +func Test_updateHookArgs_Validate(t *testing.T) { + t.Parallel() + + settings := json.RawMessage(`{}`) + + // the settings are checked against the hook's type later, so any + // payload passes here. + assertValidate(t, + updateHookArgs{DocumentID: _testDocID, HookID: _testHookID, Settings: settings}, + map[string]Args{ + _keyDocumentID: updateHookArgs{hookRefArgs: hookRefArgs{HookID: _testHookID}, Settings: settings}, + _keyHookID: updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID}, Settings: settings}, + _keySettings: updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}}, + }, + ) +} + +func Test_updateHook_Info(t *testing.T) { + t.Parallel() + + info := updateHook{}.Info() + + assert.Equal(t, NameUpdateHook, info.Name) + assert.NotEmpty(t, info.Description) + assert.Equal(t, []string{_keyDocumentID, _keyHookID, _keySettings}, info.Required) + + for _, key := range []string{_keyDocumentID, _keyHookID, _keySettings} { + assert.Contains(t, info.Properties, key) + } + + assert.Equal(t, hookSettingsSchema(), info.Properties[_keySettings]) + assert.NotContains(t, info.Properties, _keyType) +} + +func Test_updateHook_Traits(t *testing.T) { + t.Parallel() + + assert.Equal(t, Traits{Write: true}, updateHook{}.Traits()) +} + +func Test_updateHook_Title(t *testing.T) { + t.Parallel() + + got, err := updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{}}`)) + require.NoError(t, err) + assert.Equal(t, "Updating a hook on Runbook", got) + + _, err = updateHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateHook, requiredArgs(t, NameUpdateHook))) + require.Error(t, err) + + _, err = updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`)) + require.Error(t, err) +} + +func Test_updateHook_Summary(t *testing.T) { + t.Parallel() + + got, err := updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"schedule":"`+_stubSchedule+`"}}`)) + require.NoError(t, err) + assert.Equal(t, ActionSummary{ + Tool: NameUpdateHook, + DocumentID: _testDocID, + DocumentName: "Runbook", + Summary: "Update the Scheduled Reminder hook on Runbook on branch draft", + }, got) + + // settings of another type are refused here, so the user is not + // asked to approve a call that cannot run. + _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"url":"https://example.com"}}`)) + require.Error(t, err) + assert.Equal(t, fmt.Errorf("%s: %w", NameUpdateHook, fmt.Errorf("%s applies to %s only", _keyURL, hook.TypeURLWatcher)), err) + + _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_unknownHookID)+`,"settings":{"schedule":"`+_stubSchedule+`"}}`)) + require.Error(t, err) + + _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`)) + require.Error(t, err) +} + +func Test_updateHook_Execute(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.UpdateDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + later := time.Date(2031, 6, 1, 12, 0, 0, 0, time.UTC) + + cc := map[string]struct { + DB *DBMock + Args string + Notify int + Err error + }{ + "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError}, + "Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError}, + "Unknown hook": { + DB: stubHookDB(), + Args: `{` + hookArgs(_unknownHookID) + `,"settings":{"schedule":"` + _stubSchedule + `"}}`, + Err: fmt.Errorf("update_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)), + }, + "Settings are required": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `}`, + Err: fmt.Errorf("%s: %w", NameUpdateHook, errRequired(_keySettings)), + }, + "Schedule is required for a scheduled reminder": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `,"settings":{}}`, + Err: fmt.Errorf("update_hook: %w", errRequired(_keySchedule)), + }, + "Another type's field is refused": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `,"settings":{"schedule":"` + _stubSchedule + `","image":"nginx:1"}}`, + Err: fmt.Errorf("update_hook: %w", fmt.Errorf("%s applies to %s only", _keyImage, hook.TypeContainerImageWatcher)), + }, + "Error returned by db.UpdateDocumentHook": { + DB: failing, + Args: `{` + hookArgs(_testHookID) + `,"settings":{"schedule":"` + later.Format(time.RFC3339) + `"}}`, + Err: assert.AnError, + }, + "Updated": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `,"settings":{"schedule":"` + later.Format(time.RFC3339) + `"}}`, + Notify: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameUpdateHook, c.Args) + + res, err := updateHook{}.Execute(inp) + testutil.AssertEqualError(t, c.Err, err) + + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + + if err != nil { + return + } + + // the settings are replaced and the state reset: a fresh + // start rather than the old one carried over. + ff := c.DB.UpdateDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, _testHookID, ff[0].Hk.ID) + assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"2031-06-01T12:00:00Z"}`, string(ff[0].Hk.Settings)) + assert.NotContains(t, string(ff[0].Hk.State), "2026-01-01") + assert.True(t, ff[0].Hk.UpdatedAt.Valid) + + row := decodeHookRow(t, res) + assert.Equal(t, _testHookID, row.ID) + assert.JSONEq(t, string(ff[0].Hk.Settings), string(row.Settings)) + assert.Equal(t, "100", row.Score.String()) + + assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched) + }) + } +} + +func Test_hookRefArgs_Validate(t *testing.T) { + t.Parallel() + + assertValidate(t, + hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}, + map[string]Args{ + _keyDocumentID: hookRefArgs{HookID: _testHookID}, + _keyHookID: hookRefArgs{DocumentID: _testDocID}, + }, + ) +} + +func Test_hookRefProps(t *testing.T) { + t.Parallel() + + got := hookRefProps() + + assert.Contains(t, got, _keyDocumentID) + assert.Contains(t, got, _keyHookID) +} + +func Test_resetHook_Info(t *testing.T) { + t.Parallel() + + info := resetHook{}.Info() + + assert.Equal(t, NameResetHook, info.Name) + assert.NotEmpty(t, info.Description) + assert.Equal(t, []string{_keyDocumentID, _keyHookID}, info.Required) + assert.Contains(t, info.Properties, _keyHookID) +} + +func Test_resetHook_Traits(t *testing.T) { + t.Parallel() + + assert.Equal(t, Traits{Write: true}, resetHook{}.Traits()) +} + +func Test_resetHook_Title(t *testing.T) { + t.Parallel() + + got, err := resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`)) + require.NoError(t, err) + assert.Equal(t, "Resetting a hook on Runbook", got) + + _, err = resetHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameResetHook, requiredArgs(t, NameResetHook))) + require.Error(t, err) + + _, err = resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`)) + require.Error(t, err) +} + +func Test_resetHook_Summary(t *testing.T) { + t.Parallel() + + got, err := resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`)) + require.NoError(t, err) + assert.Equal(t, ActionSummary{ + Tool: NameResetHook, + DocumentID: _testDocID, + DocumentName: "Runbook", + Summary: "Reset the Scheduled Reminder hook on Runbook on branch draft", + }, got) + + _, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_unknownHookID)+`}`)) + require.Error(t, err) + + _, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`)) + require.Error(t, err) +} + +func Test_resetHook_Execute(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.UpdateDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Args string + Notify int + Err error + }{ + "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError}, + "Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError}, + "Unknown hook": { + DB: stubHookDB(), + Args: `{` + hookArgs(_unknownHookID) + `}`, + Err: fmt.Errorf("reset_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)), + }, + "Error returned by db.UpdateDocumentHook": { + DB: failing, + Args: `{` + hookArgs(_testHookID) + `}`, + Err: assert.AnError, + }, + "Reset": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `}`, + Notify: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameResetHook, c.Args) + + res, err := resetHook{}.Execute(inp) + testutil.AssertEqualError(t, c.Err, err) + + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + + if err != nil { + return + } + + // the settings stay as they were; only the state starts over. + ff := c.DB.UpdateDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, _testHookID, ff[0].Hk.ID) + assert.JSONEq(t, string(stubHook().Settings), string(ff[0].Hk.Settings)) + assert.NotContains(t, string(ff[0].Hk.State), "2026-01-01") + + row := decodeHookRow(t, res) + assert.Equal(t, _testHookID, row.ID) + assert.Equal(t, "100", row.Score.String()) + + assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched) + }) + } +} + +func Test_deleteHook_Info(t *testing.T) { + t.Parallel() + + info := deleteHook{}.Info() + + assert.Equal(t, NameDeleteHook, info.Name) + assert.NotEmpty(t, info.Description) + assert.Equal(t, []string{_keyDocumentID, _keyHookID}, info.Required) + assert.Contains(t, info.Properties, _keyHookID) +} + +func Test_deleteHook_Traits(t *testing.T) { + t.Parallel() + + assert.Equal(t, Traits{Write: true, Destructive: true}, deleteHook{}.Traits()) +} + +func Test_deleteHook_Title(t *testing.T) { + t.Parallel() + + got, err := deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`+hookArgs(_testHookID)+`}`)) + require.NoError(t, err) + assert.Equal(t, "Deleting a hook on Runbook", got) + + _, err = deleteHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteHook, requiredArgs(t, NameDeleteHook))) + require.Error(t, err) + + _, err = deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`)) + require.Error(t, err) +} + +func Test_deleteHook_Summary(t *testing.T) { + t.Parallel() + + got, err := deleteHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`+hookArgs(_testHookID)+`}`)) + require.NoError(t, err) + assert.Equal(t, ActionSummary{ + Tool: NameDeleteHook, + DocumentID: _testDocID, + DocumentName: "Runbook", + Summary: "Delete the Scheduled Reminder hook on Runbook on branch draft", + }, got) + + _, err = deleteHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`+hookArgs(_unknownHookID)+`}`)) + require.Error(t, err) + + _, err = deleteHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`)) + require.Error(t, err) +} + +func Test_deleteHook_Execute(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.DeleteDocumentHookFunc = func(context.Context, xid.ID) error { + return assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Args string + Notify int + Err error + }{ + "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError}, + "Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError}, + "Unknown hook": { + DB: stubHookDB(), + Args: `{` + hookArgs(_unknownHookID) + `}`, + Err: fmt.Errorf("delete_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)), + }, + "Error returned by db.DeleteDocumentHook": { + DB: failing, + Args: `{` + hookArgs(_testHookID) + `}`, + Err: assert.AnError, + }, + "Deleted": { + DB: stubHookDB(), + Args: `{` + hookArgs(_testHookID) + `}`, + Notify: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameDeleteHook, c.Args) + + res, err := deleteHook{}.Execute(inp) + testutil.AssertEqualError(t, c.Err, err) + + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + + if err != nil { + return + } + + ff := c.DB.DeleteDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, _testHookID, ff[0].ID) + assert.JSONEq(t, `{"hook_id":"`+_testHookID.String()+`","deleted":true}`, res) + + // the branch stays, and the editor showing it has to redraw. + assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched) + }) + } +} + +func Test_describeHook(t *testing.T) { + t.Parallel() + + branchless := stubHookDB() + branchless.FetchDocumentHookFunc = func(context.Context, xid.ID, string) (*hook.Hook, error) { + hk := stubHook() + hk.BranchID = null.Value[xid.ID]{} + + return hk, nil + } + + failingDoc := stubHookDB() + failingDoc.FetchDocumentByBranchIDFunc = func(context.Context, xid.ID, string) (*document.Document, error) { + return nil, assert.AnError + } + + cc := map[string]struct { + DB *DBMock + HookID xid.ID + Branch string + Err error + }{ + "Unknown hook": { + DB: stubHookDB(), + HookID: _unknownHookID, + Err: fmt.Errorf("%s: fetch hook: %w", NameResetHook, fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)), + }, + "Error returned by the branch fetch": { + DB: failingDoc, + HookID: _testHookID, + Err: assert.AnError, + }, + "Hook on a branch": { + DB: stubHookDB(), + HookID: _testHookID, + Branch: _stubBranchName, + }, + // a hook whose branch is gone is named on the default branch, + // which the sweep will remove it from regardless. + "Hook whose branch is gone": { + DB: branchless, + HookID: _testHookID, + Branch: document.DefaultBranch, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + doc, hk, err := describeHook(testInput(testDeps(c.DB, nil, nil), NameResetHook, `{}`), NameResetHook, hookRefArgs{DocumentID: _testDocID, HookID: c.HookID}) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + assert.Nil(t, doc) + assert.Nil(t, hk) + + return + } + + assert.Equal(t, c.Branch, doc.BranchName) + assert.Equal(t, _testHookID, hk.ID) + }) + } +} + +func Test_hookLabel(t *testing.T) { + t.Parallel() + + doc := stubBranchDocument(_testDocID, "org", _stubBranchID, _stubBranchName) + + assert.Equal(t, "Website Changes hook on Runbook on branch draft", hookLabel(hook.TypeURLWatcher, null.String{}, doc)) + assert.Equal(t, "Website Changes hook on Runbook on branch draft, block b", hookLabel(hook.TypeURLWatcher, null.StringFrom("b"), doc)) +} + +func Test_hookTypes(t *testing.T) { + t.Parallel() + + got := hookTypes() + require.Len(t, got, 4) + + // every published type is one the domain accepts. + for _, v := range got { + require.NoError(t, hook.Type(v).Validate()) + } +} diff --git a/server/core/internal/assistant/tools/input.go b/server/core/internal/assistant/tools/input.go index 48d2aaaf..767bb9a8 100644 --- a/server/core/internal/assistant/tools/input.go +++ b/server/core/internal/assistant/tools/input.go @@ -13,10 +13,14 @@ import ( "strings" "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/apps/github" + "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/assistant/block" "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/oxynote/oxynote/server/core/internal/datasource" "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" "github.com/oxynote/oxynote/server/core/internal/search" "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/oxynote/oxynote/server/core/pkg/errutil" @@ -29,6 +33,12 @@ import ( // here too, for the same reason as ErrUnknownDocument. var ErrUnknownTag = errors.New("no tag with that id in this organisation; call list_tags for the ids that exist") +// errUnknownHook is what a hook lookup reports for an id the addressed +// document does not hold. A hook of another document, another +// organisation or a deleted document lands here too: the tools cannot +// be used to reach a hook through a document it is not on. +var errUnknownHook = errors.New("no hook with that id on this document; call list_hooks for the ids it has") + // Deps carries the wiring a tool set is built from: the services every // tool reaches through and the (organization, user) pair every call is // scoped to. @@ -57,6 +67,15 @@ type Deps struct { // runners hands out the runner a data-source tool reads through. runners DataSourceRunners + // githubMan is the GitHub App integration a github-tracking hook + // reads through; unconfigured on a deployment without the app. + githubMan *github.Manager + + // webchangeClient is the changedetection.io integration a + // url-watcher hook holds its watcher in; unconfigured on a + // deployment without it. + webchangeClient *webchange.Client + // applier is the edit client for content mutations and the // rename/set-icon ops that must propagate to connected editors. applier EditApplier @@ -69,6 +88,10 @@ type Deps struct { // a tag or what carries it. tags TagNotifier + // hooks notifies a document's subscribers after the assistant + // changes the hooks on one of its branches. + hooks HookNotifier + // offload retrieves results parked outside the conversation. offload OffloadReader @@ -91,9 +114,12 @@ func NewDeps( searcher Searcher, jobs *search.Jobs, runners DataSourceRunners, + githubMan *github.Manager, + webchangeClient *webchange.Client, applier EditApplier, tree TreeNotifier, tags TagNotifier, + hooks HookNotifier, offload OffloadReader, orgID, userID string, ) *Deps { @@ -103,16 +129,19 @@ func NewDeps( "org_id", orgID, "user_id", userID, ), - db: db, - search: searcher, - jobs: jobs, - runners: runners, - applier: applier, - tree: tree, - tags: tags, - offload: offload, - orgID: orgID, - userID: userID, + db: db, + search: searcher, + jobs: jobs, + runners: runners, + githubMan: githubMan, + webchangeClient: webchangeClient, + applier: applier, + tree: tree, + tags: tags, + hooks: hooks, + offload: offload, + orgID: orgID, + userID: userID, } } @@ -948,6 +977,184 @@ func (i *input) NotifyBranchTagsChange(documentID, branchID xid.ID) { i.tags.NotifyBranchTagsChange(i.orgID, documentID, branchID) } +// hookInput builds the dependencies a hook's processor reaches through, +// scoped to the session's organisation. +func (i *input) hookInput() *hook.Input { + return hook.NewInput(i.orgID, i.githubMan, i.webchangeClient) +} + +// FetchHooks returns every hook on the branch branchID names, refusing a +// branch the document does not have. +func (i *input) FetchHooks(documentID, branchID xid.ID) ([]hook.Hook, error) { + doc, err := i.FetchBranch(documentID, branchID) + if err != nil { + return nil, err + } + + hooks, err := i.db.FetchDocumentHooksByBranchID(i.ctx, doc.BranchID, i.orgID) + if err != nil { + return nil, fmt.Errorf("fetching hooks: %w", err) + } + + return hooks, nil +} + +// FetchHook returns the hook the id names on the document. The lookup is +// scoped to the organisation, and a hook of another document, or of a +// document that was deleted and left it behind, is refused as unknown to +// the one addressed. +func (i *input) FetchHook(documentID, hookID xid.ID) (*hook.Hook, error) { + hk, err := i.db.FetchDocumentHook(i.ctx, hookID, i.orgID) + if err != nil { + if errutil.IsNotFound(err) { + return nil, i.unknownHook(documentID, hookID) + } + + return nil, fmt.Errorf("fetching hook: %w", err) + } + + if !hk.DocumentID.Valid || hk.DocumentID.V != documentID { + return nil, i.unknownHook(documentID, hookID) + } + + return hk, nil +} + +// unknownHook builds the refusal for a hook the document does not hold, +// naming the document the call addressed. +func (i *input) unknownHook(documentID, hookID xid.ID) error { + return fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook) +} + +// CreateHook creates a hook on the branch branchID names, anchored to the +// block blockUID names when one is given. The branch and block are +// checked here rather than by the caller, so a hook cannot land on a +// branch of another document or on a block the branch does not hold. A +// type whose integration the deployment lacks is refused before anything +// is created. +func (i *input) CreateHook(documentID, branchID xid.ID, blockUID string, tp hook.Type, settings processor.Settings) (*hook.Hook, error) { + doc, err := i.FetchBranch(documentID, branchID) + if err != nil { + return nil, err + } + + var blockID null.String + + if blockUID != "" { + if _, ok := doc.Content.FindByUID(blockUID); !ok { + return nil, fmt.Errorf("block %s: %w", blockUID, errUnknownBlock) + } + + blockID = null.StringFrom(blockUID) + } + + // the github processor records a missing app as a status rather than + // an error, so a hook created here would sit silently unable to work. + switch tp { + case hook.TypeGithubTracking: + if !i.githubMan.Configured() { + return nil, fmt.Errorf("%s: %w", tp, github.ErrNotConfigured) + } + case hook.TypeURLWatcher: + if !i.webchangeClient.Configured() { + return nil, fmt.Errorf("%s: %w", tp, webchange.ErrNotConfigured) + } + case hook.TypeScheduledReminder, hook.TypeContainerImageWatcher: + // nothing outside the deployment to check. + } + + hk, err := hook.NewHook(i.ctx, hook.CreateInput{ + Type: tp, + BranchID: doc.BranchID, + BlockID: blockID, + Settings: settings, + }, doc.ID, doc.BranchID, i.orgID, i.hookInput()) + if err != nil { + return nil, err + } + + if err := i.db.InsertDocumentHook(i.ctx, *hk); err != nil { + // NewHook created the watcher as a side effect, and without a row + // nothing would ever tear it down. + if derr := hk.Delete(i.ctx, i.hookInput()); derr != nil { + i.log.Error( + "tearing down the hook of a failed insert", + slog.String("hook_id", hk.ID.String()), + slog.String("error", derr.Error()), + ) + } + + return nil, fmt.Errorf("insert: %w", err) + } + + i.hookChanged(hk) + + return hk, nil +} + +// UpdateHook replaces the hook's settings and resets its score and state, +// as a fresh hook with those settings would have them. +func (i *input) UpdateHook(hk *hook.Hook, settings processor.Settings) error { + if err := hk.ApplyUpdate(i.ctx, hook.UpdateInput{Settings: settings}, i.hookInput()); err != nil { + return err + } + + if err := i.db.UpdateDocumentHook(i.ctx, *hk); err != nil { + return fmt.Errorf("update: %w", err) + } + + i.hookChanged(hk) + + return nil +} + +// ResetHook restores the hook's score and state, keeping its settings. +func (i *input) ResetHook(hk *hook.Hook) error { + if err := hk.Reset(i.ctx, i.hookInput()); err != nil { + return err + } + + if err := i.db.UpdateDocumentHook(i.ctx, *hk); err != nil { + return fmt.Errorf("update: %w", err) + } + + i.hookChanged(hk) + + return nil +} + +// DeleteHook tears down what the hook holds outside the document and +// removes its row. The branch stays, and the editor showing it has to +// redraw, so the delete records the branch as touched. +func (i *input) DeleteHook(hk *hook.Hook) error { + if err := hk.Delete(i.ctx, i.hookInput()); err != nil { + return err + } + + if err := i.db.DeleteDocumentHook(i.ctx, hk.ID); err != nil { + return fmt.Errorf("delete: %w", err) + } + + i.hookChanged(hk) + + return nil +} + +// hookChanged records the branch a hook write changed and announces it +// to the document's subscribers. A hook whose branch was deleted, and +// which the sweep has not yet removed, has no branch to link to. +func (i *input) hookChanged(hk *hook.Hook) { + if hk.BranchID.Valid { + i.recordTouched(hk.DocumentID.V, hk.BranchID.V) + } + + if i.hooks == nil { + return + } + + i.hooks.NotifyHooksChange(i.orgID, hk.DocumentID, hk.BranchID) +} + // docRef wraps the (documentID, branchID) pair the edit client needs to // address a live Y.Doc. The branch is resolved to the document's // default branch — multi-branch editing is out of scope for the @@ -979,6 +1186,7 @@ type DB interface { DocumentDB DataSourceDB TagDB + HookDB } // DocumentDB is the document tree, its branches and their content as @@ -1072,6 +1280,29 @@ type TagDB interface { UpdateTagTree(ctx context.Context, tree tag.Summaries, organizationID string) error } +// HookDB is the freshness hooks on a document's branches as the tools +// read and change them. +type HookDB interface { + // FetchDocumentHooksByBranchID should return every hook on a branch + // within the org. Used by list_hooks. + FetchDocumentHooksByBranchID(ctx context.Context, branchID xid.ID, organizationID string) ([]hook.Hook, error) + + // FetchDocumentHook should return a hook by id within the org. Used + // by every hook write to resolve what it was asked about. + FetchDocumentHook(ctx context.Context, id xid.ID, organizationID string) (*hook.Hook, error) + + // InsertDocumentHook should store a new hook. Used by create_hook. + InsertDocumentHook(ctx context.Context, hk hook.Hook) error + + // UpdateDocumentHook should store a hook's changed settings, score + // and state. Used by update_hook and reset_hook. + UpdateDocumentHook(ctx context.Context, hk hook.Hook) error + + // DeleteDocumentHook should remove a hook the caller has already + // fetched org-scoped. Used by delete_hook. + DeleteDocumentHook(ctx context.Context, id xid.ID) error +} + // Tx is the transactional half of DB, so a tool whose write spans // tables can commit or abandon all of it at once. // @@ -1142,6 +1373,20 @@ type TagNotifier interface { NotifyBranchTagsChange(organizationID string, documentID, branchID xid.ID) } +// HookNotifier publishes hook-change events so an open editor redraws +// its hook indicators after assistant-driven hook writes. The server +// hook handler satisfies this interface via its NotifyHooksChange +// method. +// +//go:generate ../../../scripts/codegen/mock -t internal HookNotifier hook_notifier +type HookNotifier interface { + // NotifyHooksChange should tell the subscribers of the document that + // the hooks on its branch branchID changed, and do nothing for a hook + // whose document or branch is gone. Implementations must be safe to + // call concurrently. + NotifyHooksChange(organizationID string, documentID, branchID null.Value[xid.ID]) +} + // EditApplier is the live-document mutation surface the write tools // use for content edits and the rename/set-icon ops. The edit.Client // satisfies it. diff --git a/server/core/internal/assistant/tools/input_test.go b/server/core/internal/assistant/tools/input_test.go index 7cdb23f7..663e1c5f 100644 --- a/server/core/internal/assistant/tools/input_test.go +++ b/server/core/internal/assistant/tools/input_test.go @@ -10,18 +10,24 @@ import ( "reflect" "slices" "testing" + "time" "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/apps/github" + "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/assistant/block" "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/oxynote/oxynote/server/core/internal/datasource" datasourceMock "github.com/oxynote/oxynote/server/core/internal/datasource/_mock" "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" "github.com/oxynote/oxynote/server/core/internal/search" "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/oxynote/oxynote/server/core/pkg/errutil" "github.com/oxynote/oxynote/server/core/pkg/testutil" "github.com/rs/xid" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -77,10 +83,12 @@ func testDeps(db *DBMock, applier *EditApplierMock, tree *TreeNotifierMock) *Dep return &datasourceMock.Runner{} }, }, - applier: applier, - offload: &offloadReaderMock{}, - orgID: "org", - userID: "user", + githubMan: unconfiguredGithub(), + webchangeClient: webchange.NewClient("", ""), + applier: applier, + offload: &offloadReaderMock{}, + orgID: "org", + userID: "user", } // a nil notifier is a real case — NotifyTreeChange no-ops — so it is @@ -92,6 +100,17 @@ func testDeps(db *DBMock, applier *EditApplierMock, tree *TreeNotifierMock) *Dep return d } +// unconfiguredGithub builds the GitHub manager of a deployment without +// the app, which refuses every call that would reach GitHub. +func unconfiguredGithub() *github.Manager { + man, err := github.NewManager(nil, github.Options{}) + if err != nil { + panic(err) + } + + return man +} + // testInput builds the per-call input a tool is handed. func testInput(d *Deps, name Name, args string) *input { return d.newInput(context.Background(), name, json.RawMessage(args)) @@ -135,6 +154,14 @@ func requiredArgs(t *testing.T, name Name) string { vals[key] = _testDocID.String() case _keyTagID: vals[key] = _testTagID.String() + case _keyHookID: + vals[key] = _testHookID.String() + case _keyType: + vals[key] = string(hook.TypeScheduledReminder) + case _keySettings: + // the stubbed hook is a scheduled reminder, and so is the + // type above. + vals[key] = map[string]any{_keySchedule: _stubSchedule} case _keyColor: vals[key] = _stubTagColor case _keySortIndex: @@ -217,9 +244,43 @@ func stubDocumentDB() *DBMock { }, FetchDocumentBranchesFunc: stubBranches(false, false), FetchTagTreeFunc: stubTagTree, + FetchDocumentHookFunc: stubHookLookup, } } +// stubHookLookup answers a hook lookup with the test hook, a scheduled +// reminder on the draft branch of the test document, for the test hook +// id and with not found for any other. +func stubHookLookup(_ context.Context, id xid.ID, _ string) (*hook.Hook, error) { + if id != _testHookID { + return nil, errutil.ErrNotFound + } + + return stubHook(), nil +} + +// stubHook builds the hook every hook stub answers with. +func stubHook() *hook.Hook { + return &hook.Hook{ + ID: _testHookID, + Type: hook.TypeScheduledReminder, + DocumentID: null.ValueFrom(_testDocID), + OrganizationID: null.StringFrom("org"), + BranchID: null.ValueFrom(_stubBranchID), + Settings: processor.Settings(`{"scale":"linear","duration":"custom","schedule":"2030-01-01T00:00:00Z"}`), + State: processor.State(`{"startedAt":"2026-01-01T00:00:00Z"}`), + Score: decimal.NewFromInt(100), + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +// _testHookID is the hook id the hook tool tests address; _unknownHookID +// names none. +var ( + _testHookID = xid.New() + _unknownHookID = xid.New() +) + // stubTagTree answers a tag tree listing with two tags: the test tag, // carried by the test document, and a hidden one carried by nothing. func stubTagTree(context.Context, string, string) (tag.Summaries, error) { @@ -269,6 +330,10 @@ func stubBranches(mainProtected, draftProtected bool) func(context.Context, xid. } } +// _stubSchedule is the schedule every scheduled-reminder argument stub +// carries, far enough ahead that a reset scores it fresh. +const _stubSchedule = "2030-01-01T00:00:00Z" + // _stubBranchName is the non-default branch every branch stub lists. const _stubBranchName = "draft" @@ -332,7 +397,10 @@ func Test_NewDeps(t *testing.T) { applier = &EditApplierMock{} tree = &TreeNotifierMock{} tags = &TagNotifierMock{} + hooks = &HookNotifierMock{} offload = &offloadReaderMock{} + gh = unconfiguredGithub() + wc = webchange.NewClient("", "") ) runners := &DataSourceRunnersMock{ @@ -341,7 +409,7 @@ func Test_NewDeps(t *testing.T) { jobs := search.NewJobs(true) - d := NewDeps(discardLog(), db, searcher, jobs, runners, applier, tree, tags, offload, "org", "user") + d := NewDeps(discardLog(), db, searcher, jobs, runners, gh, wc, applier, tree, tags, hooks, offload, "org", "user") require.NotNil(t, d) assert.NotNil(t, d.log) @@ -349,9 +417,12 @@ func Test_NewDeps(t *testing.T) { assert.Same(t, searcher, d.search) assert.Same(t, jobs, d.jobs) assert.Same(t, runners, d.runners) + assert.Same(t, gh, d.githubMan) + assert.Same(t, wc, d.webchangeClient) assert.Same(t, applier, d.applier) assert.Same(t, tree, d.tree) assert.Same(t, tags, d.tags) + assert.Same(t, hooks, d.hooks) assert.Same(t, offload, d.offload) assert.Equal(t, "org", d.orgID) assert.Equal(t, "user", d.userID) @@ -2169,3 +2240,545 @@ func Test_input_NotifyBranchTagsChange(t *testing.T) { assert.Equal(t, _testDocID, ff[0].DocumentID) assert.Equal(t, _stubBranchID, ff[0].BranchID) } + +func Test_input_hookInput(t *testing.T) { + t.Parallel() + + inp := testInput(testDeps(nil, nil, nil), NameCreateHook, `{}`) + + got := inp.hookInput() + require.NotNil(t, got) + + // the deployment has neither integration, and the input says so + // the way the processors ask. + assert.Same(t, inp.webchangeClient, got.ChangeDetection()) + + _, err := got.Github(context.Background()) + assert.Equal(t, github.ErrNotConfigured, err) +} + +func Test_input_FetchHooks(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.FetchDocumentHooksByBranchIDFunc = func(context.Context, xid.ID, string) ([]hook.Hook, error) { + return nil, assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Branch xid.ID + Result []hook.Hook + Err error + }{ + "Unknown branch": { + DB: stubHookDB(), + Branch: _unknownBranchID, + Err: fmt.Errorf("branch %s: %w; the branches are main (%s), draft (%s)", _unknownBranchID, ErrUnknownBranch, _stubMainBranchID, _stubBranchID), + }, + "Error returned by db.FetchDocumentHooksByBranchID": { + DB: failing, + Branch: _stubBranchID, + Err: assert.AnError, + }, + "Branch without hooks": { + DB: stubHookDB(), + Branch: _stubMainBranchID, + Result: []hook.Hook{}, + }, + "Branch with hooks": { + DB: stubHookDB(), + Branch: _stubBranchID, + Result: []hook.Hook{*stubHook()}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + got, err := testInput(testDeps(c.DB, nil, nil), NameListHooks, `{}`).FetchHooks(_testDocID, c.Branch) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + assert.Nil(t, got) + return + } + + assert.Equal(t, c.Result, got) + + ff := c.DB.FetchDocumentHooksByBranchIDCalls() + require.Len(t, ff, 1) + assert.Equal(t, c.Branch, ff[0].BranchID) + assert.Equal(t, "org", ff[0].OrganizationID) + }) + } +} + +func Test_input_FetchHook(t *testing.T) { + t.Parallel() + + // withHook answers the lookup with the given hook. + withHook := func(hk *hook.Hook) *DBMock { + return &DBMock{ + FetchDocumentHookFunc: func(context.Context, xid.ID, string) (*hook.Hook, error) { + return hk, nil + }, + } + } + + otherDocument := stubHook() + otherDocument.DocumentID = null.ValueFrom(xid.New()) + + deletedDocument := stubHook() + deletedDocument.DocumentID = null.Value[xid.ID]{} + + cc := map[string]struct { + DB *DBMock + ID xid.ID + Err error + }{ + "Error returned by db.FetchDocumentHook": { + DB: &DBMock{ + FetchDocumentHookFunc: func(context.Context, xid.ID, string) (*hook.Hook, error) { + return nil, assert.AnError + }, + }, + ID: _testHookID, + Err: assert.AnError, + }, + "Unknown hook": { + DB: stubHookDB(), + ID: _unknownHookID, + Err: fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook), + }, + // the repository scopes by organisation, so another one's hook + // is as absent as an id that names nothing. + "Hook of another organisation": { + DB: &DBMock{ + FetchDocumentHookFunc: func(context.Context, xid.ID, string) (*hook.Hook, error) { + return nil, errutil.ErrNotFound + }, + }, + ID: _testHookID, + Err: fmt.Errorf("hook %s on document %s: %w", _testHookID, _testDocID, errUnknownHook), + }, + "Hook of another document": { + DB: withHook(otherDocument), + ID: _testHookID, + Err: fmt.Errorf("hook %s on document %s: %w", _testHookID, _testDocID, errUnknownHook), + }, + "Hook of a deleted document": { + DB: withHook(deletedDocument), + ID: _testHookID, + Err: fmt.Errorf("hook %s on document %s: %w", _testHookID, _testDocID, errUnknownHook), + }, + "Known hook": { + DB: stubHookDB(), + ID: _testHookID, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + hk, err := testInput(testDeps(c.DB, nil, nil), NameResetHook, `{}`).FetchHook(_testDocID, c.ID) + testutil.AssertEqualError(t, c.Err, err) + + ff := c.DB.FetchDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, c.ID, ff[0].ID) + assert.Equal(t, "org", ff[0].OrganizationID) + + if err != nil { + assert.Nil(t, hk) + return + } + + assert.Equal(t, stubHook(), hk) + }) + } +} + +func Test_input_unknownHook(t *testing.T) { + t.Parallel() + + err := testInput(testDeps(nil, nil, nil), NameResetHook, `{}`).unknownHook(_testDocID, _unknownHookID) + + // the message names the document the call addressed, so a hook that + // exists elsewhere is not mistaken for a typo. + assert.Equal(t, fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook), err) +} + +func Test_input_CreateHook(t *testing.T) { + t.Parallel() + + scheduled := processor.Settings(`{"scale":"linear","duration":"custom","schedule":"` + _stubSchedule + `"}`) + + failing := stubHookDB() + failing.InsertDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Branch xid.ID + BlockUID string + Type hook.Type + Settings processor.Settings + Inserts int + Notify int + Touched []Touched + Err error + }{ + "Unknown branch is refused before anything is created": { + DB: stubHookDB(), + Branch: _unknownBranchID, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + Err: fmt.Errorf("branch %s: %w; the branches are main (%s), draft (%s)", _unknownBranchID, ErrUnknownBranch, _stubMainBranchID, _stubBranchID), + }, + "Unknown block": { + DB: stubHookDB(), + Branch: _stubBranchID, + BlockUID: "nope", + Type: hook.TypeScheduledReminder, + Settings: scheduled, + Err: fmt.Errorf("block %s: %w", "nope", errUnknownBlock), + }, + "GitHub tracking without the app": { + DB: stubHookDB(), + Branch: _stubBranchID, + Type: hook.TypeGithubTracking, + Settings: processor.Settings(`{"repository":"o/r","branch":"main","paths":["a"]}`), + Err: fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured), + }, + "URL watcher without changedetection": { + DB: stubHookDB(), + Branch: _stubBranchID, + Type: hook.TypeURLWatcher, + Settings: processor.Settings(`{"url":"https://example.com"}`), + Err: fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured), + }, + "Settings the processor refuses": { + DB: stubHookDB(), + Branch: _stubBranchID, + Type: hook.TypeScheduledReminder, + Settings: processor.Settings(`{"scale":"bogus"}`), + Err: processor.ErrInvalidScaleType, + }, + "Error returned by db.InsertDocumentHook": { + DB: failing, + Branch: _stubBranchID, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + Inserts: 1, + Err: fmt.Errorf("insert: %w", assert.AnError), + }, + "Created on the document": { + DB: stubHookDB(), + Branch: _stubBranchID, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + Inserts: 1, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + "Created on a block": { + DB: stubHookDB(), + Branch: _stubBranchID, + BlockUID: _stubContentUID, + Type: hook.TypeScheduledReminder, + Settings: scheduled, + Inserts: 1, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameCreateHook, `{}`) + + hk, err := inp.CreateHook(_testDocID, c.Branch, c.BlockUID, c.Type, c.Settings) + testutil.AssertEqualError(t, c.Err, err) + + ff := c.DB.InsertDocumentHookCalls() + require.Len(t, ff, c.Inserts) + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + assert.Equal(t, c.Touched, inp.touched) + + if err != nil { + assert.Nil(t, hk) + return + } + + assert.Equal(t, *hk, ff[0].Hk) + assert.Equal(t, c.Type, hk.Type) + assert.Equal(t, null.ValueFrom(_testDocID), hk.DocumentID) + assert.Equal(t, null.ValueFrom(c.Branch), hk.BranchID) + assert.Equal(t, null.StringFrom("org"), hk.OrganizationID) + assert.Equal(t, null.NewString(c.BlockUID, c.BlockUID != ""), hk.BlockID) + assert.Equal(t, c.Settings, hk.Settings) + assert.Equal(t, "100", hk.Score.String()) + assert.NotEmpty(t, hk.State) + }) + } +} + +func Test_input_UpdateHook(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.UpdateDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + cc := map[string]struct { + DB *DBMock + Settings processor.Settings + Updates int + Notify int + Touched []Touched + Err error + }{ + "Settings the processor refuses": { + DB: stubHookDB(), + Settings: processor.Settings(`{"scale":"bogus"}`), + Err: processor.ErrInvalidScaleType, + }, + "Error returned by db.UpdateDocumentHook": { + DB: failing, + Settings: processor.Settings(`{"scale":"linear","duration":"custom","schedule":"2031-01-01T00:00:00Z"}`), + Updates: 1, + Err: fmt.Errorf("update: %w", assert.AnError), + }, + "Updated and recorded": { + DB: stubHookDB(), + Settings: processor.Settings(`{"scale":"linear","duration":"custom","schedule":"2031-01-01T00:00:00Z"}`), + Updates: 1, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameUpdateHook, `{}`) + hk := stubHook() + + err := inp.UpdateHook(hk, c.Settings) + testutil.AssertEqualError(t, c.Err, err) + + ff := c.DB.UpdateDocumentHookCalls() + require.Len(t, ff, c.Updates) + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + assert.Equal(t, c.Touched, inp.touched) + + if err != nil { + return + } + + // the hook handed in is the one written, with the settings + // replaced and a fresh state. + assert.Equal(t, *hk, ff[0].Hk) + assert.Equal(t, c.Settings, hk.Settings) + assert.NotContains(t, string(hk.State), "2026-01-01") + assert.True(t, hk.UpdatedAt.Valid) + }) + } +} + +func Test_input_ResetHook(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.UpdateDocumentHookFunc = func(context.Context, hook.Hook) error { + return assert.AnError + } + + invalid := stubHook() + invalid.Settings = processor.Settings(`{"scale":"bogus"}`) + + cc := map[string]struct { + DB *DBMock + Hook *hook.Hook + Updates int + Notify int + Touched []Touched + Err error + }{ + "Settings the processor refuses": { + DB: stubHookDB(), + Hook: invalid, + Err: processor.ErrInvalidScaleType, + }, + "Error returned by db.UpdateDocumentHook": { + DB: failing, + Hook: stubHook(), + Updates: 1, + Err: fmt.Errorf("update: %w", assert.AnError), + }, + "Reset and recorded": { + DB: stubHookDB(), + Hook: stubHook(), + Updates: 1, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameResetHook, `{}`) + + err := inp.ResetHook(c.Hook) + testutil.AssertEqualError(t, c.Err, err) + + ff := c.DB.UpdateDocumentHookCalls() + require.Len(t, ff, c.Updates) + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + assert.Equal(t, c.Touched, inp.touched) + + if err != nil { + return + } + + // the settings stay; only the state starts over. + assert.Equal(t, *c.Hook, ff[0].Hk) + assert.Equal(t, stubHook().Settings, c.Hook.Settings) + assert.NotContains(t, string(c.Hook.State), "2026-01-01") + }) + } +} + +func Test_input_DeleteHook(t *testing.T) { + t.Parallel() + + failing := stubHookDB() + failing.DeleteDocumentHookFunc = func(context.Context, xid.ID) error { + return assert.AnError + } + + // a url watcher tears its changedetection.io watcher down from its + // state, and a state it cannot read stops the delete before the row. + watcher := stubHook() + watcher.Type = hook.TypeURLWatcher + watcher.Settings = processor.Settings(`{"url":"https://example.com"}`) + watcher.State = processor.State(`{`) + + cc := map[string]struct { + DB *DBMock + Hook *hook.Hook + Deletes int + Notify int + Touched []Touched + Err error + }{ + "External teardown failed": { + DB: stubHookDB(), + Hook: watcher, + Err: assert.AnError, + }, + "Error returned by db.DeleteDocumentHook": { + DB: failing, + Hook: stubHook(), + Deletes: 1, + Err: fmt.Errorf("delete: %w", assert.AnError), + }, + "Deleted and recorded": { + DB: stubHookDB(), + Hook: stubHook(), + Deletes: 1, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d, hooks := hookDeps(c.DB) + inp := testInput(d, NameDeleteHook, `{}`) + + err := inp.DeleteHook(c.Hook) + testutil.AssertEqualError(t, c.Err, err) + + ff := c.DB.DeleteDocumentHookCalls() + require.Len(t, ff, c.Deletes) + assert.Len(t, hooks.NotifyHooksChangeCalls(), c.Notify) + assert.Equal(t, c.Touched, inp.touched) + + if err != nil { + return + } + + assert.Equal(t, _testHookID, ff[0].ID) + }) + } +} + +func Test_input_hookChanged(t *testing.T) { + t.Parallel() + + branchless := stubHook() + branchless.BranchID = null.Value[xid.ID]{} + + cc := map[string]struct { + Hook *hook.Hook + Notifier bool + Notify int + Touched []Touched + }{ + // a hook whose branch is gone has no branch to link to; the + // notifier is told and decides for itself. + "Hook whose branch is gone": {Hook: branchless, Notifier: true, Notify: 1}, + "Without a notifier": {Hook: stubHook(), Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}}, + "With a notifier": { + Hook: stubHook(), + Notifier: true, + Notify: 1, + Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + d := testDeps(nil, nil, nil) + hooks := &HookNotifierMock{} + + if c.Notifier { + d.hooks = hooks + } + + inp := testInput(d, NameResetHook, `{}`) + inp.hookChanged(c.Hook) + + assert.Equal(t, c.Touched, inp.touched) + + ff := hooks.NotifyHooksChangeCalls() + require.Len(t, ff, c.Notify) + + if c.Notify == 0 { + return + } + + assert.Equal(t, "org", ff[0].OrganizationID) + assert.Equal(t, c.Hook.DocumentID, ff[0].DocumentID) + assert.Equal(t, c.Hook.BranchID, ff[0].BranchID) + }) + } +} diff --git a/server/core/internal/assistant/tools/testdata/tool_schemas.golden b/server/core/internal/assistant/tools/testdata/tool_schemas.golden index 8ef71a1a..f4e7f4a2 100644 --- a/server/core/internal/assistant/tools/testdata/tool_schemas.golden +++ b/server/core/internal/assistant/tools/testdata/tool_schemas.golden @@ -1 +1 @@ -{"assign_tag":{"desc":"Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"assign_tag"},"create_document":{"desc":"Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty.","type":"string"},"name":{"description":"Display name for the new document.","type":"string"},"parent_id":{"description":"Optional parent document id. Omit to create at the org root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_tag":{"desc":"Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a malformed colour or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The tag's colour as a hex triplet with the leading hash, such as \"#22c55e\".","type":"string"},"name":{"description":"Display name for the new tag; unique within the organisation.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by uid, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and update_block_text when the aim is new wording. Returns {deleted: uid}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to delete.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id to delete.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_tag":{"desc":"Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The id of the tag to delete.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"get_prometheus_metadata":{"desc":"List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_prometheus_metadata"},"get_sql_metadata":{"desc":"List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_sql_metadata"},"get_sql_query_labels":{"desc":"Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to probe. $__ macros are expanded as they are for a metric block.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"get_sql_query_labels"},"insert_block":{"desc":"Insert one canonical block into a document. position start or end puts it at the document's start or end; before or after puts it beside the block reference_block_uid names, which stays in place. The block has to be legal where it lands: the document root takes every type except titled_code, metric and split_doc_param_list, and a reference inside a split_doc or metric_grid takes only what that container holds. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"description":"A canonical block. Which fields apply depends on type; supply only that type's own.","properties":{"attrs":{"description":"Per-type attributes. Required where the type says so: heading level, titled_code title, image and figma src. Also code language, callout icon, image alt and width, and a metric's chart configuration, whose width is compact, standard or wide.","properties":{"level":{"description":"heading: the level. Required on a heading.","enum":[1,2,3],"type":"integer"},"refreshInterval":{"description":"metric: how often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"metric: draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"metric: the window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"metric: the unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"metric: the chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"}},"type":"object"},"children":{"description":"Blocks nested under a single list entry. A list entry is a paragraph; anything else nested under it goes here, never in the entry itself and never in a second entry.","type":"array"},"header":{"description":"split_doc_param_list heading, as plain text.","type":"string"},"items":{"description":"Entries of bullet_list and ordered_list, which are paragraphs; items of metric_grid, and of blockquote or callout when not using text.","type":"array"},"left":{"description":"split_doc concept side. Must start with a level-1 heading; split_doc_param_list is legal only here.","type":"array"},"params":{"description":"split_doc_param_list rows, each {name, type, description}.","type":"array"},"right":{"description":"split_doc example side. Holds titled_code or metric only, and is the one place titled_code is legal.","type":"array"},"task_items":{"description":"Rows of task_list, each {checked, block}.","type":"array"},"text":{"description":"Inline text for paragraph, heading, blockquote, code, titled_code, mermaid and callout. Raw inside code, titled_code and mermaid; minimal markdown elsewhere. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"description":"The block's canonical type.","enum":["paragraph","heading","blockquote","bullet_list","ordered_list","task_list","callout","code","titled_code","mermaid","horizontal_rule","image","file","figma","metric","metric_grid","split_doc","split_doc_param_list"],"type":"string"},"uid":{"description":"Leave unset. Uids are generated server-side and are the handle later edits use.","type":"string"}},"required":["type"],"type":"object"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Where the block lands: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","block"],"type":"object"},"name":"insert_block"},"list_data_sources":{"desc":"List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. Return only the direct children of this document. Omit for the full tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_prometheus_label_names":{"desc":"List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_label_names"},"list_prometheus_label_values":{"desc":"List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"label":{"description":"The label whose values to list, e.g. \"job\".","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","label"],"type":"object"},"name":"list_prometheus_label_values"},"list_prometheus_series":{"desc":"List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move an existing block before or after another block in the same document. The block keeps its uid, attrs and nested content, so comments, hooks and files attached to it stay attached; deleting it and inserting a copy would lose them. The landing spot has to accept the block's type, by the same rule insert_block applies. Returns the summary rows of the moved block and everything nested in it.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to move.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Landing side relative to the reference block.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to move relative to.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"move_tag":{"desc":"Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.","has_params_one_of":true,"json_schema":{"properties":{"sort_index":{"description":"The 0-based position the tag should end up at among the organisation's tags.","type":"integer"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id","sort_index"],"type":"object"},"name":"move_tag"},"query_prometheus":{"desc":"Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The PromQL expression to run.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_prometheus"},"query_sql":{"desc":"Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_sql"},"read_block":{"desc":"Return the full canonical content of one block by uid, including any nested children. Use it only when get_document's rows are not enough: to edit a split_doc, a nested list or a split_doc_param_list, whose inner structure has to be written back in full. Fails when the uid is not in the document.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The block uid to fetch.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"read_block"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_block":{"desc":"Replace a block by uid with a new block in the same position. The old block's uid, content and children are all gone unless the new block carries them, so use it to change a block's type or its whole structure. For a wording change use update_block_text, and for an attribute change update_block_attrs; both keep the uid, which comments, hooks and files hang off. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"description":"A canonical block. Which fields apply depends on type; supply only that type's own.","properties":{"attrs":{"description":"Per-type attributes. Required where the type says so: heading level, titled_code title, image and figma src. Also code language, callout icon, image alt and width, and a metric's chart configuration, whose width is compact, standard or wide.","properties":{"level":{"description":"heading: the level. Required on a heading.","enum":[1,2,3],"type":"integer"},"refreshInterval":{"description":"metric: how often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"metric: draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"metric: the window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"metric: the unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"metric: the chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"}},"type":"object"},"children":{"description":"Blocks nested under a single list entry. A list entry is a paragraph; anything else nested under it goes here, never in the entry itself and never in a second entry.","type":"array"},"header":{"description":"split_doc_param_list heading, as plain text.","type":"string"},"items":{"description":"Entries of bullet_list and ordered_list, which are paragraphs; items of metric_grid, and of blockquote or callout when not using text.","type":"array"},"left":{"description":"split_doc concept side. Must start with a level-1 heading; split_doc_param_list is legal only here.","type":"array"},"params":{"description":"split_doc_param_list rows, each {name, type, description}.","type":"array"},"right":{"description":"split_doc example side. Holds titled_code or metric only, and is the one place titled_code is legal.","type":"array"},"task_items":{"description":"Rows of task_list, each {checked, block}.","type":"array"},"text":{"description":"Inline text for paragraph, heading, blockquote, code, titled_code, mermaid and callout. Raw inside code, titled_code and mermaid; minimal markdown elsewhere. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"description":"The block's canonical type.","enum":["paragraph","heading","blockquote","bullet_list","ordered_list","task_list","callout","code","titled_code","mermaid","horizontal_rule","image","file","figma","metric","metric_grid","split_doc","split_doc_param_list"],"type":"string"},"uid":{"description":"Leave unset. Uids are generated server-side and are the handle later edits use.","type":"string"}},"required":["type"],"type":"object"},"block_uid":{"description":"The uid of the block being replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","block"],"type":"object"},"name":"replace_block"},"search_documents":{"desc":"Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Maximum number of hits to return. Defaults to 20; cap is 50.","type":"integer"},"query":{"description":"The full-text search query (typo-tolerant, matches block text).","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"unassign_tag":{"desc":"Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"unassign_tag"},"update_block_attrs":{"desc":"Set or override named attributes on an existing block, such as a heading's level or a callout's icon. Attributes not mentioned are kept and uid cannot change. Values are validated for the block's type, so a level outside 1 to 3 or a metric width other than compact, standard or wide is rejected. Use replace_block when the type itself has to change. Returns the summary rows of the block and everything nested in it, attrs as they now stand.","has_params_one_of":true,"json_schema":{"properties":{"attrs":{"description":"Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).","type":"object"},"block_uid":{"description":"The uid of the block whose attrs should be updated.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","attrs"],"type":"object"},"name":"update_block_attrs"},"update_block_text":{"desc":"Replace the inline text of one text-bearing block: paragraph, heading, blockquote, code, titled_code, mermaid, or a callout written with text. Type, attrs and uid are kept, so this is the tool for wording changes. Text follows the canonical markdown subset (**bold**, *italic*, _underline_, ~~strike~~, backtick code, [label](url)), and is raw in code, titled_code and mermaid. One block is one paragraph; to add a paragraph, insert a block instead. Returns the block's summary row with the new text.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block whose text should be replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"text":{"description":"New inline text in canonical markdown.","type":"string"}},"required":["document_id","branch_id","block_uid","text"],"type":"object"},"name":"update_block_text"},"update_document":{"desc":"Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. The new icon identifier; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_tag":{"desc":"Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour as a hex triplet with the leading hash; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}} \ No newline at end of file +{"assign_tag":{"desc":"Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"assign_tag"},"create_document":{"desc":"Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty.","type":"string"},"name":{"description":"Display name for the new document.","type":"string"},"parent_id":{"description":"Optional parent document id. Omit to create at the org root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_hook":{"desc":"Add a freshness hook to one branch of a document and return it as list_hooks would. Supply type and, in settings, only that type's fields: scheduled-reminder takes schedule; github-tracking takes repository, branch and paths; url-watcher takes url; container-image-watcher takes image. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole.","type":"string"},"branch_id":{"description":"The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"settings":{"description":"The hook's settings. Which fields apply depends on the hook type; supply only that type's own.","properties":{"branch":{"description":"github-tracking: the repository branch to follow.","type":"string"},"image":{"description":"container-image-watcher: the container image reference to watch for new digests.","type":"string"},"paths":{"description":"github-tracking: the repository paths whose changes mark the document stale; at least one.","items":{"type":"string"},"type":"array"},"repository":{"description":"github-tracking: the repository to follow, as owner/name.","type":"string"},"schedule":{"description":"scheduled-reminder: the RFC 3339 timestamp at which the reminder is due.","type":"string"},"url":{"description":"url-watcher: the web address to watch for changes.","type":"string"}},"type":"object"},"type":{"description":"The hook type, which decides the fields settings takes.","enum":["scheduled-reminder","github-tracking","url-watcher","container-image-watcher"],"type":"string"}},"required":["document_id","branch_id","type","settings"],"type":"object"},"name":"create_hook"},"create_tag":{"desc":"Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a malformed colour or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The tag's colour as a hex triplet with the leading hash, such as \"#22c55e\".","type":"string"},"name":{"description":"Display name for the new tag; unique within the organisation.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by uid, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and update_block_text when the aim is new wording. Returns {deleted: uid}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to delete.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id to delete.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_hook":{"desc":"Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"delete_hook"},"delete_tag":{"desc":"Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The id of the tag to delete.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"get_prometheus_metadata":{"desc":"List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_prometheus_metadata"},"get_sql_metadata":{"desc":"List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_sql_metadata"},"get_sql_query_labels":{"desc":"Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to probe. $__ macros are expanded as they are for a metric block.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"get_sql_query_labels"},"insert_block":{"desc":"Insert one canonical block into a document. position start or end puts it at the document's start or end; before or after puts it beside the block reference_block_uid names, which stays in place. The block has to be legal where it lands: the document root takes every type except titled_code, metric and split_doc_param_list, and a reference inside a split_doc or metric_grid takes only what that container holds. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"description":"A canonical block. Which fields apply depends on type; supply only that type's own.","properties":{"attrs":{"description":"Per-type attributes. Required where the type says so: heading level, titled_code title, image and figma src. Also code language, callout icon, image alt and width, and a metric's chart configuration, whose width is compact, standard or wide.","properties":{"level":{"description":"heading: the level. Required on a heading.","enum":[1,2,3],"type":"integer"},"refreshInterval":{"description":"metric: how often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"metric: draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"metric: the window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"metric: the unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"metric: the chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"}},"type":"object"},"children":{"description":"Blocks nested under a single list entry. A list entry is a paragraph; anything else nested under it goes here, never in the entry itself and never in a second entry.","type":"array"},"header":{"description":"split_doc_param_list heading, as plain text.","type":"string"},"items":{"description":"Entries of bullet_list and ordered_list, which are paragraphs; items of metric_grid, and of blockquote or callout when not using text.","type":"array"},"left":{"description":"split_doc concept side. Must start with a level-1 heading; split_doc_param_list is legal only here.","type":"array"},"params":{"description":"split_doc_param_list rows, each {name, type, description}.","type":"array"},"right":{"description":"split_doc example side. Holds titled_code or metric only, and is the one place titled_code is legal.","type":"array"},"task_items":{"description":"Rows of task_list, each {checked, block}.","type":"array"},"text":{"description":"Inline text for paragraph, heading, blockquote, code, titled_code, mermaid and callout. Raw inside code, titled_code and mermaid; minimal markdown elsewhere. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"description":"The block's canonical type.","enum":["paragraph","heading","blockquote","bullet_list","ordered_list","task_list","callout","code","titled_code","mermaid","horizontal_rule","image","file","figma","metric","metric_grid","split_doc","split_doc_param_list"],"type":"string"},"uid":{"description":"Leave unset. Uids are generated server-side and are the handle later edits use.","type":"string"}},"required":["type"],"type":"object"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Where the block lands: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","block"],"type":"object"},"name":"insert_block"},"list_data_sources":{"desc":"List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. Return only the direct children of this document. Omit for the full tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_hooks":{"desc":"List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"list_hooks"},"list_prometheus_label_names":{"desc":"List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_label_names"},"list_prometheus_label_values":{"desc":"List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"label":{"description":"The label whose values to list, e.g. \"job\".","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","label"],"type":"object"},"name":"list_prometheus_label_values"},"list_prometheus_series":{"desc":"List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move an existing block before or after another block in the same document. The block keeps its uid, attrs and nested content, so comments, hooks and files attached to it stay attached; deleting it and inserting a copy would lose them. The landing spot has to accept the block's type, by the same rule insert_block applies. Returns the summary rows of the moved block and everything nested in it.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to move.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Landing side relative to the reference block.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to move relative to.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"move_tag":{"desc":"Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.","has_params_one_of":true,"json_schema":{"properties":{"sort_index":{"description":"The 0-based position the tag should end up at among the organisation's tags.","type":"integer"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id","sort_index"],"type":"object"},"name":"move_tag"},"query_prometheus":{"desc":"Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The PromQL expression to run.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_prometheus"},"query_sql":{"desc":"Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_sql"},"read_block":{"desc":"Return the full canonical content of one block by uid, including any nested children. Use it only when get_document's rows are not enough: to edit a split_doc, a nested list or a split_doc_param_list, whose inner structure has to be written back in full. Fails when the uid is not in the document.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The block uid to fetch.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"read_block"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_block":{"desc":"Replace a block by uid with a new block in the same position. The old block's uid, content and children are all gone unless the new block carries them, so use it to change a block's type or its whole structure. For a wording change use update_block_text, and for an attribute change update_block_attrs; both keep the uid, which comments, hooks and files hang off. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"description":"A canonical block. Which fields apply depends on type; supply only that type's own.","properties":{"attrs":{"description":"Per-type attributes. Required where the type says so: heading level, titled_code title, image and figma src. Also code language, callout icon, image alt and width, and a metric's chart configuration, whose width is compact, standard or wide.","properties":{"level":{"description":"heading: the level. Required on a heading.","enum":[1,2,3],"type":"integer"},"refreshInterval":{"description":"metric: how often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"metric: draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"metric: the window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"metric: the unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"metric: the chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"}},"type":"object"},"children":{"description":"Blocks nested under a single list entry. A list entry is a paragraph; anything else nested under it goes here, never in the entry itself and never in a second entry.","type":"array"},"header":{"description":"split_doc_param_list heading, as plain text.","type":"string"},"items":{"description":"Entries of bullet_list and ordered_list, which are paragraphs; items of metric_grid, and of blockquote or callout when not using text.","type":"array"},"left":{"description":"split_doc concept side. Must start with a level-1 heading; split_doc_param_list is legal only here.","type":"array"},"params":{"description":"split_doc_param_list rows, each {name, type, description}.","type":"array"},"right":{"description":"split_doc example side. Holds titled_code or metric only, and is the one place titled_code is legal.","type":"array"},"task_items":{"description":"Rows of task_list, each {checked, block}.","type":"array"},"text":{"description":"Inline text for paragraph, heading, blockquote, code, titled_code, mermaid and callout. Raw inside code, titled_code and mermaid; minimal markdown elsewhere. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"description":"The block's canonical type.","enum":["paragraph","heading","blockquote","bullet_list","ordered_list","task_list","callout","code","titled_code","mermaid","horizontal_rule","image","file","figma","metric","metric_grid","split_doc","split_doc_param_list"],"type":"string"},"uid":{"description":"Leave unset. Uids are generated server-side and are the handle later edits use.","type":"string"}},"required":["type"],"type":"object"},"block_uid":{"description":"The uid of the block being replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","block"],"type":"object"},"name":"replace_block"},"reset_hook":{"desc":"Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"reset_hook"},"search_documents":{"desc":"Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Maximum number of hits to return. Defaults to 20; cap is 50.","type":"integer"},"query":{"description":"The full-text search query (typo-tolerant, matches block text).","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"unassign_tag":{"desc":"Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"unassign_tag"},"update_block_attrs":{"desc":"Set or override named attributes on an existing block, such as a heading's level or a callout's icon. Attributes not mentioned are kept and uid cannot change. Values are validated for the block's type, so a level outside 1 to 3 or a metric width other than compact, standard or wide is rejected. Use replace_block when the type itself has to change. Returns the summary rows of the block and everything nested in it, attrs as they now stand.","has_params_one_of":true,"json_schema":{"properties":{"attrs":{"description":"Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).","type":"object"},"block_uid":{"description":"The uid of the block whose attrs should be updated.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","attrs"],"type":"object"},"name":"update_block_attrs"},"update_block_text":{"desc":"Replace the inline text of one text-bearing block: paragraph, heading, blockquote, code, titled_code, mermaid, or a callout written with text. Type, attrs and uid are kept, so this is the tool for wording changes. Text follows the canonical markdown subset (**bold**, *italic*, _underline_, ~~strike~~, backtick code, [label](url)), and is raw in code, titled_code and mermaid. One block is one paragraph; to add a paragraph, insert a block instead. Returns the block's summary row with the new text.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block whose text should be replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"text":{"description":"New inline text in canonical markdown.","type":"string"}},"required":["document_id","branch_id","block_uid","text"],"type":"object"},"name":"update_block_text"},"update_document":{"desc":"Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. The new icon identifier; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_hook":{"desc":"Replace the settings of a hook and reset its score and state, returning it as list_hooks would. The hook keeps its type, so give exactly that type's fields in settings: scheduled-reminder takes schedule; github-tracking takes repository, branch and paths; url-watcher takes url; container-image-watcher takes image. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. To change the type, delete the hook and create another.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"},"settings":{"description":"The hook's settings. Which fields apply depends on the hook type; supply only that type's own.","properties":{"branch":{"description":"github-tracking: the repository branch to follow.","type":"string"},"image":{"description":"container-image-watcher: the container image reference to watch for new digests.","type":"string"},"paths":{"description":"github-tracking: the repository paths whose changes mark the document stale; at least one.","items":{"type":"string"},"type":"array"},"repository":{"description":"github-tracking: the repository to follow, as owner/name.","type":"string"},"schedule":{"description":"scheduled-reminder: the RFC 3339 timestamp at which the reminder is due.","type":"string"},"url":{"description":"url-watcher: the web address to watch for changes.","type":"string"}},"type":"object"}},"required":["document_id","hook_id","settings"],"type":"object"},"name":"update_hook"},"update_tag":{"desc":"Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour as a hex triplet with the leading hash; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}} \ No newline at end of file diff --git a/server/core/internal/assistant/tools/tool.go b/server/core/internal/assistant/tools/tool.go index 03debd34..8acefc46 100644 --- a/server/core/internal/assistant/tools/tool.go +++ b/server/core/internal/assistant/tools/tool.go @@ -9,6 +9,8 @@ import ( "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/oxynote/oxynote/server/core/internal/datasource" "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" "github.com/oxynote/oxynote/server/core/internal/search" "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/rs/xid" @@ -210,6 +212,10 @@ type DescribeInput interface { // FetchTag should return the tag the id names, with the documents // carrying it, for a description that has to name its subject. FetchTag(tagID xid.ID) (*tag.Summary, error) + + // FetchHook should return the hook the id names on the document, for + // a description that has to name its subject. + FetchHook(documentID, hookID xid.ID) (*hook.Hook, error) } // DataSources is the organisation's outbound data-source connections as @@ -338,6 +344,36 @@ type TagWriter interface { NotifyBranchTagsChange(documentID, branchID xid.ID) } +// Hooks is the freshness hooks on a document's branches as a tool reads +// them. Every method is already scoped to the session's organisation. +type Hooks interface { + // FetchHooks should return every hook on the branch branchID names, + // refusing a branch the document does not have. + FetchHooks(documentID, branchID xid.ID) ([]hook.Hook, error) +} + +// HookWriter changes the hooks on a document's branches and announces +// the change so an open editor stays in step. +type HookWriter interface { + // CreateHook should create a hook of the type with the settings on + // the branch branchID names, anchored to the block blockUID names + // when one is given, refusing a branch or block that does not exist + // and a type whose integration the deployment lacks. + CreateHook(documentID, branchID xid.ID, blockUID string, t hook.Type, settings processor.Settings) (*hook.Hook, error) + + // UpdateHook should replace the hook's settings and reset its score + // and state. + UpdateHook(hk *hook.Hook, settings processor.Settings) error + + // ResetHook should restore the hook's score and state, keeping its + // settings. + ResetHook(hk *hook.Hook) error + + // DeleteHook should tear down what the hook holds outside the + // document and remove it. + DeleteHook(hk *hook.Hook) error +} + // Editor applies content changes to a live document through the // realtime service, so connected editors see them as they land. type Editor interface { @@ -376,6 +412,8 @@ type Input interface { DataSources Tags TagWriter + Hooks + HookWriter Editor // OrganizationID should return the organisation every call is diff --git a/server/core/internal/assistant/tools/tools.go b/server/core/internal/assistant/tools/tools.go index 13639c7f..88f7b23f 100644 --- a/server/core/internal/assistant/tools/tools.go +++ b/server/core/internal/assistant/tools/tools.go @@ -1,5 +1,5 @@ // Package tools owns the AI assistant's tool surface. Tools are grouped -// by what they act on — documents, blocks, tags, the search index — and each +// by what they act on — documents, blocks, tags, hooks, the search index — and each // describes itself, names what it is about to do, and does its work in // this package's own vocabulary; eino.go is the only place that speaks // the agent framework's. All tools execute server-side — there is no @@ -29,6 +29,7 @@ const ( NameReadBlock Name = "read_block" NameSearchDocuments Name = "search_documents" NameListTags Name = "list_tags" + NameListHooks Name = "list_hooks" ) // Data-source tools — reads against the organisation's outbound @@ -65,6 +66,10 @@ const ( NameAssignTag Name = "assign_tag" NameUnassignTag Name = "unassign_tag" NameMoveTag Name = "move_tag" + NameCreateHook Name = "create_hook" + NameUpdateHook Name = "update_hook" + NameResetHook Name = "reset_hook" + NameDeleteHook Name = "delete_hook" ) // Set is the tools one session offers the model: an immutable registry @@ -102,6 +107,7 @@ func New(deps *Deps) *Set { getDocument{}, readBlock{}, listTags{}, + listHooks{}, } // a deployment without search has no index behind search_documents, @@ -137,6 +143,10 @@ func New(deps *Deps) *Set { assignTag{}, unassignTag{}, moveTag{}, + createHook{}, + updateHook{}, + resetHook{}, + deleteHook{}, readToolOutput{}, ) diff --git a/server/core/internal/assistant/tools/tools_test.go b/server/core/internal/assistant/tools/tools_test.go index c765bdc0..ef798d4e 100644 --- a/server/core/internal/assistant/tools/tools_test.go +++ b/server/core/internal/assistant/tools/tools_test.go @@ -21,6 +21,7 @@ func allToolNames() []Name { NameGetDocument, NameReadBlock, NameListTags, + NameListHooks, NameSearchDocuments, NameListDataSources, NameGetPrometheusMetadata, @@ -46,6 +47,10 @@ func allToolNames() []Name { NameAssignTag, NameUnassignTag, NameMoveTag, + NameCreateHook, + NameUpdateHook, + NameResetHook, + NameDeleteHook, NameReadToolOutput, } } @@ -164,7 +169,7 @@ func Test_Set_Entries(t *testing.T) { entries := s.Entries() require.Len(t, entries, len(allToolNames())) - destructive := []Name{NameDeleteDocument, NameDeleteBlock, NameDeleteTag} + destructive := []Name{NameDeleteDocument, NameDeleteBlock, NameDeleteTag, NameDeleteHook} for i, e := range entries { assert.Equal(t, allToolNames()[i], e.Name) @@ -306,6 +311,10 @@ func Test_Set_WriteNames(t *testing.T) { string(NameAssignTag), string(NameUnassignTag), string(NameMoveTag), + string(NameCreateHook), + string(NameUpdateHook), + string(NameResetHook), + string(NameDeleteHook), }, got) // mutating the returned slice must not affect the registry. diff --git a/server/core/internal/server/internal/document/hook/http.go b/server/core/internal/server/internal/document/hook/http.go index bcc5b69b..d4687449 100644 --- a/server/core/internal/server/internal/document/hook/http.go +++ b/server/core/internal/server/internal/document/hook/http.go @@ -24,12 +24,20 @@ var ErrBranchMismatch = errutil.New(http.StatusNotFound, "document.branch_mismat // document identified by the request path. var ErrHookMismatch = errutil.New(http.StatusNotFound, "document.hook_mismatch", "hook does not belong to the document") +// ErrBlockNotFound is returned when a hook is to be anchored to a block the +// branch's content does not hold. +var ErrBlockNotFound = errutil.New(http.StatusNotFound, "document.hook_block_not_found", "block not found in the branch") + // Handler holds dependencies required for document hook operations. type Handler struct { log *slog.Logger db DB githubMan *github.Manager webchangeClient *webchange.Client + + hooks struct { + changeCallback func(organizationID string, documentID, branchID xid.ID) + } } // NewHandler creates a new handler instance with the provided logger and database. @@ -131,6 +139,16 @@ func (h *Handler) CreateDocumentHook(w http.ResponseWriter, r *http.Request) { return } + // a hook anchored to a block the branch does not hold is invisible in + // the editor and soft-deleted by the next sweep, so it is refused + // rather than created to vanish. + if hi.BlockID.Valid { + if _, ok := branchDoc.Content.FindByUID(hi.BlockID.String); !ok { + httpserver.RespondError(h.log, w, ErrBlockNotFound) + return + } + } + hk, err := hookCore.NewHook( r.Context(), hi, @@ -153,6 +171,8 @@ func (h *Handler) CreateDocumentHook(w http.ResponseWriter, r *http.Request) { return } + h.NotifyHooksChange(session.ActiveOrganizationID, hk.DocumentID, hk.BranchID) + httpserver.Respond( h.log, w, @@ -219,6 +239,8 @@ func (h *Handler) UpdateDocumentHook(w http.ResponseWriter, r *http.Request) { return } + h.NotifyHooksChange(session.ActiveOrganizationID, hk.DocumentID, hk.BranchID) + httpserver.Respond( h.log, w, @@ -274,6 +296,8 @@ func (h *Handler) ResetDocumentHook(w http.ResponseWriter, r *http.Request) { return } + h.NotifyHooksChange(session.ActiveOrganizationID, hk.DocumentID, hk.BranchID) + httpserver.Respond( h.log, w, @@ -329,6 +353,8 @@ func (h *Handler) DeleteDocumentHook(w http.ResponseWriter, r *http.Request) { return } + h.NotifyHooksChange(session.ActiveOrganizationID, hk.DocumentID, hk.BranchID) + httpserver.Respond( h.log, w, diff --git a/server/core/internal/server/internal/document/hook/http_test.go b/server/core/internal/server/internal/document/hook/http_test.go index ed4e0d5a..932dc7bc 100644 --- a/server/core/internal/server/internal/document/hook/http_test.go +++ b/server/core/internal/server/internal/document/hook/http_test.go @@ -49,6 +49,7 @@ func urlWatcherHook() *hookCore.Hook { Type: hookCore.TypeURLWatcher, DocumentID: null.ValueFrom(_documentID), OrganizationID: null.StringFrom("org1"), + BranchID: null.ValueFrom(_branchID), Settings: processor.Settings(`{"url":"https://example.com"}`), State: processor.State(`{"watcherId":"w1"}`), } @@ -62,10 +63,43 @@ func scheduledHook(typ hookCore.Type) *hookCore.Hook { Type: typ, DocumentID: null.ValueFrom(_documentID), OrganizationID: null.StringFrom("org1"), + BranchID: null.ValueFrom(_branchID), Settings: processor.Settings(`{"scale":"linear"}`), } } +// notified is one hooks-change announcement a handler made. +type notified struct { + organizationID string + documentID xid.ID + branchID xid.ID +} + +// recordNotifications binds the handler's hooks-change callback to a +// list the test reads back. +func recordNotifications(hdl *Handler) *[]notified { + var out []notified + + hdl.hooks.changeCallback = func(organizationID string, documentID, branchID xid.ID) { + out = append(out, notified{organizationID, documentID, branchID}) + } + + return &out +} + +// assertNotified checks that a write that succeeded announced the branch +// once and one that failed announced nothing. +func assertNotified(t *testing.T, got []notified, code int) { + t.Helper() + + if code >= http.StatusBadRequest { + assert.Empty(t, got) + return + } + + assert.Equal(t, []notified{{"org1", _documentID, _branchID}}, got) +} + func Test_NewHandler(t *testing.T) { t.Parallel() @@ -212,6 +246,7 @@ func Test_Handler_FetchDocumentHooks(t *testing.T) { db: c.DB, webchangeClient: webchange.NewClient("", ""), } + got := recordNotifications(&hdl) req := httptest.NewRequest(http.MethodGet, "http://test.com/"+c.Query, http.NoBody) @@ -229,6 +264,9 @@ func Test_Handler_FetchDocumentHooks(t *testing.T) { hdl.FetchDocumentHooks(rec, req.WithContext(ctx)) + // a read announces nothing. + assert.Empty(t, *got) + for _, ch := range c.Checks { ch(t, c.DB, rec) } @@ -349,6 +387,28 @@ func Test_Handler_CreateDocumentHook(t *testing.T) { wasInsertCalled(0), ), }, + "Block not in the branch": { + DB: &DBMock{}, + Body: `{"type":"scheduled-reminder","branchId":"` + _branchID.String() + `","blockId":"nope","settings":{"scale":"linear"}}`, + Checks: checks( + hasResp(http.StatusNotFound, `{"code":"document.hook_block_not_found","message":"block not found in the branch"}`), + wasInsertCalled(0), + ), + }, + "Successful creation on a block": { + DB: &DBMock{}, + Body: `{"type":"scheduled-reminder","branchId":"` + _branchID.String() + `","blockId":"b1","settings":{"scale":"linear"}}`, + Checks: checks( + func(t *testing.T, db *DBMock, rec *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusCreated, rec.Code) + + ff := db.InsertDocumentHookCalls() + require.Len(t, ff, 1) + assert.Equal(t, null.StringFrom("b1"), ff[0].Hk.BlockID) + }, + wasInsertCalled(1), + ), + }, "URL watcher without changedetection": { DB: &DBMock{}, Body: `{"type":"url-watcher","branchId":"` + _branchID.String() + `","settings":{"url":"https://example.com"}}`, @@ -374,9 +434,19 @@ func Test_Handler_CreateDocumentHook(t *testing.T) { t.Run(cn, func(t *testing.T) { t.Parallel() + // the branch holds one block, so a hook can anchor to it and + // to nothing else. if c.DB.FetchDocumentByBranchIDFunc == nil { c.DB.FetchDocumentByBranchIDFunc = func(context.Context, xid.ID, string) (*document.Document, error) { - return &document.Document{ID: _documentID}, nil + return &document.Document{ + ID: _documentID, + Content: document.RootBlock{ + Content: []document.Block{{ + Type: document.BlockNodeParagraph, + Attrs: document.Attributes{document.AttrUID: "b1"}, + }}, + }, + }, nil } } @@ -385,6 +455,7 @@ func Test_Handler_CreateDocumentHook(t *testing.T) { db: c.DB, webchangeClient: webchange.NewClient("", ""), } + got := recordNotifications(&hdl) req := httptest.NewRequest(http.MethodPost, "http://test.com/", strings.NewReader(c.Body)) @@ -402,6 +473,8 @@ func Test_Handler_CreateDocumentHook(t *testing.T) { hdl.CreateDocumentHook(rec, req.WithContext(ctx)) + assertNotified(t, *got, rec.Code) + for _, ch := range c.Checks { ch(t, c.DB, rec) } @@ -583,6 +656,7 @@ func Test_Handler_UpdateDocumentHook(t *testing.T) { db: c.DB, webchangeClient: webchange.NewClient("", ""), } + got := recordNotifications(&hdl) req := httptest.NewRequest(http.MethodPut, "http://test.com/", strings.NewReader(c.Body)) @@ -604,6 +678,8 @@ func Test_Handler_UpdateDocumentHook(t *testing.T) { hdl.UpdateDocumentHook(rec, req.WithContext(ctx)) + assertNotified(t, *got, rec.Code) + for _, ch := range c.Checks { ch(t, c.DB, rec) } @@ -727,6 +803,7 @@ func Test_Handler_ResetDocumentHook(t *testing.T) { db: c.DB, webchangeClient: webchange.NewClient("", ""), } + got := recordNotifications(&hdl) req := httptest.NewRequest(http.MethodPost, "http://test.com/", http.NoBody) @@ -748,6 +825,8 @@ func Test_Handler_ResetDocumentHook(t *testing.T) { hdl.ResetDocumentHook(rec, req.WithContext(ctx)) + assertNotified(t, *got, rec.Code) + assert.Equal(t, c.RespCode, rec.Code) for _, ch := range c.Checks { @@ -875,6 +954,7 @@ func Test_Handler_DeleteDocumentHook(t *testing.T) { db: c.DB, webchangeClient: webchange.NewClient("", ""), } + got := recordNotifications(&hdl) req := httptest.NewRequest(http.MethodDelete, "http://test.com/", http.NoBody) @@ -896,6 +976,8 @@ func Test_Handler_DeleteDocumentHook(t *testing.T) { hdl.DeleteDocumentHook(rec, req.WithContext(ctx)) + assertNotified(t, *got, rec.Code) + assert.Equal(t, c.RespCode, rec.Code) for _, ch := range c.Checks { diff --git a/server/core/internal/server/internal/document/hook/ws.go b/server/core/internal/server/internal/document/hook/ws.go new file mode 100644 index 00000000..6fbf6d96 --- /dev/null +++ b/server/core/internal/server/internal/document/hook/ws.go @@ -0,0 +1,51 @@ +package hook + +import ( + "context" + "time" + + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/server/internal/auth" + "github.com/oxynote/wetsocks/wsserver" + "github.com/rs/xid" +) + +// _publishTimeout bounds each WebSocket publish triggered by a domain +// callback. +const _publishTimeout = 5 * time.Second + +// HooksChangeMessage represents a change in the hooks one branch of a +// document carries. It names the branch so a subscriber showing another +// branch of the same document can leave its own list alone. +type HooksChangeMessage struct { + // BranchID is the branch whose hooks changed. + BranchID xid.ID `json:"branchId"` +} + +// BindHooksChange binds a hooks change event to the given topic, which +// is scoped to one document. +func (h *Handler) BindHooksChange(tpc wsserver.Topic) { + h.hooks.changeCallback = func(organizationID string, documentID, branchID xid.ID) { + ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout) + defer cancel() + + tpc.PublishMany( + ctx, + HooksChangeMessage{BranchID: branchID}, + auth.FilterOrganizationDocument(organizationID, documentID), + ) + } +} + +// NotifyHooksChange announces that the hooks on a document's branch +// changed to every subscriber of that document in the organization. Safe +// to call before BindHooksChange has been invoked: no subscribers means +// it is a no-op. A hook whose document or branch is gone has no editor +// to redraw, so a null id is a no-op too. +func (h *Handler) NotifyHooksChange(organizationID string, documentID, branchID null.Value[xid.ID]) { + if h.hooks.changeCallback == nil || !documentID.Valid || !branchID.Valid { + return + } + + h.hooks.changeCallback(organizationID, documentID.V, branchID.V) +} diff --git a/server/core/internal/server/internal/document/hook/ws_test.go b/server/core/internal/server/internal/document/hook/ws_test.go new file mode 100644 index 00000000..816abb14 --- /dev/null +++ b/server/core/internal/server/internal/document/hook/ws_test.go @@ -0,0 +1,67 @@ +package hook + +import ( + "context" + "testing" + + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/server/internal/auth" + "github.com/oxynote/wetsocks/wsserver" + wsMock "github.com/oxynote/wetsocks/wsserver/_mock" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sessionCtx returns a subscriber context carrying a test session. +func sessionCtx() context.Context { + return auth.AddSessionToContext(context.Background(), auth.Session{ + UserID: "u1", + ActiveOrganizationID: "org1", + }) +} + +// docTopicCtx returns a subscriber context for the given document topic. +func docTopicCtx(documentID xid.ID) context.Context { + return wsserver.NewTopicParamsContext(sessionCtx(), map[string]string{"documentId": documentID.String()}) +} + +func Test_Handler_BindHooksChange(t *testing.T) { + t.Parallel() + + hdl := &Handler{} + tpc := &wsMock.Topic{} + + hdl.BindHooksChange(tpc) + require.NotNil(t, hdl.hooks.changeCallback) +} + +func Test_Handler_NotifyHooksChange(t *testing.T) { + t.Parallel() + + hdl := &Handler{} + + // notifying before binding must be a safe no-op. + hdl.NotifyHooksChange("org1", null.ValueFrom(_documentID), null.ValueFrom(_branchID)) + + tpc := &wsMock.Topic{} + + hdl.BindHooksChange(tpc) + + // a hook whose branch or document is gone has no editor to redraw. + hdl.NotifyHooksChange("org1", null.ValueFrom(_documentID), null.Value[xid.ID]{}) + hdl.NotifyHooksChange("org1", null.Value[xid.ID]{}, null.ValueFrom(_branchID)) + require.Empty(t, tpc.PublishManyCalls()) + + hdl.NotifyHooksChange("org1", null.ValueFrom(_documentID), null.ValueFrom(_branchID)) + + pubs := tpc.PublishManyCalls() + require.Len(t, pubs, 1) + assert.Equal(t, HooksChangeMessage{BranchID: _branchID}, pubs[0].Payload) + + // only the subscribers of that document in the organization hear it + assert.True(t, pubs[0].Filter(docTopicCtx(_documentID), "topic")) + assert.False(t, pubs[0].Filter(docTopicCtx(xid.New()), "topic")) + assert.False(t, pubs[0].Filter(sessionCtx(), "topic")) + assert.False(t, pubs[0].Filter(context.Background(), "topic")) +} diff --git a/server/core/internal/server/internal/mcp/http_test.go b/server/core/internal/server/internal/mcp/http_test.go index 3366c7f1..9394a9cd 100644 --- a/server/core/internal/server/internal/mcp/http_test.go +++ b/server/core/internal/server/internal/mcp/http_test.go @@ -52,10 +52,13 @@ func stubSet(db *toolsMock.DB) *tools.Set { }, search.NewJobs(true), nil, + nil, + nil, &toolsMock.EditApplier{}, nil, nil, nil, + nil, "org1", "user1", )) diff --git a/server/core/internal/server/internal/mcp/server_test.go b/server/core/internal/server/internal/mcp/server_test.go index 804e6530..08bd0a22 100644 --- a/server/core/internal/server/internal/mcp/server_test.go +++ b/server/core/internal/server/internal/mcp/server_test.go @@ -14,6 +14,7 @@ var _readToolNames = []string{ "get_document", "read_block", "list_tags", + "list_hooks", "search_documents", } @@ -35,6 +36,10 @@ var _writeToolNames = []string{ "assign_tag", "unassign_tag", "move_tag", + "create_hook", + "update_hook", + "reset_hook", + "delete_hook", } // _dataSourceToolNames is every data-source tool the MCP surface serves diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index 2d424b20..38e74fd3 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -82,6 +82,12 @@ func (s *Server) wsRouter() *wsserver.Router { }, ) + binderFn("change@documents.{documentId}.hooks", + func(tpc wsserver.Topic) { + s.handlers.hook.BindHooksChange(tpc) + }, + ) + // slack messages binderFn("post@slack.messages", func(tpc wsserver.Topic) { diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go index 81346b86..b23fe939 100644 --- a/server/core/internal/server/server.go +++ b/server/core/internal/server/server.go @@ -190,6 +190,7 @@ func NewServer( // rename/set-icon. assistantMan.SetTreeNotifier(srv.handlers.document) assistantMan.SetTagNotifier(srv.handlers.tag) + assistantMan.SetHookNotifier(srv.handlers.hook) // the MCP handler serves the assistant's tool registry over the // Model Context Protocol; it must be built after the tree notifier diff --git a/server/core/internal/server/server_test.go b/server/core/internal/server/server_test.go index 2dd1a432..945730af 100644 --- a/server/core/internal/server/server_test.go +++ b/server/core/internal/server/server_test.go @@ -80,11 +80,11 @@ func Test_NewServer(t *testing.T) { log := discardLog() fc := metricutil.NewFactory("test", prometheus.NewRegistry()) - assistantMan := assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, "claude") - githubMan, err := github.NewManager(nil, github.Options{}) require.NoError(t, err) + assistantMan := assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, githubMan, nil, "claude") + slackMan, err := slack.NewManager(log, nil, nil, nil, nil, slack.Options{}) require.NoError(t, err) @@ -135,7 +135,7 @@ func Test_NewServer(t *testing.T) { // a manager of this subtest's own: NewServer calls // SetTreeNotifier, which must not race the parallel // success case's identical call on a shared manager. - assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, "claude"), + assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, githubMan, nil, "claude"), datasourceCore.NewManager(log, nil), nil, githubMan, @@ -234,7 +234,7 @@ func Test_NewServer(t *testing.T) { db, fc, storer, - assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, "claude"), + assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, search.NewJobs(false), nil, githubMan, nil, "claude"), datasourceCore.NewManager(log, nil), nil, githubMan, diff --git a/web/app/components/editor/DocumentContainer.client.nuxt.test.ts b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts index bfc08e9f..8306f328 100644 --- a/web/app/components/editor/DocumentContainer.client.nuxt.test.ts +++ b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts @@ -6,10 +6,19 @@ import { clearQueryCache, disposeMockEndpoints, makeXid, + mockEndpoint, seedQueryData, } from "~/composables/api/test-helpers" -import { at, emitFrom, seedAuthSession } from "~/components/test-helpers" +import { + at, + emitFrom, + seedAuthSession, + settleMutations, + WAIT_FOR_OPTIONS, +} from "~/components/test-helpers" import { stubThemeColorContext } from "./test-helpers/theme" +import type WsState from "~/utils/websocket" +import { makeWsHooksChangeTopic } from "~/utils" // the real provider opens a websocket to the realtime service; the // stand-in records what it was asked to sync and lets the suite fire the @@ -147,6 +156,85 @@ describe("", { concurrent: false }, () => { }) afterEach(disposeMockEndpoints) + afterEach(() => { + useWebSocketStateStore().state = null + }) + + // stubSocket stands in for the socket, handing back every handler a + // mount subscribes so a test can fire the server's message itself + function stubSocket() { + const handlers: ((payload: object) => void)[] = [] + const subscribe = vi.fn( + (_topic: string, handler: (payload: object) => void) => { + handlers.push(handler) + + return () => undefined + }, + ) + useWebSocketStateStore().state = { + subscribe: subscribe, + } as unknown as WsState + + return { handlers, subscribe } + } + + it("refetches the branch's hooks when the server says they changed", async ({ + expect, + }) => { + const calls = mockEndpoint( + "GET", + `/api/documents/${DOCUMENT_ID}/hooks`, + () => [], + ) + const { handlers, subscribe } = stubSocket() + + await mountContainer() + + // the list was seeded, so nothing has been fetched yet + const before = calls.length + handlers.forEach((handler) => { + handler({ branchId: BRANCH_ID }) + }) + await vi.waitFor(() => { + expect(calls.length).toBeGreaterThan(before) + }, WAIT_FOR_OPTIONS) + expect(subscribe).toHaveBeenCalledTimes(1) + expect(subscribe.mock.calls[0]?.[0]).toBe( + makeWsHooksChangeTopic(DOCUMENT_ID), + ) + }) + + it("leaves the hooks alone when another branch's hooks changed", async ({ + expect, + }) => { + const calls = mockEndpoint( + "GET", + `/api/documents/${DOCUMENT_ID}/hooks`, + () => [], + ) + const { handlers } = stubSocket() + + await mountContainer() + + const before = calls.length + handlers.forEach((handler) => { + handler({ branchId: TARGET_BRANCH_ID }) + }) + await settleMutations() + + expect(calls.length).toBe(before) + }) + + it("subscribes to no hooks topic while no page is open", async ({ + expect, + }) => { + useEditorStore().updateActiveDocumentId(null) + const { subscribe } = stubSocket() + + await mountContainer() + + expect(subscribe).toHaveBeenCalledTimes(0) + }) it("syncs the active branch under the document and branch id", async ({ expect, diff --git a/web/app/components/editor/DocumentContainer.client.vue b/web/app/components/editor/DocumentContainer.client.vue index e05f54c7..df85ad52 100644 --- a/web/app/components/editor/DocumentContainer.client.vue +++ b/web/app/components/editor/DocumentContainer.client.vue @@ -9,6 +9,7 @@ import { redirectToLogin } from "~/plugins/03.api-fetch" import { showToastMessage } from "~/components/toast" import { refreshGapDecorationsInBackground } from "./drag-handle/gap-decorations" import { editorCaretColors } from "~/assets/css" +import { DOCUMENT_HOOK_QUERY_KEYS } from "~/composables/api/useDocumentHookAPI" interface BranchProviderEntry { provider: HocuspocusProvider @@ -46,6 +47,39 @@ const fetchDocumentHooks = documentHookAPI.useFetchDocumentHooksByDocID( () => editorStore.activeDocumentId, () => editorStore.activeBranchId, ) +const queryCache = useQueryCache() +const wsState = useWebSocketStateStore() +let unsubWsHooksChange: (() => void) | null | undefined = null + +// the hook menus refetch after their own writes, but a hook the assistant or +// an MCP client adds arrives only on this topic: without it the indicators +// would sit stale until the list expired +watchImmediate( + () => editorStore.activeDocumentId, + (newId) => { + unsubWsHooksChange?.() + unsubWsHooksChange = null + + if (!newId) { + return + } + + unsubWsHooksChange = wsState.state?.subscribe( + makeWsHooksChangeTopic(newId), + (rawPayload) => { + const payload = rawPayload as WSHooksChangePayload + + void queryCache.invalidateQueries({ + key: DOCUMENT_HOOK_QUERY_KEYS.list(newId, payload.branchId), + }) + }, + ) + }, +) + +onUnmounted(() => { + unsubWsHooksChange?.() +}) const userCaretDetails = computed(() => { const caretColors = editorCaretColors() diff --git a/web/app/utils/api/document.test.ts b/web/app/utils/api/document.test.ts index 01868e80..5e60c0e3 100644 --- a/web/app/utils/api/document.test.ts +++ b/web/app/utils/api/document.test.ts @@ -7,6 +7,7 @@ import { makeWsDocumentMaintainersChangeTopic, makeWsDocumentMetadataChangeTopic, makeWsDocumentReviewersChangeTopic, + makeWsHooksChangeTopic, type DocumentTreeElement, } from "./document" @@ -49,6 +50,12 @@ describe("makeWsDocumentReviewersChangeTopic", () => { }) }) +describe("makeWsHooksChangeTopic", () => { + it("builds the hooks change topic for the document", ({ expect }) => { + expect(makeWsHooksChangeTopic("d1")).toBe("change@documents.d1.hooks") + }) +}) + describe("isIdInDocumentTree", () => { it.for([ { name: "finds a top-level element", id: "a", expected: true }, diff --git a/web/app/utils/api/document.ts b/web/app/utils/api/document.ts index 0f433382..1cca738e 100644 --- a/web/app/utils/api/document.ts +++ b/web/app/utils/api/document.ts @@ -12,6 +12,16 @@ export function makeWsDocumentReviewersChangeTopic(docId: string): string { return `change@documents.${docId}.reviewers` } +export function makeWsHooksChangeTopic(docId: string): string { + return `change@documents.${docId}.hooks` +} + +// what the hooks change topic carries: the branch whose hooks changed, so an +// editor showing another branch of the document leaves its list alone +export interface WSHooksChangePayload { + branchId: string +} + export interface DocumentTreeElement { id: string documentName: string