mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
fix(sidebar): narrow user popover width
Merge conflict resolution: keep narrower w-48 popover from PR #1045. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"preview": "electron-vite preview",
|
||||
"package": "pnpm run bundle-cli && electron-builder",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,6 +48,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 19514;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
@@ -39,6 +40,11 @@ let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
let operationInProgress = false;
|
||||
let cachedCliBinary: string | null | undefined = undefined;
|
||||
let cliResolvePromise: Promise<string | null> | null = null;
|
||||
let cachedCliBinaryVersion: string | null | undefined = undefined;
|
||||
// Set when a CLI version mismatch was detected but the running daemon is
|
||||
// busy executing tasks. The poll loop retries the check on each tick and
|
||||
// fires the restart once active_task_count drops to 0.
|
||||
let pendingVersionRestart = false;
|
||||
let targetApiBaseUrl: string | null = null;
|
||||
let activeProfile: ActiveProfile | null = null;
|
||||
|
||||
@@ -132,6 +138,8 @@ interface HealthPayload {
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
server_url?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
agents?: string[];
|
||||
workspaces?: unknown[];
|
||||
}
|
||||
@@ -361,6 +369,91 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the running daemon's `cli_version` against the CLI binary we
|
||||
* would use to spawn a new one, and restarts only when safe. The decision
|
||||
* logic itself is in `version-decision.ts` (pure, unit-tested); this
|
||||
* wrapper handles the async plumbing and side effects.
|
||||
*
|
||||
* Restart is only fired when ALL of:
|
||||
* - a daemon is actually running on the active profile's port
|
||||
* - both sides report a version and the strings differ
|
||||
* - `active_task_count` is 0 (no in-flight agent work would be killed)
|
||||
*
|
||||
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
|
||||
* is set; the poll loop retries this function on each 5s tick and will fire
|
||||
* the restart as soon as the daemon drains.
|
||||
*/
|
||||
async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
"restarted" | "deferred" | "ok" | "not_running"
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
switch (action) {
|
||||
case "not_running":
|
||||
pendingVersionRestart = false;
|
||||
return "not_running";
|
||||
case "ok":
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
case "defer": {
|
||||
if (!pendingVersionRestart) {
|
||||
const activeTasks = running?.active_task_count ?? 0;
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
|
||||
);
|
||||
}
|
||||
pendingVersionRestart = true;
|
||||
return "deferred";
|
||||
}
|
||||
case "restart":
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
|
||||
);
|
||||
pendingVersionRestart = false;
|
||||
await restartDaemon();
|
||||
return "restarted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
|
||||
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
|
||||
@@ -582,6 +675,10 @@ async function pollOnce(): Promise<void> {
|
||||
const status = await fetchHealth();
|
||||
currentState = status.state;
|
||||
sendStatus(status);
|
||||
// Retry a deferred version-mismatch restart once the daemon drains.
|
||||
if (pendingVersionRestart && status.state === "running") {
|
||||
void ensureRunningDaemonVersionMatches();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
@@ -738,6 +835,9 @@ export function setupDaemonManager(
|
||||
ipcMain.handle("daemon:retry-install", async () => {
|
||||
cachedCliBinary = undefined;
|
||||
cliResolvePromise = null;
|
||||
// A retry-install may land a new CLI at a different version; drop the
|
||||
// cached version string so the next check re-reads the binary.
|
||||
cachedCliBinaryVersion = undefined;
|
||||
await bootstrapCli();
|
||||
});
|
||||
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
|
||||
@@ -755,7 +855,12 @@ export function setupDaemonManager(
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return;
|
||||
const health = await fetchHealth();
|
||||
if (health.state === "running") return;
|
||||
if (health.state === "running") {
|
||||
// Daemon is up but may be running an older CLI than the one we just
|
||||
// bundled. Restart it so the new binary actually takes effect.
|
||||
await ensureRunningDaemonVersionMatches();
|
||||
return;
|
||||
}
|
||||
await startDaemon();
|
||||
});
|
||||
|
||||
|
||||
88
apps/desktop/src/main/version-decision.test.ts
Normal file
88
apps/desktop/src/main/version-decision.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("returns not_running when health payload is null", () => {
|
||||
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns not_running when status is not 'running'", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
|
||||
).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns ok when bundled version is unknown (fail safe)", () => {
|
||||
expect(
|
||||
decideVersionAction(null, {
|
||||
status: "running",
|
||||
cli_version: "v1.0.0",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", {
|
||||
status: "running",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when versions match exactly", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.3",
|
||||
active_task_count: 5,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns restart when versions differ and daemon is idle", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("returns defer when versions differ but daemon is busy", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 2,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
|
||||
it("transitions defer → restart as tasks drain", () => {
|
||||
// Same bundled version across three observations while the daemon ages.
|
||||
const bundled = "v2.0.0";
|
||||
const base = { status: "running", cli_version: "v1.9.0" } as const;
|
||||
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
|
||||
).toBe("restart");
|
||||
});
|
||||
});
|
||||
37
apps/desktop/src/main/version-decision.ts
Normal file
37
apps/desktop/src/main/version-decision.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Pure decision logic for the daemon version-check flow. Kept in its own
|
||||
// module so it can be unit-tested without mocking Electron, execFile, or
|
||||
// the HTTP health probe.
|
||||
|
||||
export interface VersionCheckHealth {
|
||||
status?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
}
|
||||
|
||||
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
|
||||
|
||||
/**
|
||||
* Decides what the daemon-manager should do given the currently-resolved
|
||||
* bundled CLI version and the latest /health payload.
|
||||
*
|
||||
* not_running: no daemon is up, nothing to do
|
||||
* ok: versions match, OR either side is unknown (fail safe)
|
||||
* defer: versions differ but the daemon is busy — wait for drain
|
||||
* restart: versions differ and the daemon is idle — safe to restart
|
||||
*
|
||||
* Pure function: no I/O, no side effects, no module state.
|
||||
*/
|
||||
export function decideVersionAction(
|
||||
bundled: string | null,
|
||||
running: VersionCheckHealth | null,
|
||||
): VersionAction {
|
||||
if (!running || running.status !== "running") return "not_running";
|
||||
|
||||
const runningVersion = running.cli_version;
|
||||
if (!bundled || !runningVersion) return "ok";
|
||||
if (runningVersion === bundled) return "ok";
|
||||
|
||||
const activeTasks = running.active_task_count ?? 0;
|
||||
if (activeTasks > 0) return "defer";
|
||||
return "restart";
|
||||
}
|
||||
10
apps/desktop/vitest.config.ts
Normal file
10
apps/desktop/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -181,6 +181,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
apps/docs:
|
||||
dependencies:
|
||||
|
||||
@@ -39,6 +39,7 @@ type Daemon struct {
|
||||
cancelFunc context.CancelFunc // set by Run(); called by triggerRestart
|
||||
restartBinary string // non-empty after a successful update; path to the new binary
|
||||
updating atomic.Bool // prevents concurrent update attempts
|
||||
activeTasks atomic.Int64 // number of tasks currently in handleTask; exposed via /health
|
||||
}
|
||||
|
||||
// New creates a new Daemon instance.
|
||||
@@ -649,8 +650,10 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
|
||||
}
|
||||
d.logger.Info("task received", "task", shortID(task.ID), "target", taskTarget)
|
||||
wg.Add(1)
|
||||
d.activeTasks.Add(1)
|
||||
go func(t Task) {
|
||||
defer wg.Done()
|
||||
defer d.activeTasks.Add(-1)
|
||||
defer func() { <-sem }()
|
||||
d.handleTask(ctx, t)
|
||||
}(*task)
|
||||
|
||||
@@ -14,14 +14,16 @@ import (
|
||||
|
||||
// HealthResponse is returned by the daemon's local health endpoint.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
Uptime string `json:"uptime"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Agents []string `json:"agents"`
|
||||
Workspaces []healthWorkspace `json:"workspaces"`
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
Uptime string `json:"uptime"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
ActiveTaskCount int64 `json:"active_task_count"`
|
||||
Agents []string `json:"agents"`
|
||||
Workspaces []healthWorkspace `json:"workspaces"`
|
||||
}
|
||||
|
||||
type healthWorkspace struct {
|
||||
@@ -49,11 +51,10 @@ type repoCheckoutRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// serveHealth runs the health HTTP server on the given listener.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
// healthHandler returns the /health HTTP handler. Extracted from serveHealth
|
||||
// so tests can exercise it without spinning up a listener.
|
||||
func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
var wsList []healthWorkspace
|
||||
for id, ws := range d.workspaces {
|
||||
@@ -70,19 +71,28 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
||||
}
|
||||
|
||||
resp := HealthResponse{
|
||||
Status: "running",
|
||||
PID: os.Getpid(),
|
||||
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
|
||||
DaemonID: d.cfg.DaemonID,
|
||||
DeviceName: d.cfg.DeviceName,
|
||||
ServerURL: d.cfg.ServerBaseURL,
|
||||
Agents: agents,
|
||||
Workspaces: wsList,
|
||||
Status: "running",
|
||||
PID: os.Getpid(),
|
||||
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
|
||||
DaemonID: d.cfg.DaemonID,
|
||||
DeviceName: d.cfg.DeviceName,
|
||||
ServerURL: d.cfg.ServerBaseURL,
|
||||
CLIVersion: d.cfg.CLIVersion,
|
||||
ActiveTaskCount: d.activeTasks.Load(),
|
||||
Agents: agents,
|
||||
Workspaces: wsList,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// serveHealth runs the health HTTP server on the given listener.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", d.healthHandler(startedAt))
|
||||
|
||||
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
||||
100
server/internal/daemon/health_test.go
Normal file
100
server/internal/daemon/health_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &Daemon{
|
||||
cfg: Config{
|
||||
CLIVersion: "v9.9.9",
|
||||
DaemonID: "daemon-test",
|
||||
DeviceName: "dev",
|
||||
ServerBaseURL: "http://localhost:8080",
|
||||
},
|
||||
workspaces: map[string]*workspaceState{},
|
||||
logger: slog.Default(),
|
||||
}
|
||||
d.activeTasks.Store(3)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
d.healthHandler(time.Now()).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Decode into a raw map so the test locks in the exact wire-level JSON
|
||||
// keys — the desktop TS client depends on snake_case (cli_version,
|
||||
// active_task_count), so a silent struct-tag rename must fail here.
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("decode raw response: %v", err)
|
||||
}
|
||||
if got, want := raw["cli_version"], "v9.9.9"; got != want {
|
||||
t.Errorf("cli_version key: got %v, want %q", got, want)
|
||||
}
|
||||
// JSON numbers decode to float64 through map[string]any.
|
||||
if got, want := raw["active_task_count"], float64(3); got != want {
|
||||
t.Errorf("active_task_count key: got %v, want %v", got, want)
|
||||
}
|
||||
if got, want := raw["status"], "running"; got != want {
|
||||
t.Errorf("status key: got %v, want %q", got, want)
|
||||
}
|
||||
|
||||
// Also round-trip into the typed struct as a separate check that the
|
||||
// field values match, independent of key naming.
|
||||
var resp HealthResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode typed response: %v", err)
|
||||
}
|
||||
if resp.CLIVersion != "v9.9.9" {
|
||||
t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9")
|
||||
}
|
||||
if resp.ActiveTaskCount != 3 {
|
||||
t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &Daemon{
|
||||
cfg: Config{CLIVersion: "v1.0.0"},
|
||||
workspaces: map[string]*workspaceState{},
|
||||
logger: slog.Default(),
|
||||
}
|
||||
handler := d.healthHandler(time.Now())
|
||||
|
||||
// Simulate the pollLoop increment/decrement protocol.
|
||||
d.activeTasks.Add(1)
|
||||
d.activeTasks.Add(1)
|
||||
assertActiveTaskCount(t, handler, 2)
|
||||
|
||||
d.activeTasks.Add(-1)
|
||||
assertActiveTaskCount(t, handler, 1)
|
||||
|
||||
d.activeTasks.Add(-1)
|
||||
assertActiveTaskCount(t, handler, 0)
|
||||
}
|
||||
|
||||
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
var resp HealthResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.ActiveTaskCount != want {
|
||||
t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user