authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-21 00:14:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 00:14:19-07:00
log1de563c1491c7d82563e4553bf5bf689ce6adbb9
treea35bfb9eee5bce24114c392f1c6c08f18c42003a
parentb379b5b1407b4c718095cd13395173b6cc005218
signature Signed by PGP key B5690EEEBB952194

feat(devices): offer manual updates in tool version details (#12877)


8 files changed, 188 insertions(+), 25 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+7
......@@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin
8282 AuthOrchestrationOperateScope,
8383 );
8484});
85
86it("requires operate permission for tool updates even alongside a read-only check", () => {
87 expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe(
88 AuthOrchestrationOperateScope,
89 );
90 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
91});
apps/server/src/auth/RpcAuthorization.ts+3-1
......@@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
184184
185185/** Retrying can install or restart tools even though ordinary listing is readable. */
186186export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
187 input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope;
187 input.retryHostId || input.updateTool
188 ? AuthOrchestrationOperateScope
189 : AuthOrchestrationReadScope;
apps/server/src/device/DeviceService.test.ts+72-1
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22import {
33 DEFAULT_SERVER_SETTINGS,
44 DeviceId,
5 DeviceOperationError,
56 LOCAL_DEVICE_HOST_ID,
67 ThreadId,
78 type DeviceServiceState,
......@@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* (
6869 failListAfterShutdown = false,
6970 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
7071 inspectError = false,
72 installTool?: Parameters<typeof makeWithHosts>[3],
7173) {
7274 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
7375 const starts: string[] = [];
......@@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* (
129131 starts.push("stop");
130132 }),
131133 };
132 const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe(
134 const service = yield* makeWithHosts(
135 new Map([[host.id, host]]),
136 undefined,
137 undefined,
138 installTool,
139 ).pipe(
133140 Effect.provideService(DeviceHost.DeviceHost, host),
134141 Effect.provideService(
135142 ServerSettingsService,
......@@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i
660667 expect(starts).toEqual([]);
661668 }).pipe(Effect.scoped),
662669);
670
671it.effect(
672 "manual updates install only the selected tool without enabling access or starting helpers",
673 () =>
674 Effect.gen(function* () {
675 const installed: string[] = [];
676 const { service, starts, agentStarts, requests } = yield* fixture(
677 Effect.void,
678 undefined,
679 false,
680 undefined,
681 false,
682 (tool) =>
683 Effect.sync(() => {
684 installed.push(tool);
685 }),
686 );
687 const before = yield* service.state;
688 const state = yield* service.updateTool("agent");
689 expect(installed).toEqual(["agent"]);
690 expect(state.supportsToolUpdate).toBe(true);
691 expect(state.hostStatus).toBe(before.hostStatus);
692 expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled);
693 expect(state.revision).toBeGreaterThan(before.revision);
694 expect(starts).toEqual([]);
695 expect(agentStarts).toEqual([]);
696 expect(requests).toEqual([]);
697 yield* service.updateTool("hub");
698 expect(installed).toEqual(["agent", "hub"]);
699 }).pipe(Effect.scoped),
700);
701
702it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () =>
703 Effect.gen(function* () {
704 let attempts = 0;
705 const { service, starts, agentStarts } = yield* fixture(
706 Effect.void,
707 undefined,
708 false,
709 undefined,
710 false,
711 () =>
712 Effect.suspend(() =>
713 ++attempts === 1
714 ? Effect.fail(
715 new DeviceOperationError({
716 operation: "update device tool",
717 reason: "command_failed",
718 cause: new Error("offline"),
719 }),
720 )
721 : Effect.void,
722 ),
723 );
724 const before = yield* service.state;
725 const result = yield* service.updateTool("agent").pipe(Effect.result);
726 expect(result._tag).toBe("Failure");
727 expect(yield* service.state).toEqual(before);
728 yield* service.updateTool("agent");
729 expect(attempts).toBe(2);
730 expect(starts).toEqual([]);
731 expect(agentStarts).toEqual([]);
732 }).pipe(Effect.scoped),
733);
apps/server/src/device/DeviceService.ts+33-1
......@@ -38,7 +38,7 @@ import {
3838import * as FileSystem from "effect/FileSystem";
3939import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
4040import * as Path from "effect/Path";
41import { ensureAgentDevice } from "./DeviceToolchain.ts";
41import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts";
4242import * as ServerConfig from "../config.ts";
4343import {
4444 agentDeviceConfigPath,
......@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<
126126 ) => Effect.Effect<DeviceServiceState, DeviceError>;
127127 /** Refreshes devices only after device support has been enabled. */
128128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly updateTool: (tool: "hub" | "agent") => Effect.Effect<DeviceServiceState, DeviceError>;
129130 readonly inspect: Effect.Effect<DeviceServiceState>;
130131 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
131132 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
......@@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
182183 reason: "Agent configuration is unavailable in this device service.",
183184 }),
184185 ),
186 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,
185187) {
186188 const settings = yield* ServerSettings.ServerSettingsService;
187189 const lifecycleLock = yield* Semaphore.make(1);
......@@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
205207 const stateRef = yield* SynchronizedRef.make<ServiceState>({
206208 state: {
207209 supportsHostRetry: true,
210 supportsToolUpdate: installTool !== undefined,
208211 supportsToolInspection: true,
209212 hosts: initialHosts,
210213 hostStatus: initialSettings.enabled ? "idle" : "disabled",
......@@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
899902 return {
900903 ...DeviceService.of({
901904 testHost,
905 updateTool: (tool) =>
906 lifecycleLock.withPermit(
907 Effect.gen(function* () {
908 if (!installTool)
909 return yield* Effect.fail(
910 new DeviceOperationError({
911 operation: "update device tool",
912 reason: "request_failed",
913 cause: new Error("Tool installation is unavailable in this device service."),
914 }),
915 );
916 yield* installTool(tool);
917 return yield* inspect;
918 }),
919 ),
902920 retryHost,
903921 inspect,
904922 agentCli: Effect.fail(
......@@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () {
10211039 ),
10221040 ),
10231041 configureAgent,
1042 (tool) =>
1043 (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe(
1044 Effect.provideService(FileSystem.FileSystem, fs),
1045 Effect.provideService(Path.Path, path),
1046 Effect.provideService(ProcessRunner.ProcessRunner, runner),
1047 Effect.mapError(
1048 (cause) =>
1049 new DeviceOperationError({
1050 operation: "update device tool",
1051 reason: "command_failed",
1052 cause,
1053 }),
1054 ),
1055 ),
10241056 );
10251057 const hostContext =
10261058 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
apps/server/src/ws.ts+6-4
......@@ -3456,13 +3456,15 @@ const makeWsRpcLayer = (
34563456 [WS_METHODS.deviceList]: (input) =>
34573457 observeRpcEffect(
34583458 WS_METHODS.deviceList,
3459 input.inspectOnly
3459 input.inspectOnly && !input.updateTool
34603460 ? deviceService.inspect
34613461 : authorizeEffect(
34623462 requiredScopeForDeviceList(input),
3463 input.retryHostId
3464 ? deviceService.retryHost(input.retryHostId)
3465 : deviceService.list,
3463 input.updateTool
3464 ? deviceService.updateTool(input.updateTool)
3465 : input.retryHostId
3466 ? deviceService.retryHost(input.retryHostId)
3467 : deviceService.list,
34663468 ),
34673469 {
34683470 "rpc.aggregate": "device",
apps/web/src/components/device/DeviceToolVersions.tsx+1-1
......@@ -57,7 +57,7 @@ export function DeviceToolVersions({
5757 .filter(([name]) => !kind || name === label)
5858 .map(([name, tool]) => (
5959 <div key={name} className="space-y-2 py-3 first:pt-0 last:pb-0">
60 <p className="text-xs font-medium">{name}</p>
60 {!kind ? <p className="text-xs font-medium">{name}</p> : null}
6161 <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs">
6262 <dt className="text-muted-foreground">Running</dt>
6363 <dd className="text-right font-mono">{tool.runningVersion ?? "Not running"}</dd>
apps/web/src/components/settings/IntegrationsSettings.tsx+63-17
......@@ -642,7 +642,9 @@ function DeviceIntegrationControls({
642642 );
643643 const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false });
644644 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
645 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
645 const [pending, setPending] = useState<
646 "hub" | "check" | "agent" | "update-hub" | "update-agent" | null
647 >(null);
646648 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
647649 const [platformsRevealed, setPlatformsRevealed] = useState(false);
648650 // Keep diagnostics visible through subsequent agent setup and refresh phases.
......@@ -685,20 +687,64 @@ function DeviceIntegrationControls({
685687 }
686688 };
687689
688 const checkVersions = state.supportsToolInspection ? (
689 <Button
690 size="sm"
691 variant="outline"
692 disabled={!environmentId || pending !== null || busy}
693 onClick={() => {
694 if (!environmentId) return;
695 setPending("check");
696 void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null));
697 }}
698 >
699 {pending === "check" ? "Checking…" : "Check versions"}
700 </Button>
701 ) : null;
690 const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>(
691 null,
692 );
693 const localTools = state.hosts.find((host) => host.kind === "local")?.tools;
694 const versionActions = (tool: "hub" | "agent") => {
695 const version = localTools?.[tool];
696 const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion);
697 return (
698 <div className="space-y-2">
699 <div className="flex flex-wrap gap-2">
700 {state.supportsToolUpdate && needsUpdate ? (
701 <Button
702 size="sm"
703 disabled={!environmentId || pending !== null || busy}
704 onClick={() => {
705 if (!environmentId) return;
706 setUpdateError(null);
707 setPending(`update-${tool}`);
708 void list({ environmentId, input: { updateTool: tool } })
709 .then((result) => {
710 if (result._tag === "Failure")
711 setUpdateError({
712 tool,
713 message:
714 "Update failed. Check this host's network connection and try again.",
715 });
716 })
717 .finally(() => setPending(null));
718 }}
719 >
720 {pending === `update-${tool}` ? "Updating…" : `Update to v${version.requiredVersion}`}
721 </Button>
722 ) : null}
723 {state.supportsToolInspection ? (
724 <Button
725 size="sm"
726 variant="outline"
727 disabled={!environmentId || pending !== null || busy}
728 onClick={() => {
729 if (!environmentId) return;
730 setPending("check");
731 void list({ environmentId, input: { inspectOnly: true } }).finally(() =>
732 setPending(null),
733 );
734 }}
735 >
736 {pending === "check" ? "Checking…" : "Check versions"}
737 </Button>
738 ) : null}
739 </div>
740 {updateError?.tool === tool ? (
741 <p role="alert" className="text-xs text-destructive">
742 {updateError.message}
743 </p>
744 ) : null}
745 </div>
746 );
747 };
702748
703749 return (
704750 <>
......@@ -710,7 +756,7 @@ function DeviceIntegrationControls({
710756 control={
711757 <>
712758 <DeviceToolVersions
713 action={checkVersions}
759 action={versionActions("hub")}
714760 kind="hub"
715761 tools={state.hosts.find((host) => host.kind === "local")?.tools}
716762 />
......@@ -774,7 +820,7 @@ function DeviceIntegrationControls({
774820 control={
775821 <>
776822 <DeviceToolVersions
777 action={checkVersions}
823 action={versionActions("agent")}
778824 kind="agent"
779825 tools={state.hosts.find((host) => host.kind === "local")?.tools}
780826 />
packages/contracts/src/device.ts+3
......@@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
129129
130130export const DeviceServiceState = Schema.Struct({
131131 supportsHostRetry: Schema.optional(Schema.Boolean),
132 supportsToolUpdate: Schema.optional(Schema.Boolean),
132133 supportsToolInspection: Schema.optional(Schema.Boolean),
133134 hosts: Schema.Array(DeviceHostSummary),
134135 hostStatus: DeviceHostStatus,
......@@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({
154155export type DeviceServiceState = typeof DeviceServiceState.Type;
155156
156157export const DeviceListInput = Schema.Struct({
158 /** Install this server's pinned tool without enabling access or starting helpers. */
159 updateTool: Schema.optional(Schema.Literals(["hub", "agent"])),
157160 /** Read inventory without installing tools or starting helpers. */
158161 inspectOnly: Schema.optional(Schema.Boolean),
159162 /** Retry this host only, including agent tools if access was already granted. */