From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 13:48:26 +0000 (-0300) Subject: Order daemon and storage declarations X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/f5c601f1801aaa276f8c335fcb51c373edc0b50c Order daemon and storage declarations Daemon, storage, configuration, and build files put types before constructors and grouped methods. Shared test helpers use that order, and ordinary tests keep their tests-first order. Constants and variables precede types so every declaration category is easy to find. The declaration text and function bodies stay unchanged. Issue: 947bddb --- diff --git a/agent.go b/agent.go index d95a81c..8226a43 100644 --- a/agent.go +++ b/agent.go @@ -25,6 +25,28 @@ const ( agentIDBytes = 16 ) +// Agent is one agent and its directory. The directory holds config.json, the +// user's instructions in instructions.md, the chat, and the usage file. +type Agent struct { + dir string + config AgentConfig +} + +// AgentConfig holds the settings of the config.json of an agent. ID is random, +// made once when the file is made, and never changes. The cache key comes from +// the ID, so a rename keeps the prompt cache and two agents with one name do +// not share a key. Workdir is the directory where the commands of the agent +// run. Responsibility and Parent are set together for a child agent. +type AgentConfig struct { + Name string `json:"name"` + ID string `json:"id"` + Model string `json:"model"` + Effort string `json:"effort"` + Workdir string `json:"workdir"` + Responsibility string `json:"responsibility,omitempty"` + Parent string `json:"parent,omitempty"` +} + // openAgent reads the config.json of the agent in dir. When the file does not // exist, it makes the directory and writes the file with the defaults and a new // ID. A missing field of an existing file takes the default, but the ID must be @@ -73,13 +95,6 @@ func openAgent(dir string) (*Agent, error) { }, nil } -// Agent is one agent and its directory. The directory holds config.json, the -// user's instructions in instructions.md, the chat, and the usage file. -type Agent struct { - dir string - config AgentConfig -} - // Images returns the shared data-directory image store, also for a child. func (a *Agent) Images() *ImageStore { root := a.dir @@ -295,21 +310,6 @@ func (a *Agent) instructions() (string, error) { return string(data), nil } -// AgentConfig holds the settings of the config.json of an agent. ID is random, -// made once when the file is made, and never changes. The cache key comes from -// the ID, so a rename keeps the prompt cache and two agents with one name do -// not share a key. Workdir is the directory where the commands of the agent -// run. Responsibility and Parent are set together for a child agent. -type AgentConfig struct { - Name string `json:"name"` - ID string `json:"id"` - Model string `json:"model"` - Effort string `json:"effort"` - Workdir string `json:"workdir"` - Responsibility string `json:"responsibility,omitempty"` - Parent string `json:"parent,omitempty"` -} - // validate makes sure that each setting has a valid value. func (c AgentConfig) validate() error { if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") { diff --git a/backup_archive.go b/backup_archive.go index 0c3544f..35a389c 100644 --- a/backup_archive.go +++ b/backup_archive.go @@ -13,6 +13,25 @@ import ( "strings" ) +// BackupReader checks cancellation between reads of an archive or file. +type BackupReader struct { + ctx context.Context + reader io.Reader +} + +func (r BackupReader) Read(data []byte) (int, error) { + if r.ctx.Err() != nil { + return 0, r.ctx.Err() + } + + count, err := r.reader.Read(data) + if err != nil { + return count, fmt.Errorf("read backup data: %w", err) + } + + return count, nil +} + // backupArchive writes the files in root to path through a private temporary // file and a rename. Only directories and regular files enter the archive. func backupArchive(ctx context.Context, root, path string) (size int64, err error) { @@ -197,25 +216,6 @@ func backupExtract(ctx context.Context, path, root string) (err error) { return nil } -// BackupReader checks cancellation between reads of an archive or file. -type BackupReader struct { - ctx context.Context - reader io.Reader -} - -func (r BackupReader) Read(data []byte) (int, error) { - if r.ctx.Err() != nil { - return 0, r.ctx.Err() - } - - count, err := r.reader.Read(data) - if err != nil { - return count, fmt.Errorf("read backup data: %w", err) - } - - return count, nil -} - // backupCopyFile copies the size fixed at the snapshot of path. Growth after // that point is not part of the copy, and a shorter file is an error. func backupCopyFile(ctx context.Context, out io.Writer, path string, size int64) (err error) { diff --git a/build_queue.go b/build_queue.go index 7db24be..540e396 100644 --- a/build_queue.go +++ b/build_queue.go @@ -14,14 +14,6 @@ const ( buildLeafWindow = 8 ) -func newBuildQueue() *BuildQueue { - return &BuildQueue{ - queued: make(map[NodeKey]bool), - running: make(map[NodeKey]bool), - failed: make(map[NodeKey]bool), - } -} - // BuildQueue holds the nodes that wait for a model build. A leaf joins it when // its message is too long to be its own node. A merge joins it when both of its // children are built and their texts do not fit in one node. So the core never @@ -42,6 +34,17 @@ type BuildQueue struct { retry []NodeKey } +// BuildMergeHeap holds the merges that can start, by the end of their context. +type BuildMergeHeap []NodeKey + +func newBuildQueue() *BuildQueue { + return &BuildQueue{ + queued: make(map[NodeKey]bool), + running: make(map[NodeKey]bool), + failed: make(map[NodeKey]bool), + } +} + // AddLeaf adds the leaf of message id. The IDs must come in increasing order. func (q *BuildQueue) AddLeaf(id int) { q.leaves = append(q.leaves, id) @@ -135,9 +138,6 @@ func (q *BuildQueue) nextLeaf() (NodeKey, bool) { return NodeKey{}, false } -// BuildMergeHeap holds the merges that can start, by the end of their context. -type BuildMergeHeap []NodeKey - func (h BuildMergeHeap) Len() int { return len(h) } diff --git a/build_run.go b/build_run.go index cc12cf2..707a928 100644 --- a/build_run.go +++ b/build_run.go @@ -33,6 +33,13 @@ type BuildRun struct { usagePath string } +// DatedMessage is one message with an optional RFC 3339 date. +type DatedMessage struct { + Kind string `json:"kind"` + Text string `json:"text"` + Date string `json:"date,omitempty"` +} + // Run appends the entries. It waits for the builds only before a user message, // as a turn does, so up to buildMax builds run at once in between, and it waits // for all builds at the end. It returns the number of messages appended and why @@ -122,13 +129,6 @@ func (r *BuildRun) waitBuilds(ctx context.Context) error { return r.memory.Wait(ctx) } -// DatedMessage is one message with an optional RFC 3339 date. -type DatedMessage struct { - Kind string `json:"kind"` - Text string `json:"text"` - Date string `json:"date,omitempty"` -} - // buildReadUsage reads the records of usage.jsonl at path, in the order of the // start of their calls. The file has them in the order of the end of their // calls, which differs when calls run at once. diff --git a/chain.go b/chain.go index 23c7dec..59ce563 100644 --- a/chain.go +++ b/chain.go @@ -35,6 +35,22 @@ type Chain struct { images *ImageStore } +// ChainFunctionCall holds the fields of a function call output item. +type ChainFunctionCall struct { + Type string `json:"type"` + Name string `json:"name"` + Arguments string `json:"arguments"` + CallID string `json:"call_id"` +} + +// ChainFunctionCallOutput is the input item with the result of a function +// call. +type ChainFunctionCallOutput struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output any `json:"output"` +} + // Run runs the chain from input, the input of its first request, and returns // the text of the final answer. An error of a log write wraps // errChainLogWrite. @@ -134,22 +150,6 @@ func (c *Chain) handle(ctx context.Context, input []any, response ModelResponse) return input, "", nil } -// ChainFunctionCall holds the fields of a function call output item. -type ChainFunctionCall struct { - Type string `json:"type"` - Name string `json:"name"` - Arguments string `json:"arguments"` - CallID string `json:"call_id"` -} - -// ChainFunctionCallOutput is the input item with the result of a function -// call. -type ChainFunctionCallOutput struct { - Type string `json:"type"` - CallID string `json:"call_id"` - Output any `json:"output"` -} - // chainInputItem returns an output item as an input item for the next request. // The calls do not store their items, so the item goes back whole, without its // id. diff --git a/chatgpt.go b/chatgpt.go index 8849ada..ad40abd 100644 --- a/chatgpt.go +++ b/chatgpt.go @@ -51,37 +51,6 @@ const ( // must run "onechat login". var errChatGPTLoginNeeded = errors.New(`login needed: run "onechat login"`) -// chatgptLogin runs "onechat login": the device code login into the token file -// of the data directory. It holds the lock of the token file, so it does not -// run while the daemon runs. -func chatgptLogin(ctx context.Context, out io.Writer) (err error) { - dir, err := dataDir() - if err != nil { - return err - } - - tokens, err := lockTokens(dir) - if err != nil { - return err - } - - defer func() { - err = errors.Join(err, tokens.Close()) - }() - - return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) -} - -// newChatGPTAuth returns the authorizer for the token file at path, with the -// login service at issuer. -func newChatGPTAuth(path, issuer string) *ChatGPTAuth { - return &ChatGPTAuth{ - path: path, - issuer: issuer, - http: &http.Client{}, - } -} - // ChatGPTAuth authorizes model calls to the ChatGPT backend with the tokens in // the token file. The tokens belong to OneChat only. The Codex CLI rotates the // refresh token on each refresh, so two programs with one token file log each @@ -98,6 +67,47 @@ type ChatGPTAuth struct { http *http.Client } +// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a +// number or a string of seconds. +type ChatGPTDeviceCode struct { + DeviceAuthID string `json:"device_auth_id"` + UserCode string `json:"user_code"` + UserCodeAlt string `json:"usercode"` + Interval json.RawMessage `json:"interval"` +} + +// ChatGPTTokenFile is the content of the token file. +type ChatGPTTokenFile struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + AccountID string `json:"account_id"` +} + +// ChatGPTTokenResponse is the answer of the token endpoint. +type ChatGPTTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` +} + +// ChatGPTDeviceGrant is the answer of the device token endpoint after the user +// approves the code. +type ChatGPTDeviceGrant struct { + AuthorizationCode string `json:"authorization_code"` + CodeVerifier string `json:"code_verifier"` +} + +// newChatGPTAuth returns the authorizer for the token file at path, with the +// login service at issuer. +func newChatGPTAuth(path, issuer string) *ChatGPTAuth { + return &ChatGPTAuth{ + path: path, + issuer: issuer, + http: &http.Client{}, + } +} + // Authorize sets the access token and the account ID, after a refresh when the // token expires within chatgptRefreshMargin. func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error { @@ -365,35 +375,25 @@ func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body [ return response.StatusCode, nil } -// ChatGPTDeviceCode is the answer of the user code endpoint. The interval is a -// number or a string of seconds. -type ChatGPTDeviceCode struct { - DeviceAuthID string `json:"device_auth_id"` - UserCode string `json:"user_code"` - UserCodeAlt string `json:"usercode"` - Interval json.RawMessage `json:"interval"` -} +// chatgptLogin runs "onechat login": the device code login into the token file +// of the data directory. It holds the lock of the token file, so it does not +// run while the daemon runs. +func chatgptLogin(ctx context.Context, out io.Writer) (err error) { + dir, err := dataDir() + if err != nil { + return err + } -// ChatGPTTokenFile is the content of the token file. -type ChatGPTTokenFile struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - AccountID string `json:"account_id"` -} + tokens, err := lockTokens(dir) + if err != nil { + return err + } -// ChatGPTTokenResponse is the answer of the token endpoint. -type ChatGPTTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` -} + defer func() { + err = errors.Join(err, tokens.Close()) + }() -// ChatGPTDeviceGrant is the answer of the device token endpoint after the user -// approves the code. -type ChatGPTDeviceGrant struct { - AuthorizationCode string `json:"authorization_code"` - CodeVerifier string `json:"code_verifier"` + return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) } // chatgptTokenExpiry returns the expiry of a JWT from its exp claim. diff --git a/chatgpt_fake_helper_test.go b/chatgpt_fake_helper_test.go index ddd4c67..0dca6bb 100644 --- a/chatgpt_fake_helper_test.go +++ b/chatgpt_fake_helper_test.go @@ -35,6 +35,27 @@ type ChatGPTFake struct { access []string } +func newChatGPTFake(t *testing.T) *ChatGPTFake { + t.Helper() + + f := &ChatGPTFake{} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode) + mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken) + mux.HandleFunc("POST /oauth/token", f.oauthToken) + + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + + return f +} + +func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth { + t.Helper() + + return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL) +} + // token returns a new access token that expires after ttl, for a token file // that a test writes. func (f *ChatGPTFake) token(ttl time.Duration) string { @@ -166,27 +187,6 @@ func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) { chatgptWriteJSON(w, answer) } -func newChatGPTFake(t *testing.T) *ChatGPTFake { - t.Helper() - - f := &ChatGPTFake{} - mux := http.NewServeMux() - mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode) - mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken) - mux.HandleFunc("POST /oauth/token", f.oauthToken) - - f.Server = httptest.NewServer(mux) - t.Cleanup(f.Close) - - return f -} - -func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth { - t.Helper() - - return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL) -} - func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) { t.Helper() diff --git a/compactor.go b/compactor.go index 2b23901..e101428 100644 --- a/compactor.go +++ b/compactor.go @@ -15,20 +15,6 @@ const compactorTries = 5 // bytes, and a sample line as the ruler gets its content copied. var compactorRuler = strings.Repeat("-", nodeBytes) -// newCompactor returns the compactor of an agent, which sends its calls through -// client with model. instructions gives the system prompt of the agent at each -// build. tools are the tools of that agent, so a build sends the same prompt -// and tools as its turns. -func newCompactor(client *ModelClient, instructions func() (string, error), model ModelConfig, tools []ModelTool, images *ImageStore) *Compactor { - return &Compactor{ - client: client, - model: model, - instructions: instructions, - tools: tools, - images: images, - } -} - // Compactor builds the nodes of one agent with a model. A compaction is a call // like a turn, with the same system prompt and tools, so it reads them from the // prompt cache of the turns. The tools are never called. After them comes one @@ -52,6 +38,20 @@ type Compactor struct { images *ImageStore } +// newCompactor returns the compactor of an agent, which sends its calls through +// client with model. instructions gives the system prompt of the agent at each +// build. tools are the tools of that agent, so a build sends the same prompt +// and tools as its turns. +func newCompactor(client *ModelClient, instructions func() (string, error), model ModelConfig, tools []ModelTool, images *ImageStore) *Compactor { + return &Compactor{ + client: client, + model: model, + instructions: instructions, + tools: tools, + images: images, + } +} + // Summarize returns the line of the node of task. func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) { text, err := compactorTask(task) diff --git a/config.go b/config.go index 1d68b67..fb24c20 100644 --- a/config.go +++ b/config.go @@ -19,6 +19,19 @@ const ( configEndpointAPI = "api" ) +// Config holds the settings of config.json in the data directory. +type Config struct { + Endpoint string `json:"endpoint"` + Voice VoiceConfig `json:"voice"` + Compactor ModelConfig `json:"compactor"` +} + +// ModelConfig names a model and its reasoning effort. +type ModelConfig struct { + Model string `json:"model"` + Effort string `json:"effort"` +} + // loadConfig reads config.json in dir over the defaults. A missing file gives // the defaults. An unknown field is an error, so a typo does not become a // silent default. @@ -52,13 +65,6 @@ func loadConfig(dir string) (Config, error) { return config, nil } -// Config holds the settings of config.json in the data directory. -type Config struct { - Endpoint string `json:"endpoint"` - Voice VoiceConfig `json:"voice"` - Compactor ModelConfig `json:"compactor"` -} - // validate makes sure that each setting has a valid value. func (c Config) validate() error { if c.Endpoint != configEndpointChatGPT && c.Endpoint != configEndpointAPI { @@ -72,12 +78,6 @@ func (c Config) validate() error { return nil } -// ModelConfig names a model and its reasoning effort. -type ModelConfig struct { - Model string `json:"model"` - Effort string `json:"effort"` -} - // defaultConfig returns the settings that apply when config.json does not // set them. func defaultConfig() Config { diff --git a/daemon.go b/daemon.go index 31a6714..dd75d31 100644 --- a/daemon.go +++ b/daemon.go @@ -24,105 +24,6 @@ const ( daemonClientEvents = 1024 ) -// daemon runs "onechat daemon": it owns the root, its children, and their -// files, and serves the clients on the Unix socket in the data directory until -// ctx ends. When telegram.json exists, it also runs the Telegram bot. It also -// keeps a linked WhatsApp device connected. It holds the lock of the token -// file, so it is the only process that refreshes the tokens and opens the -// WhatsApp session. Its log lines go to out, through the loggers of -// daemonLogger. -func daemon(ctx context.Context, out io.Writer) (err error) { - dir, err := dataDir() - if err != nil { - return err - } - - // Check the socket path before the daemon writes a file, so that a data - // directory with a long path stays empty. - socket := filepath.Join(dir, socketFileName) - err = checkSocketPath(socket) - if err != nil { - return err - } - - config, err := loadConfig(dir) - if err != nil { - return err - } - - botSettings, hasBot, err := loadTelegramConfig(dir) - if err != nil { - return err - } - - tokens, err := lockTokens(dir) - if err != nil { - return err - } - - defer func() { - err = errors.Join(err, tokens.Close()) - }() - - whatsappSetDeviceProps() - - logger := daemonLogger(out, "onechat: ") - whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient) - if err != nil { - return err - } - - defer func() { - err = errors.Join(err, whatsapp.Close()) - }() - - address, auth, err := modelEndpoint(config, dir) - if err != nil { - return err - } - - agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger) - if err != nil { - return err - } - - defer func() { - err = errors.Join(err, agents.Close()) - }() - - whatsapp.inbox.voice = &VoiceTranscriber{config: config.Voice} - - root := agents[0] - agent := root.agent - schedules := root.schedules - - listener, err := daemonListenSocket(socket) - if err != nil { - return err - } - - linked := "not linked" - if whatsapp.isLinked() { - linked = "linked" - } - - logger.Info("ready", "version", version, "socket", listener.Addr(), "model", agent.config.Model, "effort", agent.config.Effort, "workdir", agent.config.Workdir, "whatsapp", linked, "schedules", schedules.Len(), "children", agents.ChildNames()) - - var telegram *TelegramBot - - if hasBot { - telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), daemonLogger(out, "telegram: ")) - - telegram.voice = &VoiceTranscriber{config: config.Voice} - - logger.Info("Telegram bot started") - } - - return daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) { - return root.Loop(whatsapp, notify) - }, agents[1:]...) -} - // DaemonHub holds the connected clients and sends each event to all of them. It // runs the WhatsApp commands of the clients on whatsapp. snapshot locks all // agents and subagents during a backup copy. @@ -135,6 +36,14 @@ type DaemonHub struct { workers sync.WaitGroup } +// DaemonClient is one connected client. Its events wait in a channel for the +// writer, so a slow client does not stop the agent. +type DaemonClient struct { + conn net.Conn + events chan Event + once sync.Once +} + // Accept serves each client of listener until the listener closes. A new // client gets a ready event with the name of the agent, and a busy event when a // turn runs. @@ -292,12 +201,103 @@ func (h *DaemonHub) CloseAll() { h.workers.Wait() } -// DaemonClient is one connected client. Its events wait in a channel for the -// writer, so a slow client does not stop the agent. -type DaemonClient struct { - conn net.Conn - events chan Event - once sync.Once +// daemon runs "onechat daemon": it owns the root, its children, and their +// files, and serves the clients on the Unix socket in the data directory until +// ctx ends. When telegram.json exists, it also runs the Telegram bot. It also +// keeps a linked WhatsApp device connected. It holds the lock of the token +// file, so it is the only process that refreshes the tokens and opens the +// WhatsApp session. Its log lines go to out, through the loggers of +// daemonLogger. +func daemon(ctx context.Context, out io.Writer) (err error) { + dir, err := dataDir() + if err != nil { + return err + } + + // Check the socket path before the daemon writes a file, so that a data + // directory with a long path stays empty. + socket := filepath.Join(dir, socketFileName) + err = checkSocketPath(socket) + if err != nil { + return err + } + + config, err := loadConfig(dir) + if err != nil { + return err + } + + botSettings, hasBot, err := loadTelegramConfig(dir) + if err != nil { + return err + } + + tokens, err := lockTokens(dir) + if err != nil { + return err + } + + defer func() { + err = errors.Join(err, tokens.Close()) + }() + + whatsappSetDeviceProps() + + logger := daemonLogger(out, "onechat: ") + whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient) + if err != nil { + return err + } + + defer func() { + err = errors.Join(err, whatsapp.Close()) + }() + + address, auth, err := modelEndpoint(config, dir) + if err != nil { + return err + } + + agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger) + if err != nil { + return err + } + + defer func() { + err = errors.Join(err, agents.Close()) + }() + + whatsapp.inbox.voice = &VoiceTranscriber{config: config.Voice} + + root := agents[0] + agent := root.agent + schedules := root.schedules + + listener, err := daemonListenSocket(socket) + if err != nil { + return err + } + + linked := "not linked" + if whatsapp.isLinked() { + linked = "linked" + } + + logger.Info("ready", "version", version, "socket", listener.Addr(), "model", agent.config.Model, "effort", agent.config.Effort, "workdir", agent.config.Workdir, "whatsapp", linked, "schedules", schedules.Len(), "children", agents.ChildNames()) + + var telegram *TelegramBot + + if hasBot { + telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), daemonLogger(out, "telegram: ")) + + telegram.voice = &VoiceTranscriber{config: config.Voice} + + logger.Info("Telegram bot started") + } + + return daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) { + return root.Loop(whatsapp, notify) + }, agents[1:]...) } // daemonListenSocket listens on the Unix socket at path, with mode 0600. The diff --git a/daemon_agent.go b/daemon_agent.go index 227d23b..682fed7 100644 --- a/daemon_agent.go +++ b/daemon_agent.go @@ -9,6 +9,22 @@ import ( "time" ) +// DaemonAgent owns the open files and model client of one persistent agent. Its +// loop must stop before Close releases the files. +type DaemonAgent struct { + agent *Agent + memory *Memory + client *ModelClient + subagents *Subagents + schedules *Schedules + clock func() time.Time + logger *slog.Logger + openChild func(context.Context, *Agent) (*DaemonAgent, error) +} + +// DaemonAgents holds the root first and then its children in directory order. +type DaemonAgents []*DaemonAgent + // openDaemonAgents opens the root and its direct children. An invalid child // stops startup, and each opened agent is closed when startup fails. func openDaemonAgents(ctx context.Context, dir, address string, auth ModelAuthorizer, compactor ModelConfig, clock func() time.Time, logger *slog.Logger) (agents DaemonAgents, err error) { @@ -79,19 +95,6 @@ func openDaemonAgent(ctx context.Context, agent *Agent, address string, auth Mod }, nil } -// DaemonAgent owns the open files and model client of one persistent agent. Its -// loop must stop before Close releases the files. -type DaemonAgent struct { - agent *Agent - memory *Memory - client *ModelClient - subagents *Subagents - schedules *Schedules - clock func() time.Time - logger *slog.Logger - openChild func(context.Context, *Agent) (*DaemonAgent, error) -} - // Loop makes the turn loop with its event destination. Only the root has a // WhatsApp link. Child events stay with their own logs. func (a *DaemonAgent) Loop(whatsapp *WhatsApp, notify func(Event)) (*TurnLoop, error) { @@ -110,9 +113,6 @@ func (a *DaemonAgent) Close() error { return errors.Join(a.subagents.Close(), a.memory.Close()) } -// DaemonAgents holds the root first and then its children in directory order. -type DaemonAgents []*DaemonAgent - // Close closes all agents, including after a partial startup. func (a DaemonAgents) Close() error { var err error diff --git a/daemon_log.go b/daemon_log.go index ef1360c..f1e4b0b 100644 --- a/daemon_log.go +++ b/daemon_log.go @@ -11,18 +11,6 @@ import ( "time" ) -// daemonLogger returns a logger for one daemon part. JOURNAL_STREAM selects -// syslog priorities without a time stamp. Other output carries the standard -// time stamp and level. -func daemonLogger(out io.Writer, prefix string) *slog.Logger { - return slog.New(&DaemonLogHandler{ - out: out, - prefix: prefix, - journal: os.Getenv("JOURNAL_STREAM") != "", - mutex: &sync.Mutex{}, - }) -} - // DaemonLogHandler writes short event lines with ordered attributes. Copies // share the writer lock. The err attribute follows all other attributes as a // cause. Journal lines carry a syslog priority instead of a time and level. @@ -116,6 +104,18 @@ func (h *DaemonLogHandler) WithGroup(name string) slog.Handler { return &clone } +// daemonLogger returns a logger for one daemon part. JOURNAL_STREAM selects +// syslog priorities without a time stamp. Other output carries the standard +// time stamp and level. +func daemonLogger(out io.Writer, prefix string) *slog.Logger { + return slog.New(&DaemonLogHandler{ + out: out, + prefix: prefix, + journal: os.Getenv("JOURNAL_STREAM") != "", + mutex: &sync.Mutex{}, + }) +} + // daemonLogAttr resolves and writes one attribute, or keeps its cause for the // end of the line. Groups flatten into dotted keys and empty attributes produce // no text. diff --git a/daemon_message.go b/daemon_message.go index 8ee26e8..b70947d 100644 --- a/daemon_message.go +++ b/daemon_message.go @@ -12,6 +12,43 @@ import ( "sync" ) +// DaemonMessages owns the durable queue between the root and its children and +// the routed inputs. The mutex serializes queue writes and delivery. Backups +// take it before the locks of the loops, memory, and subagents. A stored log ID +// makes delivery idempotent, including a stop between the log append and the +// wake. +type DaemonMessages struct { + mutex sync.Mutex + path string + loops map[string]*TurnLoop + ordered []*TurnLoop + state DaemonMessageFile + failure error + routes *Routes +} + +// DaemonMessageFile holds the sequence, unacknowledged deliveries, and receipts +// for WhatsApp batches until the inbox removes them. A receipt stays when its +// turn finishes before inbox removal. +type DaemonMessageFile struct { + Next int `json:"next"` + Pending []DaemonMessage `json:"pending"` + WhatsAppBatches []int `json:"whatsapp_batches,omitempty"` +} + +// DaemonMessage is one queued delivery. LogID is set before the log append. A +// nil LogID means that delivery has not started. WhatsAppBatch marks a routed +// external batch, whose event source is WhatsApp rather than its queue sender. +type DaemonMessage struct { + ID int `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Kind string `json:"kind"` + Text string `json:"text"` + LogID *int `json:"log_id,omitempty"` + WhatsAppBatch int `json:"whatsapp_batch,omitempty"` +} + // openDaemonMessages connects the root and its children. It restores queued // messages before the loops start. A queue entry stays until its recipient // finishes the turn that takes it, so a stop can interrupt delivery or a turn. @@ -109,21 +146,6 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { return m, nil } -// DaemonMessages owns the durable queue between the root and its children and -// the routed inputs. The mutex serializes queue writes and delivery. Backups -// take it before the locks of the loops, memory, and subagents. A stored log ID -// makes delivery idempotent, including a stop between the log append and the -// wake. -type DaemonMessages struct { - mutex sync.Mutex - path string - loops map[string]*TurnLoop - ordered []*TurnLoop - state DaemonMessageFile - failure error - routes *Routes -} - // deliver records one queued message and wakes its recipient. The caller holds // the mutex, or owns the queue before the loops start. func (m *DaemonMessages) deliver(message *DaemonMessage) error { @@ -441,25 +463,3 @@ func (m *DaemonMessages) Finish(loop *TurnLoop, active []Message, reply string) return nil } - -// DaemonMessageFile holds the sequence, unacknowledged deliveries, and receipts -// for WhatsApp batches until the inbox removes them. A receipt stays when its -// turn finishes before inbox removal. -type DaemonMessageFile struct { - Next int `json:"next"` - Pending []DaemonMessage `json:"pending"` - WhatsAppBatches []int `json:"whatsapp_batches,omitempty"` -} - -// DaemonMessage is one queued delivery. LogID is set before the log append. A -// nil LogID means that delivery has not started. WhatsAppBatch marks a routed -// external batch, whose event source is WhatsApp rather than its queue sender. -type DaemonMessage struct { - ID int `json:"id"` - Source string `json:"source"` - Target string `json:"target"` - Kind string `json:"kind"` - Text string `json:"text"` - LogID *int `json:"log_id,omitempty"` - WhatsAppBatch int `json:"whatsapp_batch,omitempty"` -} diff --git a/daemon_message_helper_test.go b/daemon_message_helper_test.go index 2122a93..2a627e2 100644 --- a/daemon_message_helper_test.go +++ b/daemon_message_helper_test.go @@ -22,6 +22,41 @@ type DaemonMessageTest struct { done []chan error } +func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest { + t.Helper() + + _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children")) + if errors.Is(err, os.ErrNotExist) { + daemonChildConfig(t, dir, "Garden") + daemonChildConfig(t, dir, "Library") + } else { + require.NoError(t, err) + } + + test := &DaemonMessageTest{ + logs: &SyncBuffer{}, + } + test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs)) + require.NoError(t, err) + t.Cleanup(func() { + test.Close(t) + }) + + for _, agent := range test.agents { + events := &TurnEventRecorder{} + loop, err := agent.Loop(nil, events.Add) + require.NoError(t, err) + + test.events = append(test.events, events) + test.loops = append(test.loops, loop) + } + + test.messages, err = openDaemonMessages(test.loops) + require.NoError(t, err) + + return test +} + func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) { t.Helper() @@ -67,38 +102,3 @@ func (r *DaemonMessageTest) Close(t *testing.T) { r.agents = nil } - -func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest { - t.Helper() - - _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children")) - if errors.Is(err, os.ErrNotExist) { - daemonChildConfig(t, dir, "Garden") - daemonChildConfig(t, dir, "Library") - } else { - require.NoError(t, err) - } - - test := &DaemonMessageTest{ - logs: &SyncBuffer{}, - } - test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs)) - require.NoError(t, err) - t.Cleanup(func() { - test.Close(t) - }) - - for _, agent := range test.agents { - events := &TurnEventRecorder{} - loop, err := agent.Loop(nil, events.Add) - require.NoError(t, err) - - test.events = append(test.events, events) - test.loops = append(test.loops, loop) - } - - test.messages, err = openDaemonMessages(test.loops) - require.NoError(t, err) - - return test -} diff --git a/log.go b/log.go index 6d0d478..2b9f32f 100644 --- a/log.go +++ b/log.go @@ -21,6 +21,38 @@ var logKinds = map[string]bool{ "note": true, } +// Log is the append-only message log of one chat. It writes one JSON line for +// each message to main/YYYY-MM-DD.jsonl, named for the local day of the +// message. It never edits or deletes a line. Each line goes to the operating +// system before Append returns, so a crash of the process loses no message. An +// exclusive lock on the lock file makes sure that only one process writes the +// log. After a failed write, the end of the file is not known, so the log +// refuses all writes until it is opened again. The caller serializes all calls. +// +// The log keeps no message in memory. The index index/main.idx holds the +// position of message i in record i, so a read of a message is one read of the +// index and one of its day file. At open the log indexes the lines after the +// last indexed one, so a missing index is built again from the day files. +type Log struct { + clock func() time.Time + lock *os.File + journal Journal + index *Records + failure error +} + +// Message is one entry of the chat log. ID is its position in the log, from 0. +// A rebuild assigns positions in the merged log. Size is the UTF-8 byte length +// of Text. Date is the message time in RFC 3339 format, supplied at append or +// taken from the clock. +type Message struct { + ID int `json:"i"` + Kind string `json:"kind"` + Text string `json:"text"` + Size int `json:"size"` + Date string `json:"date"` +} + // openLog locks the chat in root and indexes its new lines. The clock gives the // date of each new message. func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) { @@ -57,26 +89,6 @@ func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, er return l, nil } -// Log is the append-only message log of one chat. It writes one JSON line for -// each message to main/YYYY-MM-DD.jsonl, named for the local day of the -// message. It never edits or deletes a line. Each line goes to the operating -// system before Append returns, so a crash of the process loses no message. An -// exclusive lock on the lock file makes sure that only one process writes the -// log. After a failed write, the end of the file is not known, so the log -// refuses all writes until it is opened again. The caller serializes all calls. -// -// The log keeps no message in memory. The index index/main.idx holds the -// position of message i in record i, so a read of a message is one read of the -// index and one of its day file. At open the log indexes the lines after the -// last indexed one, so a missing index is built again from the day files. -type Log struct { - clock func() time.Time - lock *os.File - journal Journal - index *Records - failure error -} - // Close releases the lock. The log refuses all calls after Close. func (l *Log) Close() error { err := l.journal.Close() @@ -234,18 +246,6 @@ func (l *Log) addIndex(place Position) error { return l.index.Append(record) } -// Message is one entry of the chat log. ID is its position in the log, from 0. -// A rebuild assigns positions in the merged log. Size is the UTF-8 byte length -// of Text. Date is the message time in RFC 3339 format, supplied at append or -// taken from the clock. -type Message struct { - ID int `json:"i"` - Kind string `json:"kind"` - Text string `json:"text"` - Size int `json:"size"` - Date string `json:"date"` -} - // logValidateMessage makes sure that a message has a known kind, valid UTF-8 // text, the size of its text, and a valid date. func logValidateMessage(message Message) error { diff --git a/records.go b/records.go index abd88a8..52824cb 100644 --- a/records.go +++ b/records.go @@ -11,6 +11,16 @@ import ( // positionBytes is the size of an encoded position. const positionBytes = 16 +// Records is a file of fixed-size Records, numbered from 0. Record n starts at +// byte n*size. A record that was never written reads as zeros. An index is data +// that the day files can give again, so Records cuts a partial record at the +// end of the file. +type Records struct { + file *os.File + size int + count int +} + // openRecords opens or creates the record file at path with records of size // bytes. func openRecords(path string, size int) (*Records, error) { @@ -40,32 +50,6 @@ func openRecords(path string, size int) (*Records, error) { return r, nil } -// encodePosition writes place into the first positionBytes bytes of record. -func encodePosition(record []byte, place Position) { - binary.LittleEndian.PutUint32(record[0:], place.day) - binary.LittleEndian.PutUint32(record[4:], place.length) - binary.LittleEndian.PutUint64(record[8:], place.offset) -} - -// decodePosition reads a position from the first positionBytes bytes of record. -func decodePosition(record []byte) Position { - return Position{ - day: binary.LittleEndian.Uint32(record[0:]), - length: binary.LittleEndian.Uint32(record[4:]), - offset: binary.LittleEndian.Uint64(record[8:]), - } -} - -// Records is a file of fixed-size Records, numbered from 0. Record n starts at -// byte n*size. A record that was never written reads as zeros. An index is data -// that the day files can give again, so Records cuts a partial record at the -// end of the file. -type Records struct { - file *os.File - size int - count int -} - // Read returns record n, or false when the file does not reach it. func (r *Records) Read(n int) ([]byte, bool, error) { if n >= r.count { @@ -102,3 +86,19 @@ func (r *Records) Append(record []byte) error { func (r *Records) Close() error { return r.file.Close() } + +// encodePosition writes place into the first positionBytes bytes of record. +func encodePosition(record []byte, place Position) { + binary.LittleEndian.PutUint32(record[0:], place.day) + binary.LittleEndian.PutUint32(record[4:], place.length) + binary.LittleEndian.PutUint64(record[8:], place.offset) +} + +// decodePosition reads a position from the first positionBytes bytes of record. +func decodePosition(record []byte) Position { + return Position{ + day: binary.LittleEndian.Uint32(record[0:]), + length: binary.LittleEndian.Uint32(record[4:]), + offset: binary.LittleEndian.Uint64(record[8:]), + } +} diff --git a/routes.go b/routes.go index 7dce691..02e28e2 100644 --- a/routes.go +++ b/routes.go @@ -13,6 +13,16 @@ import ( "go.mau.fi/whatsmeow/types" ) +// Routes owns the root's source map. Writes replace the private file before +// changing the map in memory. The mutex protects reads and writes from tools +// and status commands. Logs omit source IDs, which can hold phone numbers. +type Routes struct { + mutex sync.Mutex + path string + logger *slog.Logger + targets map[string]string +} + // openRoutes reads the source-to-child map. A missing file has no routes. func openRoutes(path string, logger *slog.Logger) (*Routes, error) { r := &Routes{ @@ -52,16 +62,6 @@ func openRoutes(path string, logger *slog.Logger) (*Routes, error) { return r, nil } -// Routes owns the root's source map. Writes replace the private file before -// changing the map in memory. The mutex protects reads and writes from tools -// and status commands. Logs omit source IDs, which can hold phone numbers. -type Routes struct { - mutex sync.Mutex - path string - logger *slog.Logger - targets map[string]string -} - // Set maps source to a child name, or removes it when target is empty. The // caller checks that a nonempty target is a child of the root. func (r *Routes) Set(source, target string) error { diff --git a/schedule.go b/schedule.go index 28a5a76..5fa35da 100644 --- a/schedule.go +++ b/schedule.go @@ -48,6 +48,40 @@ const ( scheduleLateLine = "This run is late: the daemon was stopped at that time." ) +// Schedules holds the schedules of the agent: tasks that start a turn at one +// time or at the times of a cron expression. Each change writes the file whole +// before it returns, so a schedule survives a restart. The mutex protects the +// schedules, because the tools change them from a turn while Run reads them. +// changed tells Run that a set or a remove changed the times. +type Schedules struct { + path string + owner string + clock func() time.Time + logger *slog.Logger + changed chan struct{} + + mutex sync.Mutex + file ScheduleFile +} + +// ScheduleFile is the content of the file of the schedules: the ID of the last +// schedule, so that an ID is never used again, and the schedules. +type ScheduleFile struct { + Next int `json:"next"` + Schedules []Schedule `json:"schedules"` +} + +// Schedule is one schedule: its owner, its ID, the task for the agent, the cron +// expression of a repeating schedule, empty for a one-time schedule, and its +// next time. +type Schedule struct { + Owner string `json:"owner"` + ID int `json:"id"` + Text string `json:"text"` + Cron string `json:"cron"` + Due time.Time `json:"due"` +} + // openSchedules reads the schedules of the file at path. A missing file holds // none. The owner names its agent. Schedules without an owner take this name. A // different stored owner is an error. The clock gives the time and the time @@ -107,22 +141,6 @@ func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logg return s, nil } -// Schedules holds the schedules of the agent: tasks that start a turn at one -// time or at the times of a cron expression. Each change writes the file whole -// before it returns, so a schedule survives a restart. The mutex protects the -// schedules, because the tools change them from a turn while Run reads them. -// changed tells Run that a set or a remove changed the times. -type Schedules struct { - path string - owner string - clock func() time.Time - logger *slog.Logger - changed chan struct{} - - mutex sync.Mutex - file ScheduleFile -} - // Run starts the runs of the due schedules in l until ctx ends. It waits until // the first due time, at most scheduleClockWaitTimeout at a time, and also // wakes at a set or a remove. So a schedule that came due while the daemon was @@ -420,24 +438,6 @@ func (s *Schedules) save() error { return nil } -// ScheduleFile is the content of the file of the schedules: the ID of the last -// schedule, so that an ID is never used again, and the schedules. -type ScheduleFile struct { - Next int `json:"next"` - Schedules []Schedule `json:"schedules"` -} - -// Schedule is one schedule: its owner, its ID, the task for the agent, the cron -// expression of a repeating schedule, empty for a one-time schedule, and its -// next time. -type Schedule struct { - Owner string `json:"owner"` - ID int `json:"id"` - Text string `json:"text"` - Cron string `json:"cron"` - Due time.Time `json:"due"` -} - // Message returns the text of a run at now for the log, with the times in the // location of now. The first line starts with [Schedule ID] and gives the due // time and once or the cron expression. Then come the text, the line about a diff --git a/schedule_cron.go b/schedule_cron.go index 91fe8ce..3255f99 100644 --- a/schedule_cron.go +++ b/schedule_cron.go @@ -53,61 +53,6 @@ var scheduleCronFields = []struct { }, } -// scheduleParseCron parses a cron expression of five fields: minute, hour, day -// of month, month, and day of week, where 0 and 7 are Sunday. A field is a -// comma list of items, and an item is *, N, or N-M, where * and N-M can have a -// step /S. Names, macros, the characters ? L W #, and N/S are errors. -// -// It refuses an expression whose runs can be less than scheduleCronGap minutes -// apart. The check takes the wall times of one day from the hour and the minute -// fields, and the gap across midnight as if the next day matches too. It also -// refuses an expression that matches no day in scheduleCronYears years, such as -// one on February 30. -func scheduleParseCron(expression string) (ScheduleCron, error) { - fields := strings.Fields(expression) - if len(fields) != len(scheduleCronFields) { - return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields)) - } - - var sets [5]uint64 - - for index, field := range fields { - set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high) - if err != nil { - return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err) - } - - sets[index] = set - } - - c := ScheduleCron{ - minutes: sets[0], - hours: sets[1], - days: sets[2], - months: sets[3], - weekdays: sets[4], - anyDay: strings.HasPrefix(fields[2], "*"), - anyWeekday: strings.HasPrefix(fields[4], "*"), - description: strings.Join(fields, " "), - } - - if c.weekdays&(1<<7) != 0 { - c.weekdays |= 1 - } - - if c.gap() < scheduleCronGap { - return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap) - } - - start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) - _, found := c.Next(start) - if !found { - return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears) - } - - return c, nil -} - // ScheduleCron is a parsed cron expression. Each set holds a bit for each value // of its field. anyDay and anyWeekday tell that the day field or the weekday // field starts with *. When neither does, a day matches when either field @@ -209,6 +154,61 @@ func (c ScheduleCron) gap() int { return gap } +// scheduleParseCron parses a cron expression of five fields: minute, hour, day +// of month, month, and day of week, where 0 and 7 are Sunday. A field is a +// comma list of items, and an item is *, N, or N-M, where * and N-M can have a +// step /S. Names, macros, the characters ? L W #, and N/S are errors. +// +// It refuses an expression whose runs can be less than scheduleCronGap minutes +// apart. The check takes the wall times of one day from the hour and the minute +// fields, and the gap across midnight as if the next day matches too. It also +// refuses an expression that matches no day in scheduleCronYears years, such as +// one on February 30. +func scheduleParseCron(expression string) (ScheduleCron, error) { + fields := strings.Fields(expression) + if len(fields) != len(scheduleCronFields) { + return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields)) + } + + var sets [5]uint64 + + for index, field := range fields { + set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high) + if err != nil { + return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err) + } + + sets[index] = set + } + + c := ScheduleCron{ + minutes: sets[0], + hours: sets[1], + days: sets[2], + months: sets[3], + weekdays: sets[4], + anyDay: strings.HasPrefix(fields[2], "*"), + anyWeekday: strings.HasPrefix(fields[4], "*"), + description: strings.Join(fields, " "), + } + + if c.weekdays&(1<<7) != 0 { + c.weekdays |= 1 + } + + if c.gap() < scheduleCronGap { + return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap) + } + + start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + _, found := c.Next(start) + if !found { + return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears) + } + + return c, nil +} + // scheduleWallTime returns the time of a wall time in location. A wall time // that the clock skips, at the start of daylight saving time, is later by the // length of the skip: 02:30 becomes 03:30 when the clock goes from 02:00 to diff --git a/socket.go b/socket.go index 0dffeb0..f87bec7 100644 --- a/socket.go +++ b/socket.go @@ -20,15 +20,6 @@ const ( terminalSource = "terminal" ) -// checkSocketPath makes sure that path fits in the address of a Unix socket. -func checkSocketPath(path string) error { - if len(path) > socketPathBytes { - return fmt.Errorf("socket path %s is longer than %d bytes: set ONECHAT_DIR to a shorter path", path, socketPathBytes) - } - - return nil -} - // SocketRequest is one line from a client: "send" with the text of a user // message, "interrupt", "stop" with the name of a subagent in Text, or // "whatsapp" with the text after /whatsapp of a WhatsApp command, or "backup" @@ -40,3 +31,12 @@ type SocketRequest struct { Text string `json:"text,omitempty"` Path string `json:"path,omitempty"` } + +// checkSocketPath makes sure that path fits in the address of a Unix socket. +func checkSocketPath(path string) error { + if len(path) > socketPathBytes { + return fmt.Errorf("socket path %s is longer than %d bytes: set ONECHAT_DIR to a shorter path", path, socketPathBytes) + } + + return nil +}