add hooks to mcp and assistant

This commit is contained in:
david 2026-09-07 20:18:33 +03:00
parent 6bb265d0ed
commit 599e5cae08
30 changed files with 3874 additions and 39 deletions

View File

@ -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

View File

@ -296,6 +296,8 @@ func main() { //nolint:maintidx // main performs linear wiring of all components
searchClient,
searchJobs,
datasourceMan,
githubMan,
webchangeClient,
string(assistantOpts.Provider),
)

View File

@ -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,

View File

@ -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) {

View File

@ -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.
`

View File

@ -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;

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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, &gt); 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),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -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)
})
}
}

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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{},
)

View File

@ -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.

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}

View File

@ -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"))
}

View File

@ -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",
))

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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,

View File

@ -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("<DocumentContainer>", { 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,

View File

@ -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()

View File

@ -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 },

View File

@ -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