feat: add remote daemon self-update from client (#1513)
* feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.
New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
* fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
* fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
variable so concurrent clients share the same guard
* feat(daemon): harden remote self-update
Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.
* test(daemon): make self-update paths platform-stable
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
@@ -402,3 +402,23 @@ describe("paseo worktree archive request compatibility", () => {
|
||||
expect(parsed.scope).toBe("workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon update messages", () => {
|
||||
test("daemon update progress is a scoped outbound message", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "installing",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "daemon.update.progress",
|
||||
payload: {
|
||||
requestId: "update-1",
|
||||
phase: "installing",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1252,6 +1252,11 @@ export const ShutdownServerRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DaemonUpdateRequestMessageSchema = z.object({
|
||||
type: z.literal("daemon.update.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentTimelineCursorSchema = z.object({
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
@@ -2053,6 +2058,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CancelAgentRequestMessageSchema,
|
||||
ShutdownServerRequestMessageSchema,
|
||||
RestartServerRequestMessageSchema,
|
||||
DaemonUpdateRequestMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
SetAgentModeRequestMessageSchema,
|
||||
SetAgentModelRequestMessageSchema,
|
||||
@@ -2327,6 +2333,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
agentDetach: z.boolean().optional(),
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -4093,6 +4101,29 @@ export const TerminalAttentionRequiredSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const DaemonUpdateResponseSchema = z.object({
|
||||
type: z.literal("daemon.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
previousVersion: z.string().nullable(),
|
||||
newVersion: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
|
||||
|
||||
export const DaemonUpdateProgressMessageSchema = z.object({
|
||||
type: z.literal("daemon.update.progress"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
phase: z.enum(["starting", "downloading", "installing", "complete"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -4228,6 +4259,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
LoopInspectResponseSchema,
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
DaemonUpdateProgressMessageSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<typeof SessionOutboundMessageSchema>;
|
||||
|
||||
Reference in New Issue
Block a user