mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 07:07:33 -04:00
A worker has no database and cannot NOTIFY, and it does not need to: every
message it sends is produced inside a handler the frontend invoked, so
there is always an open control response to write on. This adds the two
ends of that, and the authorization decision that sits between them, and
nothing that dispatches yet.
workerctl.Envelope gains Subject, a REQUEST and not an instruction. Empty
means the line is for this caller alone, which is what every pre-existing
progress line is, and omitempty keeps those lines byte-identical for an
older reader. It qualifies a progress line and never a reply line: a reply
is the worker's verdict about the work, and there is no version of
"publish my verdict for me" this control plane has to carry.
nodes.MayBroadcast is the replacement for pkg/natsauth's allow list, and
the inversion is the point. NATS read an EMPTY allow list as NO
RESTRICTION, which is why phase 3 refused to delete the backend branch and
spelled it {"_INBOX.>"}. This one reads an empty list, and an absent node
type, as DENY EVERYTHING, and a table-driven spec pins that by emptying
the agent entry and asserting all three of its subjects are then refused.
Matching goes through messaging.SubjectMatches, the one definition in the
tree, so a filter that fires here fires on the carrier.
nodes.Rebroadcaster.Handle returns a bool and never an error. A refused or
failed re-broadcast is logged and the RPC continues, because the RPC's
outcome is the worker's verdict about the work and a publish failure says
nothing about it. The return shape is asserted at compile time in the file
that states the rule, so changing it to an error does not compile.
ControlClient.CallStreaming's progress callback now takes the line's
subject alongside its raw bytes. The client no longer decodes progress at
all: what a line means is a question about the verb and whether a named
broadcast may be made is a question about the node, and it knows neither.
Both moved into installProgressBridge, which every one of the three
streaming call sites in unloader.go goes through. A line naming a subject
is dropped there rather than delivered as install progress, because
backend.install and backend.upgrade are a backend worker's verbs and a
backend worker is allowed no subjects.
agents.StreamPublisher is the producing end, a messaging.Publisher writing
NDJSON envelopes onto an in-flight control response and flushing each one,
so a tick reaches the frontend while the handler is still running.
Serialized, because two concurrent encodes on one http.ResponseWriter
interleave bytes and tear the framing.
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
159 lines
6.4 KiB
Go
159 lines
6.4 KiB
Go
// Package workerctl names the HTTP control plane a worker serves to the
|
|
// frontends that manage it.
|
|
//
|
|
// It is a leaf on the standard library alone, and deliberately so: the worker
|
|
// registers these paths and the frontend calls them, so both sides must agree
|
|
// on the literals without either importing the other's package.
|
|
package workerctl
|
|
|
|
import "encoding/json"
|
|
|
|
// Prefix is the one path prefix the worker mounts its whole control plane
|
|
// under. Everything the frontend may command a worker to do lives below it,
|
|
// which is what lets the worker put the control plane behind a single
|
|
// authentication check instead of one per verb.
|
|
const Prefix = "/v1/control/"
|
|
|
|
// The control verbs. Each replaces one NATS subject; the request and reply
|
|
// bodies are the messaging DTOs those subjects already carried, unchanged, so
|
|
// a worker still reachable over NATS and one reachable over the tunnel answer
|
|
// with the same bytes.
|
|
const (
|
|
PathBackendInstall = "/v1/control/backend/install"
|
|
PathBackendUpgrade = "/v1/control/backend/upgrade"
|
|
PathBackendList = "/v1/control/backend/list"
|
|
PathBackendStop = "/v1/control/backend/stop"
|
|
PathBackendDelete = "/v1/control/backend/delete"
|
|
PathModelStop = "/v1/control/model/stop"
|
|
PathModelUnload = "/v1/control/model/unload"
|
|
PathModelDelete = "/v1/control/model/delete"
|
|
PathModelsRunning = "/v1/control/models/running"
|
|
PathNodeStop = "/v1/control/node/stop"
|
|
|
|
// The file-staging verbs. They are only ever served by a worker whose
|
|
// deployment configured an object store, because without one there is
|
|
// nothing for them to move a file to or from; a worker without one mounts
|
|
// them not at all, and the catch-all under Prefix answers for them. That is
|
|
// the same 404 an older build gives, which is what the frontend already
|
|
// reads as "this worker does not serve that verb" rather than as absence.
|
|
PathFilesEnsure = "/v1/control/files/ensure"
|
|
PathFilesStage = "/v1/control/files/stage"
|
|
PathFilesTemp = "/v1/control/files/temp"
|
|
PathFilesListDir = "/v1/control/files/listdir"
|
|
)
|
|
|
|
// The verbs an AGENT worker serves.
|
|
//
|
|
// They live in the same package, under the same prefix and behind the same
|
|
// bearer check as the backend worker's, because a worker is a worker to the
|
|
// frontend: one control client, one path table, one set of failure meanings.
|
|
// What differs is which of them a given worker MOUNTS, which is why the two
|
|
// sets are named separately below.
|
|
const (
|
|
PathMCPToolExecute = "/v1/control/mcp/tools/execute"
|
|
PathMCPDiscovery = "/v1/control/mcp/discovery"
|
|
PathAgentExecute = "/v1/control/agent/execute"
|
|
PathAgentCancel = "/v1/control/agent/cancel"
|
|
PathMCPCIRun = "/v1/control/mcp/ci/run"
|
|
)
|
|
|
|
// BackendPaths returns every control verb a BACKEND worker serves.
|
|
//
|
|
// It exists so a spec can assert a property of the whole set rather than of a
|
|
// list it re-types, which would go stale the moment a verb is added.
|
|
func BackendPaths() []string {
|
|
return []string{
|
|
PathBackendInstall,
|
|
PathBackendUpgrade,
|
|
PathBackendList,
|
|
PathBackendStop,
|
|
PathBackendDelete,
|
|
PathModelStop,
|
|
PathModelUnload,
|
|
PathModelDelete,
|
|
PathModelsRunning,
|
|
PathNodeStop,
|
|
PathFilesEnsure,
|
|
PathFilesStage,
|
|
PathFilesTemp,
|
|
PathFilesListDir,
|
|
}
|
|
}
|
|
|
|
// AgentPaths returns every control verb an AGENT worker serves.
|
|
//
|
|
// PathBackendStop is in BOTH sets and that is the point rather than an
|
|
// oversight: one path, two implementations, one caller. A backend worker kills
|
|
// the process and recycles its port; an agent worker drops the MCP sessions it
|
|
// cached for that backend. The frontend issues the same RPC to either and does
|
|
// not branch on the node's type to pick a carrier.
|
|
//
|
|
// PathAgentCancel is named here with nothing mounting it yet. The frontend
|
|
// therefore gets the catch-all's 404, which it already reads as "this worker
|
|
// does not serve that verb" rather than as absence, and the path is fixed now
|
|
// so the two sides cannot disagree about it later.
|
|
func AgentPaths() []string {
|
|
return []string{
|
|
PathMCPToolExecute,
|
|
PathMCPDiscovery,
|
|
PathAgentExecute,
|
|
PathAgentCancel,
|
|
PathMCPCIRun,
|
|
PathBackendStop,
|
|
}
|
|
}
|
|
|
|
// AllPaths returns every control verb's path, each once.
|
|
//
|
|
// It is the union of the two sets above and is what package-wide properties
|
|
// (every path under the prefix, no two verbs sharing a path) are asserted
|
|
// over. A spec about what a PARTICULAR worker mounts must use BackendPaths or
|
|
// AgentPaths instead: asserting mounting over the union would require every
|
|
// worker to serve every verb, which is the opposite of what the split is for.
|
|
func AllPaths() []string {
|
|
seen := make(map[string]bool)
|
|
out := make([]string, 0, len(BackendPaths())+len(AgentPaths()))
|
|
for _, p := range append(BackendPaths(), AgentPaths()...) {
|
|
if seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Envelope is one line of a streaming control response.
|
|
//
|
|
// Exactly one of Progress and Reply is set. Zero or more Progress lines are
|
|
// followed by exactly ONE Reply line, and the Reply line is the last thing on
|
|
// the body. That ordering is the contract: it is what lets the frontend stop
|
|
// reading, and it is what replaces the subscribe-before-request dance the NATS
|
|
// carrier needed, since progress and reply now share one response and nothing
|
|
// can arrive before the caller is listening.
|
|
//
|
|
// Progress carrying the reply's own bytes is also why the 8000-byte
|
|
// notification cap that bounded the NATS progress subject has no analogue here:
|
|
// a line is written into the response body the caller is already reading.
|
|
type Envelope struct {
|
|
Progress json.RawMessage `json:"progress,omitempty"`
|
|
Reply json.RawMessage `json:"reply,omitempty"`
|
|
|
|
// Subject names the broadcast this progress line asks the frontend reading
|
|
// the stream to make on its behalf. Empty means the line is for this caller
|
|
// alone, which is what every pre-existing progress line is.
|
|
//
|
|
// It is a REQUEST and not an instruction: the frontend checks it against an
|
|
// allow list derived from the node's type before publishing anything. A
|
|
// worker naming a subject it has no business on is refused and logged, and
|
|
// the stream continues.
|
|
//
|
|
// It qualifies a Progress line and never a Reply line. A reply is the
|
|
// worker's verdict about the work, and there is no version of "publish my
|
|
// verdict for me" that this control plane has to carry.
|
|
Subject string `json:"subject,omitempty"`
|
|
}
|
|
|
|
// ContentTypeStream is the media type of a streaming control response.
|
|
const ContentTypeStream = "application/x-ndjson"
|