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...@@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin
82 AuthOrchestrationOperateScope,82 AuthOrchestrationOperateScope,
83 );83 );
84});84});
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...@@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
184184
185/** Retrying can install or restart tools even though ordinary listing is readable. */185/** Retrying can install or restart tools even though ordinary listing is readable. */
186export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>186export 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";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
2import {2import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 DeviceId,4 DeviceId,
5 DeviceOperationError,
5 LOCAL_DEVICE_HOST_ID,6 LOCAL_DEVICE_HOST_ID,
6 ThreadId,7 ThreadId,
7 type DeviceServiceState,8 type DeviceServiceState,
...@@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -68,6 +69,7 @@ const fixture = Effect.fn("fixture")(function* (
68 failListAfterShutdown = false,69 failListAfterShutdown = false,
69 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,70 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
70 inspectError = false,71 inspectError = false,
72 installTool?: Parameters<typeof makeWithHosts>[3],
71) {73) {
72 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);74 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
73 const starts: string[] = [];75 const starts: string[] = [];
...@@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* (...@@ -129,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* (
129 starts.push("stop");131 starts.push("stop");
130 }),132 }),
131 };133 };
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(
133 Effect.provideService(DeviceHost.DeviceHost, host),140 Effect.provideService(DeviceHost.DeviceHost, host),
134 Effect.provideService(141 Effect.provideService(
135 ServerSettingsService,142 ServerSettingsService,
...@@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i...@@ -660,3 +667,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i
660 expect(starts).toEqual([]);667 expect(starts).toEqual([]);
661 }).pipe(Effect.scoped),668 }).pipe(Effect.scoped),
662);669);
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 {...@@ -38,7 +38,7 @@ import {
38import * as FileSystem from "effect/FileSystem";38import * as FileSystem from "effect/FileSystem";
39import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";39import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
40import * as Path from "effect/Path";40import * as Path from "effect/Path";
41import { ensureAgentDevice } from "./DeviceToolchain.ts";41import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts";
42import * as ServerConfig from "../config.ts";42import * as ServerConfig from "../config.ts";
43import {43import {
44 agentDeviceConfigPath,44 agentDeviceConfigPath,
...@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<...@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<
126 ) => Effect.Effect<DeviceServiceState, DeviceError>;126 ) => Effect.Effect<DeviceServiceState, DeviceError>;
127 /** Refreshes devices only after device support has been enabled. */127 /** Refreshes devices only after device support has been enabled. */
128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly updateTool: (tool: "hub" | "agent") => Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly inspect: Effect.Effect<DeviceServiceState>;130 readonly inspect: Effect.Effect<DeviceServiceState>;
130 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;131 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
131 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;132 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
...@@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -182,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
182 reason: "Agent configuration is unavailable in this device service.",183 reason: "Agent configuration is unavailable in this device service.",
183 }),184 }),
184 ),185 ),
186 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,
185) {187) {
186 const settings = yield* ServerSettings.ServerSettingsService;188 const settings = yield* ServerSettings.ServerSettingsService;
187 const lifecycleLock = yield* Semaphore.make(1);189 const lifecycleLock = yield* Semaphore.make(1);
...@@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -205,6 +207,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
205 const stateRef = yield* SynchronizedRef.make<ServiceState>({207 const stateRef = yield* SynchronizedRef.make<ServiceState>({
206 state: {208 state: {
207 supportsHostRetry: true,209 supportsHostRetry: true,
210 supportsToolUpdate: installTool !== undefined,
208 supportsToolInspection: true,211 supportsToolInspection: true,
209 hosts: initialHosts,212 hosts: initialHosts,
210 hostStatus: initialSettings.enabled ? "idle" : "disabled",213 hostStatus: initialSettings.enabled ? "idle" : "disabled",
...@@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -899,6 +902,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
899 return {902 return {
900 ...DeviceService.of({903 ...DeviceService.of({
901 testHost,904 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 ),
902 retryHost,920 retryHost,
903 inspect,921 inspect,
904 agentCli: Effect.fail(922 agentCli: Effect.fail(
...@@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () {...@@ -1021,6 +1039,20 @@ export const make = Effect.gen(function* () {
1021 ),1039 ),
1022 ),1040 ),
1023 configureAgent,1041 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 ),
1024 );1056 );
1025 const hostContext =1057 const hostContext =
1026 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();1058 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
apps/server/src/ws.ts+6-4
...@@ -3456,13 +3456,15 @@ const makeWsRpcLayer = (...@@ -3456,13 +3456,15 @@ const makeWsRpcLayer = (
3456 [WS_METHODS.deviceList]: (input) =>3456 [WS_METHODS.deviceList]: (input) =>
3457 observeRpcEffect(3457 observeRpcEffect(
3458 WS_METHODS.deviceList,3458 WS_METHODS.deviceList,
3459 input.inspectOnly3459 input.inspectOnly && !input.updateTool
3460 ? deviceService.inspect3460 ? deviceService.inspect
3461 : authorizeEffect(3461 : authorizeEffect(
3462 requiredScopeForDeviceList(input),3462 requiredScopeForDeviceList(input),
3463 input.retryHostId3463 input.updateTool
3464 ? deviceService.retryHost(input.retryHostId)3464 ? deviceService.updateTool(input.updateTool)
3465 : deviceService.list,3465 : input.retryHostId
3466 ? deviceService.retryHost(input.retryHostId)
3467 : deviceService.list,
3466 ),3468 ),
3467 {3469 {
3468 "rpc.aggregate": "device",3470 "rpc.aggregate": "device",
apps/web/src/components/device/DeviceToolVersions.tsx+1-1
...@@ -57,7 +57,7 @@ export function DeviceToolVersions({...@@ -57,7 +57,7 @@ export function DeviceToolVersions({
57 .filter(([name]) => !kind || name === label)57 .filter(([name]) => !kind || name === label)
58 .map(([name, tool]) => (58 .map(([name, tool]) => (
59 <div key={name} className="space-y-2 py-3 first:pt-0 last:pb-0">59 <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}
61 <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs">61 <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs">
62 <dt className="text-muted-foreground">Running</dt>62 <dt className="text-muted-foreground">Running</dt>
63 <dd className="text-right font-mono">{tool.runningVersion ?? "Not running"}</dd>63 <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({...@@ -642,7 +642,9 @@ function DeviceIntegrationControls({
642 );642 );
643 const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false });643 const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false });
644 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });644 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);
646 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";648 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
647 const [platformsRevealed, setPlatformsRevealed] = useState(false);649 const [platformsRevealed, setPlatformsRevealed] = useState(false);
648 // Keep diagnostics visible through subsequent agent setup and refresh phases.650 // Keep diagnostics visible through subsequent agent setup and refresh phases.
...@@ -685,20 +687,64 @@ function DeviceIntegrationControls({...@@ -685,20 +687,64 @@ function DeviceIntegrationControls({
685 }687 }
686 };688 };
687689
688 const checkVersions = state.supportsToolInspection ? (690 const [updateError, setUpdateError] = useState<{ tool: "hub" | "agent"; message: string } | null>(
689 <Button691 null,
690 size="sm"692 );
691 variant="outline"693 const localTools = state.hosts.find((host) => host.kind === "local")?.tools;
692 disabled={!environmentId || pending !== null || busy}694 const versionActions = (tool: "hub" | "agent") => {
693 onClick={() => {695 const version = localTools?.[tool];
694 if (!environmentId) return;696 const needsUpdate = version && !version.installedVersions.includes(version.requiredVersion);
695 setPending("check");697 return (
696 void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null));698 <div className="space-y-2">
697 }}699 <div className="flex flex-wrap gap-2">
698 >700 {state.supportsToolUpdate && needsUpdate ? (
699 {pending === "check" ? "Checking…" : "Check versions"}701 <Button
700 </Button>702 size="sm"
701 ) : null;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
703 return (749 return (
704 <>750 <>
...@@ -710,7 +756,7 @@ function DeviceIntegrationControls({...@@ -710,7 +756,7 @@ function DeviceIntegrationControls({
710 control={756 control={
711 <>757 <>
712 <DeviceToolVersions758 <DeviceToolVersions
713 action={checkVersions}759 action={versionActions("hub")}
714 kind="hub"760 kind="hub"
715 tools={state.hosts.find((host) => host.kind === "local")?.tools}761 tools={state.hosts.find((host) => host.kind === "local")?.tools}
716 />762 />
...@@ -774,7 +820,7 @@ function DeviceIntegrationControls({...@@ -774,7 +820,7 @@ function DeviceIntegrationControls({
774 control={820 control={
775 <>821 <>
776 <DeviceToolVersions822 <DeviceToolVersions
777 action={checkVersions}823 action={versionActions("agent")}
778 kind="agent"824 kind="agent"
779 tools={state.hosts.find((host) => host.kind === "local")?.tools}825 tools={state.hosts.find((host) => host.kind === "local")?.tools}
780 />826 />
packages/contracts/src/device.ts+3
...@@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;...@@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
129129
130export const DeviceServiceState = Schema.Struct({130export const DeviceServiceState = Schema.Struct({
131 supportsHostRetry: Schema.optional(Schema.Boolean),131 supportsHostRetry: Schema.optional(Schema.Boolean),
132 supportsToolUpdate: Schema.optional(Schema.Boolean),
132 supportsToolInspection: Schema.optional(Schema.Boolean),133 supportsToolInspection: Schema.optional(Schema.Boolean),
133 hosts: Schema.Array(DeviceHostSummary),134 hosts: Schema.Array(DeviceHostSummary),
134 hostStatus: DeviceHostStatus,135 hostStatus: DeviceHostStatus,
...@@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({...@@ -154,6 +155,8 @@ export const DeviceServiceState = Schema.Struct({
154export type DeviceServiceState = typeof DeviceServiceState.Type;155export type DeviceServiceState = typeof DeviceServiceState.Type;
155156
156export const DeviceListInput = Schema.Struct({157export 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"])),
157 /** Read inventory without installing tools or starting helpers. */160 /** Read inventory without installing tools or starting helpers. */
158 inspectOnly: Schema.optional(Schema.Boolean),161 inspectOnly: Schema.optional(Schema.Boolean),
159 /** Retry this host only, including agent tools if access was already granted. */162 /** Retry this host only, including agent tools if access was already granted. */