Merge branch 'fix/g07' into fix/integrated
# Conflicts: # internal/ipc/api.go # internal/ipc/client.go # internal/llm/client.go
This commit is contained in:
@@ -413,11 +413,17 @@ type SwapModelReq struct {
|
||||
// RolledBack is true when the requested model failed to load or would not answer
|
||||
// and the previous one was put back. In that case the call also returns an error
|
||||
// — the swap did not happen — and Model names the model still serving.
|
||||
//
|
||||
// NoBackend is the other failure and it is not a milder one: the rollback failed
|
||||
// too, no model is loaded, and every phrasing path is on its template fallback
|
||||
// with routing on the classifier. It is a separate field from RolledBack because
|
||||
// the two need opposite words on the page.
|
||||
type SwapModelResp struct {
|
||||
Model string `json:"model"`
|
||||
ModelPath string `json:"model_path"`
|
||||
BaseURL string `json:"base_url"`
|
||||
RolledBack bool `json:"rolled_back,omitempty"`
|
||||
NoBackend bool `json:"no_backend,omitempty"`
|
||||
TookMs int64 `json:"took_ms"`
|
||||
}
|
||||
|
||||
@@ -433,6 +439,15 @@ type ModelStatusResp struct {
|
||||
Swappable []string `json:"swappable,omitempty"`
|
||||
}
|
||||
|
||||
// PingResp — the answer to MethodPing. Alive is always true (the reply itself
|
||||
// is the proof); Locked says whether the daemon is still waiting for a passkey
|
||||
// assertion, which is the one state where a CoreAPI read cannot tell an
|
||||
// operator anything.
|
||||
type PingResp struct {
|
||||
Alive bool `json:"alive"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
@@ -489,6 +504,10 @@ type kindNReq struct {
|
||||
Kind string `json:"kind"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
type sourceNReq struct {
|
||||
Prefix string `json:"prefix"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
type calendarEventsReq struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
@@ -646,6 +665,11 @@ type CoreAPI interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
|
||||
+43
-22
@@ -55,28 +55,30 @@ var ErrAmbiguousOutcome = errors.New("ipc: mutation outcome unknown (connection
|
||||
// conservative (refusing to retry) is the safe default for a method added
|
||||
// here by omission.
|
||||
var readOnlyMethods = map[Method]bool{
|
||||
MethodLatestFact: true,
|
||||
MethodLatestFactBySource: true,
|
||||
MethodSince: true,
|
||||
MethodPresence: true,
|
||||
MethodListReminders: true,
|
||||
MethodRecentOutcomes: true,
|
||||
MethodRecentFacts: true,
|
||||
MethodRecentActiveFacts: true,
|
||||
MethodCalendarEvents: true,
|
||||
MethodRecentNudges: true,
|
||||
MethodRecentEcoTraces: true,
|
||||
MethodQueryNotes: true,
|
||||
MethodRecentNotes: true,
|
||||
MethodLookupTool: true,
|
||||
MethodListTools: true,
|
||||
MethodListProposedRoutines: true,
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodMCPServers: true,
|
||||
MethodDayPlan: true,
|
||||
MethodRecentEvents: true,
|
||||
MethodLatestFact: true,
|
||||
MethodLatestFactBySource: true,
|
||||
MethodSince: true,
|
||||
MethodPresence: true,
|
||||
MethodListReminders: true,
|
||||
MethodRecentOutcomes: true,
|
||||
MethodRecentFacts: true,
|
||||
MethodRecentActiveFacts: true,
|
||||
MethodCalendarEvents: true,
|
||||
MethodRecentNudges: true,
|
||||
MethodRecentEcoTraces: true,
|
||||
MethodQueryNotes: true,
|
||||
MethodRecentNotes: true,
|
||||
MethodLookupTool: true,
|
||||
MethodListTools: true,
|
||||
MethodListProposedRoutines: true,
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodMCPServers: true,
|
||||
MethodDayPlan: true,
|
||||
MethodRecentEvents: true,
|
||||
MethodRecentNotesFromSource: true,
|
||||
MethodPing: true,
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
@@ -392,6 +394,14 @@ func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
var out []Note
|
||||
if err := c.call(ctx, MethodRecentNotesFromSource, sourceNReq{Prefix: prefix, N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
var r proposeToolResp
|
||||
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil {
|
||||
@@ -659,5 +669,16 @@ func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return result.NewID, nil
|
||||
}
|
||||
|
||||
// Ping asks whether the daemon is there, and whether it is locked. It is not a
|
||||
// CoreAPI method: it touches no store, so it answers before the passkey
|
||||
// assertion that every other read waits for.
|
||||
func (c *Client) Ping(ctx context.Context) (PingResp, error) {
|
||||
var r PingResp
|
||||
if err := c.call(ctx, MethodPing, nil, &r); err != nil {
|
||||
return PingResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Compile-time check: *Client satisfies CoreAPI.
|
||||
var _ CoreAPI = (*Client)(nil)
|
||||
|
||||
@@ -189,6 +189,18 @@ func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) (
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
@@ -517,6 +529,11 @@ type Server struct {
|
||||
ListSpeakersFn ListSpeakersFunc
|
||||
ForgetSpeakerFn ForgetSpeakerFunc
|
||||
|
||||
// LockedFn — reports whether the daemon is in locked (pre-unlock) mode.
|
||||
// Read by MethodPing only. Nil ⇒ not locked, which is what an embedded or
|
||||
// test Server without the unlock dance is.
|
||||
LockedFn func() bool
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey PRF secret, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -853,6 +870,16 @@ var methodTable = map[Method]handlerFunc{
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentNotesFromSource: withParams(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
|
||||
out, err := api.RecentNotesFromSource(ctx, p.Prefix, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
|
||||
out, err := api.RecentNotes(ctx, p.N)
|
||||
if err != nil {
|
||||
@@ -988,6 +1015,17 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
||||
// state, so they can never be table entries keyed on a CoreAPI method.
|
||||
switch req.Method {
|
||||
case MethodPing:
|
||||
// Deliberately reaches nothing: no store, no CoreAPI, no daemon
|
||||
// component. That is what makes it answerable in locked mode, and it is
|
||||
// the whole point — an update that restarts her into locked mode has to
|
||||
// be able to tell that apart from a daemon that did not come up.
|
||||
locked := false
|
||||
if s.LockedFn != nil {
|
||||
locked = s.LockedFn()
|
||||
}
|
||||
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
|
||||
|
||||
case MethodAssertStepUp:
|
||||
if s.StepUp != nil {
|
||||
return marshalResult(nil), s.StepUp(ctx)
|
||||
|
||||
@@ -80,6 +80,10 @@ func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text st
|
||||
func (UnimplementedCoreAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
|
||||
func (UnimplementedCoreAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -139,3 +139,39 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
|
||||
t.Error("UnlockFn never ran")
|
||||
}
|
||||
}
|
||||
|
||||
// A locked daemon has to be able to say it is alive. Every CoreAPI method is
|
||||
// refused before unlock, so a health check built on one of those cannot tell a
|
||||
// daemon waiting for a passkey apart from a daemon that failed to start. That
|
||||
// is what turned a good update into the manual-recovery case in
|
||||
// internal/update. MethodPing reaches no store, so it answers either way.
|
||||
func TestPingAnswersWhileLocked(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
srv.LockedFn = func() bool { return true }
|
||||
locked := errors.New("daemon locked")
|
||||
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case MethodAssertStepUp, MethodUnlock, MethodPing:
|
||||
return nil
|
||||
default:
|
||||
return locked
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, err := cli.Ping(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Ping while locked: %v", err)
|
||||
}
|
||||
if !p.Alive || !p.Locked {
|
||||
t.Errorf("Ping = %+v; want alive and locked", p)
|
||||
}
|
||||
// And the read it replaces is still refused, which is the whole point.
|
||||
if _, err := cli.Presence(ctx); err == nil {
|
||||
t.Error("Presence answered while locked")
|
||||
}
|
||||
|
||||
srv.LockedFn = func() bool { return false }
|
||||
if p, err := cli.Ping(ctx); err != nil || p.Locked {
|
||||
t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
MethodWriteNote Method = "write_note"
|
||||
MethodQueryNotes Method = "query_notes"
|
||||
MethodRecentNotes Method = "recent_notes"
|
||||
MethodRecentNotesFromSource Method = "recent_notes_source"
|
||||
MethodProposeTool Method = "propose_tool"
|
||||
MethodEnableTool Method = "enable_tool"
|
||||
MethodDisableTool Method = "disable_tool"
|
||||
@@ -65,6 +66,14 @@ const (
|
||||
MethodListSpeakers Method = "list_speakers"
|
||||
MethodForgetSpeaker Method = "forget_speaker"
|
||||
MethodRecentEvents Method = "recent_events"
|
||||
|
||||
// MethodPing — liveness, and the only method that answers in locked mode
|
||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||
// returns whether the daemon is locked, so an operator tool can tell "she is
|
||||
// up and waiting for a passkey" apart from "she is not there at all".
|
||||
// Everything else about her state needs the store, and the store needs the
|
||||
// key.
|
||||
MethodPing Method = "ping"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
Reference in New Issue
Block a user