Skip to content

Commit 1de563c

Browse files
feat(devices): offer manual updates in tool version details (#12877)
1 parent b379b5b commit 1de563c

8 files changed

Lines changed: 188 additions & 25 deletions

File tree

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,10 @@ it("requires operate permission for host retry while preserving read-only listin
8282
AuthOrchestrationOperateScope,
8383
);
8484
});
85+
86+
it("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‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
184184

185185
/** Retrying can install or restart tools even though ordinary listing is readable. */
186186
export 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‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22
import {
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+
671+
it.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+
702+
it.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‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
import * as FileSystem from "effect/FileSystem";
3939
import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
4040
import * as Path from "effect/Path";
41-
import { ensureAgentDevice } from "./DeviceToolchain.ts";
41+
import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts";
4242
import * as ServerConfig from "../config.ts";
4343
import {
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‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
129129

130130
export 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({
154155
export type DeviceServiceState = typeof DeviceServiceState.Type;
155156

156157
export 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. */

0 commit comments

Comments
 (0)