feat(updates): auto-apply and self-restart on new dashboard versions
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now" button and hourly auto-check in the UI, for checkouts that are safely fast-forwardable. Reintroduces self-restart (previously removed in edc25ca for cross-environment reliability concerns) per explicit user request.
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
## Repo map
|
## Repo map
|
||||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
||||||
- `client/`: React + Vite UI.
|
- `client/`: React + Vite UI.
|
||||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `ccam update-check`.)
|
||||||
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
||||||
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
* @file UpdateNotifier.tsx
|
* @file UpdateNotifier.tsx
|
||||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||||
* command to update, and copy-to-clipboard — the dashboard never pulls or
|
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||||
* restarts itself.
|
* fast-forwardable branch — an "Update now" button that calls
|
||||||
|
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||||
|
* then polls until it's back and reloads the page.
|
||||||
*
|
*
|
||||||
* ## State sources
|
* ## State sources
|
||||||
* - Initial fetch via `api.updates.status()` on mount.
|
* - Initial fetch via `api.updates.status()` on mount.
|
||||||
|
* - Background re-check via `api.updates.check()` every hour
|
||||||
|
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
||||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||||
*
|
*
|
||||||
* ## Dismissal persistence
|
* ## Dismissal persistence
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Download, X, Copy, Check, RefreshCw } from "lucide-react";
|
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
@@ -71,6 +75,15 @@ import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
|||||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||||
|
|
||||||
|
/** How often to silently re-check for updates in the background. */
|
||||||
|
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
||||||
|
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
||||||
|
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
||||||
|
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
||||||
|
}
|
||||||
|
|
||||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||||
@@ -96,6 +109,8 @@ export function UpdateNotifier() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
@@ -128,6 +143,19 @@ export function UpdateNotifier() {
|
|||||||
});
|
});
|
||||||
}, [syncFromPayload]);
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
|
// Background re-check every hour, on top of the initial mount fetch and the
|
||||||
|
// manual "Check now" button — so a long-lived tab notices an update without
|
||||||
|
// the user having to click anything.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
api.updates
|
||||||
|
.check()
|
||||||
|
.then(syncFromPayload)
|
||||||
|
.catch(() => {});
|
||||||
|
}, AUTO_CHECK_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setDismissedSha(null);
|
const handler = () => setDismissedSha(null);
|
||||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||||
@@ -139,14 +167,14 @@ export function UpdateNotifier() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (!status?.remote_sha) return;
|
if (restarting || !status?.remote_sha) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setDismissedSha(status.remote_sha);
|
setDismissedSha(status.remote_sha);
|
||||||
}, [status?.remote_sha]);
|
}, [restarting, status?.remote_sha]);
|
||||||
|
|
||||||
// Escape to dismiss - standard modal affordance.
|
// Escape to dismiss - standard modal affordance.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,6 +211,41 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Polls `status()` until the restarted server answers again, then reloads
|
||||||
|
// so the tab picks up the new client bundle too — the server can't push
|
||||||
|
// this over its own WebSocket since it's mid-restart.
|
||||||
|
const pollUntilBack = useCallback(() => {
|
||||||
|
const attempt = () => {
|
||||||
|
api.updates
|
||||||
|
.status()
|
||||||
|
.then(() => window.location.reload())
|
||||||
|
.catch(() => setTimeout(attempt, 1500));
|
||||||
|
};
|
||||||
|
setTimeout(attempt, 1500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyNow = async () => {
|
||||||
|
if (applying || restarting) return;
|
||||||
|
setError(null);
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const result = await api.updates.apply();
|
||||||
|
if (result.applied) {
|
||||||
|
setApplying(false);
|
||||||
|
setRestarting(true);
|
||||||
|
pollUntilBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t("applyError"));
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -266,6 +329,13 @@ export function UpdateNotifier() {
|
|||||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{restarting ? (
|
||||||
|
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||||
|
{t("restarting")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-status-danger" role="alert">
|
<p className="text-xs text-status-danger" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -278,13 +348,18 @@ export function UpdateNotifier() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={checkNow}
|
onClick={checkNow}
|
||||||
disabled={checking}
|
disabled={checking || applying || restarting}
|
||||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||||
{checking ? t("checking") : t("checkNow")}
|
{checking ? t("checking") : t("checkNow")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={dismiss} className="btn-ghost">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
disabled={restarting}
|
||||||
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{t("dismiss")}
|
{t("dismiss")}
|
||||||
</button>
|
</button>
|
||||||
{status.manual_command ? (
|
{status.manual_command ? (
|
||||||
@@ -292,12 +367,27 @@ export function UpdateNotifier() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={copyCmd}
|
onClick={copyCmd}
|
||||||
disabled={copied}
|
disabled={copied}
|
||||||
className="btn-primary disabled:opacity-70"
|
className="btn-ghost disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
{copied ? t("copied") : t("copy")}
|
{copied ? t("copied") : t("copy")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isAutoApplicable(status.situation) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyNow}
|
||||||
|
disabled={applying || restarting}
|
||||||
|
className="btn-primary disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{applying || restarting ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-4 h-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @file UpdateNotifier.test.tsx
|
||||||
|
* @description Pins the "Update now" self-apply flow added on top of the
|
||||||
|
* existing manual-command modal: the button only renders when the checkout
|
||||||
|
* situation is fast-forwardable, clicking it calls `api.updates.apply()`,
|
||||||
|
* and a successful `applied: true` response flips the modal into a
|
||||||
|
* "restarting" state that polls `api.updates.status()` until it resolves.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { UpdateNotifier } from "../UpdateNotifier";
|
||||||
|
import type { UpdateStatusPayload } from "../../lib/types";
|
||||||
|
|
||||||
|
const statusMock = vi.fn();
|
||||||
|
const checkMock = vi.fn();
|
||||||
|
const applyMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
updates: {
|
||||||
|
status: (...args: unknown[]) => statusMock(...args),
|
||||||
|
check: (...args: unknown[]) => checkMock(...args),
|
||||||
|
apply: (...args: unknown[]) => applyMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const BASE_STATUS: UpdateStatusPayload = {
|
||||||
|
git_repo: true,
|
||||||
|
update_available: true,
|
||||||
|
repo_root: "/repo",
|
||||||
|
remote_ref: "origin/main",
|
||||||
|
canonical_remote: "origin",
|
||||||
|
current_branch: "main",
|
||||||
|
tracking_upstream: "origin/main",
|
||||||
|
tracks_canonical: true,
|
||||||
|
situation: "tracking_canonical",
|
||||||
|
situation_note: null,
|
||||||
|
local_sha: "aaa",
|
||||||
|
remote_sha: "bbb",
|
||||||
|
commits_behind: 2,
|
||||||
|
manual_command: 'cd "/repo" && git pull --ff-only && npm run setup',
|
||||||
|
message: "2 commit(s) on origin/main not in your checkout.",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
|
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
|
applyMock.mockReset();
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch {
|
||||||
|
// Some CI environments stub a non-functional localStorage; the component
|
||||||
|
// already tolerates that (see UpdateNotifier's own try/catch), so tests do too.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — Update now", () => {
|
||||||
|
it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => {
|
||||||
|
applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||||
|
await userEvent.click(updateBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(applyMock).toHaveBeenCalled());
|
||||||
|
// Appears both in the status banner and the button label while restarting.
|
||||||
|
const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting"));
|
||||||
|
expect(restartingNodes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Update now button for a non-fast-forwardable branch", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "feature_branch",
|
||||||
|
tracks_canonical: false,
|
||||||
|
});
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||||
|
applyMock.mockResolvedValue({
|
||||||
|
...BASE_STATUS,
|
||||||
|
applied: false,
|
||||||
|
reason: "not_fast_forwardable",
|
||||||
|
});
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||||
|
await userEvent.click(updateBtn);
|
||||||
|
|
||||||
|
expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,5 +11,10 @@
|
|||||||
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
|
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
|
||||||
"checkNow": "Check now",
|
"checkNow": "Check now",
|
||||||
"checking": "Checking...",
|
"checking": "Checking...",
|
||||||
"checkError": "Could not check for updates"
|
"checkError": "Could not check for updates",
|
||||||
|
"updateNow": "Update now",
|
||||||
|
"updating": "Updating...",
|
||||||
|
"restarting": "Rebuilt — restarting the dashboard...",
|
||||||
|
"applyError": "Could not apply the update",
|
||||||
|
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
|
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
|
||||||
"checkNow": "Kiểm tra ngay",
|
"checkNow": "Kiểm tra ngay",
|
||||||
"checking": "Đang kiểm tra...",
|
"checking": "Đang kiểm tra...",
|
||||||
"checkError": "Không thể kiểm tra cập nhật"
|
"checkError": "Không thể kiểm tra cập nhật",
|
||||||
|
"updateNow": "Cập nhật ngay",
|
||||||
|
"updating": "Đang cập nhật...",
|
||||||
|
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
|
||||||
|
"applyError": "Không thể áp dụng cập nhật",
|
||||||
|
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -557,6 +557,23 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* POST /api/updates/apply - fast-forward, rebuild, and restart.
|
||||||
|
*
|
||||||
|
* Only acts when the checkout is fast-forwardable (`tracking_canonical`
|
||||||
|
* or `fork_or_diverged_tracking`); a 409 response means it declined
|
||||||
|
* (`applied: false`, `reason` explains why). On success the server
|
||||||
|
* restarts itself right after responding, so the caller should poll
|
||||||
|
* `status()` until it answers again rather than expect the connection
|
||||||
|
* to outlive the call.
|
||||||
|
*
|
||||||
|
* @returns {@link UpdateStatusPayload} with `applied` set.
|
||||||
|
*/
|
||||||
|
apply: () =>
|
||||||
|
request<UpdateStatusPayload>("/updates/apply", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────── Stats API ────────────────────────────────
|
// ──────────────────────────────── Stats API ────────────────────────────────
|
||||||
|
|||||||
@@ -1275,6 +1275,14 @@ export interface UpdateStatusPayload {
|
|||||||
/** Set instead of a normal result when the remote fetch itself failed
|
/** Set instead of a normal result when the remote fetch itself failed
|
||||||
* (e.g. offline) - the message text explains it in user-facing terms. */
|
* (e.g. offline) - the message text explains it in user-facing terms. */
|
||||||
fetch_error?: string;
|
fetch_error?: string;
|
||||||
|
/** Present only on the response from `POST /api/updates/apply` - true once
|
||||||
|
* the checkout was fast-forwarded and rebuilt (the process is about to
|
||||||
|
* restart itself). Absent from `status`/`check` responses. */
|
||||||
|
applied?: boolean;
|
||||||
|
/** Set alongside `applied: false` on an `/apply` response that declined to
|
||||||
|
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
|
||||||
|
* detached HEAD, where there's no safe automatic move). */
|
||||||
|
reason?: "up_to_date" | "not_fast_forwardable";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── Terminal run status ─────
|
// ───── Terminal run status ─────
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `ccam
|
|||||||
| `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` |
|
| `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` |
|
||||||
| `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
| `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
||||||
| `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
|
| `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
|
||||||
| `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command** — the dashboard never restarts itself. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
| `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. When the checkout is fast-forwardable, the dashboard UI can also self-apply (`POST /api/updates/apply`: pull, rebuild, self-restart) instead of running the command by hand. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
||||||
| `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
|
| `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
|
||||||
| `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
|
| `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
|
||||||
| `ccam version` | Print the ccam version (also `--version` / `-v`) |
|
| `ccam version` | Print the ccam version (also `--version` / `-v`) |
|
||||||
|
|||||||
@@ -6328,6 +6328,35 @@ paths:
|
|||||||
error:
|
error:
|
||||||
code: UPDATE_CHECK_FAILED
|
code: UPDATE_CHECK_FAILED
|
||||||
message: 'git fetch failed: network unreachable'
|
message: 'git fetch failed: network unreachable'
|
||||||
|
/api/updates/apply:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Updates
|
||||||
|
summary: Pull, rebuild, and restart the dashboard when fast-forwardable
|
||||||
|
operationId: applyUpdate
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Checkout fast-forwarded and rebuilt; the process is about to self-restart
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Same shape as GET /api/updates/status, plus applied: true.'
|
||||||
|
'409':
|
||||||
|
description: Declined - nothing to apply, or the checkout isn't safely fast-forwardable
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable".'
|
||||||
|
'500':
|
||||||
|
description: Update apply failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/alerts:
|
/api/alerts:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
|
||||||
|
* old dashboard PID until it exits (server/index.js's own SIGTERM handler
|
||||||
|
* has a 5s force-exit backstop, so this never waits forever), then starts a
|
||||||
|
* fresh server from the same entry point and exits itself.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
const [, , pidArg, entry] = process.argv;
|
||||||
|
const pid = parseInt(pidArg, 10);
|
||||||
|
|
||||||
|
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
|
||||||
|
const POLL_MS = 200;
|
||||||
|
const MAX_WAIT_MS = 20_000;
|
||||||
|
|
||||||
|
function isAlive(p) {
|
||||||
|
try {
|
||||||
|
process.kill(p, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const child = spawn(process.execPath, [entry], {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitAndRestart(waited = 0) {
|
||||||
|
if (isAlive(pid) && waited < MAX_WAIT_MS) {
|
||||||
|
setTimeout(() => waitAndRestart(waited + POLL_MS), POLL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitAndRestart();
|
||||||
@@ -90,12 +90,17 @@ describe("POST /api/updates/check", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removed POST /api/updates/apply", () => {
|
describe("POST /api/updates/apply", () => {
|
||||||
it("returns 404 because self-update has been removed", async () => {
|
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
|
||||||
|
// The test repo checkout has no update pending (or isn't fast-forwardable
|
||||||
|
// from a bare test run), so this exercises the decline path only — it must
|
||||||
|
// never touch the working tree or process in a test run.
|
||||||
const res = await httpFetch("/api/updates/apply", {
|
const res = await httpFetch("/api/updates/apply", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
});
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 409);
|
||||||
|
assert.equal(res.body.applied, false);
|
||||||
|
assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @file Triggers the one self-restart path this dashboard has: after
|
||||||
|
* `POST /api/updates/apply` fast-forwards and rebuilds the checkout, the
|
||||||
|
* running process needs to relaunch itself on the new code. Reuses the
|
||||||
|
* existing SIGTERM graceful-shutdown path in `server/index.js` (closes
|
||||||
|
* websockets, drains the HTTP server, closes the DB) instead of duplicating
|
||||||
|
* it, and hands the "wait for the old PID to actually die, then spawn the
|
||||||
|
* replacement" job to a detached helper script — the dying process can't do
|
||||||
|
* that itself without racing its own replacement for the port.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} [root] repo root the helper should run from.
|
||||||
|
*/
|
||||||
|
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
|
||||||
|
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||||
|
const entry = process.argv[1] || path.join(root, "server", "index.js");
|
||||||
|
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
|
||||||
|
cwd: root,
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
helper.unref();
|
||||||
|
process.kill(process.pid, "SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scheduleRestart };
|
||||||
@@ -54,6 +54,24 @@ function execGit(cwd, args, opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
||||||
|
* {@link applyUpdate} runs after fast-forwarding. */
|
||||||
|
function execNpm(cwd, args, opts = {}) {
|
||||||
|
const timeout = opts.timeout ?? 300_000;
|
||||||
|
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
npmCmd,
|
||||||
|
args,
|
||||||
|
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) reject(new Error(stderr || err.message || String(err)));
|
||||||
|
else resolve(String(stdout).trim());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function listRemotes(gitRoot) {
|
async function listRemotes(gitRoot) {
|
||||||
try {
|
try {
|
||||||
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
||||||
@@ -273,4 +291,47 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
|
/**
|
||||||
|
* Fast-forwards the checkout to the canonical remote and rebuilds, for the
|
||||||
|
* "Update now" button — the one path in this dashboard that mutates the
|
||||||
|
* working tree and restarts the process on its own initiative (everywhere
|
||||||
|
* else, `server/routes/updates.js` only ever prints a command for the user
|
||||||
|
* to run). Only acts when {@link getUpdatesStatus} reports a situation that
|
||||||
|
* is actually fast-forwardable (`tracking_canonical` or
|
||||||
|
* `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
|
||||||
|
* alone since there's no safe automatic move.
|
||||||
|
*
|
||||||
|
* @param {string} [gitRoot]
|
||||||
|
* @returns {Promise<object>} the refreshed status, plus `applied` (and
|
||||||
|
* `reason` when `applied` is false).
|
||||||
|
*/
|
||||||
|
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||||
|
const status = await getUpdatesStatus(gitRoot);
|
||||||
|
if (!status.update_available) {
|
||||||
|
return { ...status, applied: false, reason: "up_to_date" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status.situation !== "tracking_canonical" &&
|
||||||
|
status.situation !== "fork_or_diverged_tracking"
|
||||||
|
) {
|
||||||
|
return { ...status, applied: false, reason: "not_fast_forwardable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(gitRoot);
|
||||||
|
if (status.situation === "tracking_canonical") {
|
||||||
|
await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
|
||||||
|
} else {
|
||||||
|
await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
|
||||||
|
await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await execNpm(root, ["run", "setup"]);
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
await execNpm(root, ["run", "build"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await getUpdatesStatus(root, { skipFetch: true });
|
||||||
|
return { ...refreshed, applied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };
|
||||||
|
|||||||
@@ -2440,6 +2440,48 @@ function createOpenApiSpec() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/updates/apply": {
|
||||||
|
post: {
|
||||||
|
tags: ["Updates"],
|
||||||
|
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
|
||||||
|
operationId: "applyUpdate",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
description: "Same shape as GET /api/updates/status, plus applied: true.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description:
|
||||||
|
"Declined - nothing to apply, or the checkout isn't safely fast-forwardable",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Update apply failed",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/alerts": {
|
"/api/alerts": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Alerts"],
|
tags: ["Alerts"],
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file HTTP routes for dashboard upstream-update detection. The dashboard never
|
* @file HTTP routes for dashboard upstream-update detection, plus the one
|
||||||
* restarts itself — users copy the printed command and run it in their terminal.
|
* self-applying path: POST /apply fast-forwards, rebuilds, and restarts the
|
||||||
|
* process on the user's explicit click (see `server/lib/update-check.js`'s
|
||||||
|
* `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the
|
||||||
|
* dashboard only ever prints a command for the user to run themselves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const { getUpdatesStatus } = require("../lib/update-check");
|
const { getUpdatesStatus, applyUpdate } = require("../lib/update-check");
|
||||||
|
const { scheduleRestart } = require("../lib/self-restart");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -37,4 +41,36 @@ router.post("/check", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/apply", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await applyUpdate();
|
||||||
|
if (!result.applied) {
|
||||||
|
res.status(409).json({
|
||||||
|
...result,
|
||||||
|
error: {
|
||||||
|
code: "UPDATE_NOT_APPLICABLE",
|
||||||
|
message:
|
||||||
|
result.reason === "up_to_date"
|
||||||
|
? "Already up to date."
|
||||||
|
: "Current branch can't be fast-forwarded automatically.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
try {
|
||||||
|
const { broadcast } = require("../websocket");
|
||||||
|
broadcast("update_status", { ...result, update_available: false });
|
||||||
|
} catch {
|
||||||
|
// WS not initialized (e.g. in isolated tests) — safe to ignore.
|
||||||
|
}
|
||||||
|
// After the response above, restart onto the freshly-built code.
|
||||||
|
setImmediate(() => scheduleRestart());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: { code: "UPDATE_APPLY_FAILED", message: err.message || String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user