Persistent daemon + thin networked client #9

Open
harry wants to merge 14 commits from feat/daemon-client-split into main
6 changed files with 102 additions and 34 deletions
Showing only changes of commit c56de27f44 - Show all commits

View File

@@ -150,6 +150,12 @@ func (r *ProjectRegistry) Count() int {
return len(r.projects) return len(r.projects)
} }
func (r *ProjectRegistry) DefaultProject() *Project {
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[r.defaultProjectKey]
}
func (r *ProjectRegistry) Shutdown() { func (r *ProjectRegistry) Shutdown() {
r.mu.Lock() r.mu.Lock()
projects := make([]*Project, 0, len(r.projects)) projects := make([]*Project, 0, len(r.projects))
@@ -418,24 +424,24 @@ func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return r.hostForCaller(callerID).TimerList(callerID) return r.hostForCaller(callerID).TimerList(callerID)
} }
func (r *ProjectRegistry) ScratchpadList() ([]scratchpad.Entry, error) { func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) {
return r.hostForCaller("").ScratchpadList() return r.hostForCaller(callerID).ScratchpadList(callerID)
} }
func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) { func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) {
return r.hostForCaller("").ScratchpadRead(name) return r.hostForCaller(callerID).ScratchpadRead(callerID, name)
} }
func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) { func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) {
return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision) return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision)
} }
func (r *ProjectRegistry) ScratchpadAppend(name, content string) error { func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error {
return r.hostForCaller("").ScratchpadAppend(name, content) return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content)
} }
func (r *ProjectRegistry) ScratchpadDelete(name string) error { func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error {
return r.hostForCaller("").ScratchpadDelete(name) return r.hostForCaller(callerID).ScratchpadDelete(callerID, name)
} }
func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI { func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI {

View File

@@ -811,13 +811,13 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
// Scratchpads / Meta // Scratchpads / Meta
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() } func (h *toolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) { func (h *toolHost) ScratchpadRead(_ string, name string) (string, string, error) {
return h.pads.Read(name) return h.pads.Read(name)
} }
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) { func (h *toolHost) ScratchpadWrite(_, name, content, expectedRevision string) (string, error) {
rev, err := h.pads.Write(name, content, expectedRevision) rev, err := h.pads.Write(name, content, expectedRevision)
if err == nil && h.scratch != nil { if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged() h.scratch.scratchpadsChanged()
@@ -825,7 +825,7 @@ func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (stri
return rev, err return rev, err
} }
func (h *toolHost) ScratchpadAppend(name, content string) error { func (h *toolHost) ScratchpadAppend(_, name, content string) error {
err := h.pads.Append(name, content) err := h.pads.Append(name, content)
if err == nil && h.scratch != nil { if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged() h.scratch.scratchpadsChanged()
@@ -833,7 +833,7 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err return err
} }
func (h *toolHost) ScratchpadDelete(name string) error { func (h *toolHost) ScratchpadDelete(_, name string) error {
err := h.pads.Delete(name) err := h.pads.Delete(name)
if err == nil && h.scratch != nil { if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged() h.scratch.scratchpadsChanged()

View File

@@ -98,3 +98,65 @@ func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) {
t.Fatalf("switching back should preserve both project process trees") t.Fatalf("switching back should preserve both project process trees")
} }
} }
func TestProjectRegistryScratchpadsRouteByCallerProject(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
projectA, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project A: %v", err)
}
projectB, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project B: %v", err)
}
a, err := projectA.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "a-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project A caller: %v", err)
}
b, err := projectB.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "b-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project B caller: %v", err)
}
t.Cleanup(func() {
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
})
waitUntilLive(t, a)
waitUntilLive(t, b)
if _, err := reg.ScratchpadWrite(a.ID, "note.md", "project A", ""); err != nil {
t.Fatalf("write project A scratchpad: %v", err)
}
if _, err := reg.ScratchpadWrite(b.ID, "note.md", "project B", ""); err != nil {
t.Fatalf("write project B scratchpad: %v", err)
}
gotA, _, err := reg.ScratchpadRead(a.ID, "note.md")
if err != nil {
t.Fatalf("read project A scratchpad: %v", err)
}
gotB, _, err := reg.ScratchpadRead(b.ID, "note.md")
if err != nil {
t.Fatalf("read project B scratchpad: %v", err)
}
if gotA != "project A" || gotB != "project B" {
t.Fatalf("scratchpad routing leaked between projects: A=%q B=%q", gotA, gotB)
}
}

View File

@@ -119,7 +119,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40) host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil { if err := host.ScratchpadDelete("", "doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err) t.Fatalf("ScratchpadDelete: %v", err)
} }
if recorder.count != 1 { if recorder.count != 1 {
@@ -128,7 +128,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) { if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err) t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
} }
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) { if err := host.ScratchpadDelete("", "doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err) t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
} }
if recorder.count != 1 { if recorder.count != 1 {

View File

@@ -177,14 +177,14 @@ func (h *blockingToolHost) TimerResume(string, string) error { return nil }
func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) { func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
return nil, nil return nil, nil
} }
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil } func (h *blockingToolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) { func (h *blockingToolHost) ScratchpadRead(string, string) (string, string, error) {
return "", "", nil return "", "", nil
} }
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) { func (h *blockingToolHost) ScratchpadWrite(string, string, string, string) (string, error) {
return "", nil return "", nil
} }
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil } func (h *blockingToolHost) ScratchpadAppend(string, string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil } func (h *blockingToolHost) ScratchpadDelete(string, string) error { return nil }
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} } func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

View File

@@ -97,11 +97,11 @@ type ToolHost interface {
TimerList(callerID string) ([]TimerInfo, error) TimerList(callerID string) ([]TimerInfo, error)
// Scratchpads. // Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error) ScratchpadList(callerID string) ([]scratchpad.Entry, error)
ScratchpadRead(name string) (content string, revision string, err error) ScratchpadRead(callerID, name string) (content string, revision string, err error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadWrite(callerID, name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error ScratchpadAppend(callerID, name, content string) error
ScratchpadDelete(name string) error ScratchpadDelete(callerID, name string) error
// Meta. // Meta.
WhoAmI(callerID string) WhoAmI WhoAmI(callerID string) WhoAmI
@@ -724,7 +724,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return ts, 0, "", nil return ts, 0, "", nil
case "scratchpad_list": case "scratchpad_list":
entries, err := h.ScratchpadList() entries, err := h.ScratchpadList(callerID)
if err != nil { if err != nil {
return nil, codeInternal, err.Error(), nil return nil, codeInternal, err.Error(), nil
} }
@@ -737,7 +737,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
content, rev, err := h.ScratchpadRead(p.Name) content, rev, err := h.ScratchpadRead(callerID, p.Name)
if err != nil { if err != nil {
return nil, codeInternal, err.Error(), nil return nil, codeInternal, err.Error(), nil
} }
@@ -752,7 +752,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
rev, err := h.ScratchpadWrite(p.Name, p.Content, p.ExpectedRevision) rev, err := h.ScratchpadWrite(callerID, p.Name, p.Content, p.ExpectedRevision)
if err != nil { if err != nil {
// Optimistic-concurrency miss returns ok:false + current_revision // Optimistic-concurrency miss returns ok:false + current_revision
// rather than a JSON-RPC error so callers can re-read + merge. // rather than a JSON-RPC error so callers can re-read + merge.
@@ -772,7 +772,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
if err := h.ScratchpadAppend(p.Name, p.Content); err != nil { if err := h.ScratchpadAppend(callerID, p.Name, p.Content); err != nil {
return nil, codeInternal, err.Error(), nil return nil, codeInternal, err.Error(), nil
} }
return map[string]any{"ok": true}, 0, "", nil return map[string]any{"ok": true}, 0, "", nil
@@ -784,7 +784,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
if err := h.ScratchpadDelete(p.Name); err != nil { if err := h.ScratchpadDelete(callerID, p.Name); err != nil {
return nil, codeInternal, err.Error(), nil return nil, codeInternal, err.Error(), nil
} }
return map[string]any{"ok": true}, 0, "", nil return map[string]any{"ok": true}, 0, "", nil