Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "devin") {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
fill={isDarkMode ? "#F5F5F5" : "#0F0F0F"}
d="M4 4h8c4.418 0 8 3.582 8 8s-3.582 8-8 8H4V4Zm2 2v12h6c3.314 0 6-2.686 6-6s-2.686-6-6-6H6Z"
/>
</Svg>
);
}

// codex (and unknown drivers)
return (
<Svg width={size} height={size} viewBox="0 0 256 260" fill="none">
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/provider/Drivers/DevinDriver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "@effect/vitest";
import * as Schema from "effect/Schema";
import { ProviderDriverKind } from "@t3tools/contracts";

import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts";
import { DevinDriver } from "./DevinDriver.ts";

const isDevinConfig = Schema.is(DevinDriver.configSchema);

describe("DevinDriver", () => {
it("is registered as a built-in driver", () => {
expect(BUILT_IN_DRIVERS.includes(DevinDriver)).toBe(true);
});

it("has the devin driver kind", () => {
expect(DevinDriver.driverKind).toBe(ProviderDriverKind.make("devin"));
});

it("exposes the DevinSettings schema and a valid default config", () => {
const defaults = DevinDriver.defaultConfig();
expect(isDevinConfig(defaults)).toBe(true);
expect(defaults.enabled).toBe(false);
expect(defaults.binaryPath).toBe("devin");
});

it("supports multiple instances", () => {
expect(DevinDriver.metadata.supportsMultipleInstances).toBe(true);
});
});
164 changes: 164 additions & 0 deletions apps/server/src/provider/Drivers/DevinDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { DevinSettings, ProviderDriverKind } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeDevinAdapter } from "../Layers/DevinAdapter.ts";
import {
buildInitialDevinProviderSnapshot,
checkDevinProviderStatus,
enrichDevinSnapshot,
} from "../Layers/DevinProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
type ProviderContinuationIdentity,
type ProviderDriver,
type ProviderInstance,
} from "../ProviderDriver.ts";
import { withInstanceIdentity } from "./instanceIdentity.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
makeManualOnlyProviderMaintenanceCapabilities,
makeStaticProviderMaintenanceResolver,
resolveProviderMaintenanceCapabilitiesEffect,
} from "../providerMaintenance.ts";
import {
haveProviderSnapshotSettingsChanged,
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { resolveDevinRuntimeProfile } from "./DevinProfile.ts";

const decodeDevinSettings = Schema.decodeSync(DevinSettings);

const DRIVER_KIND = ProviderDriverKind.make("devin");
const UPDATE = makeStaticProviderMaintenanceResolver(
makeManualOnlyProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: null,
}),
);

export type DevinDriverEnv =
| BackgroundPolicy.BackgroundPolicy
| ChildProcessSpawner.ChildProcessSpawner
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;

export const DevinDriver: ProviderDriver<DevinSettings, DevinDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Devin",
supportsMultipleInstances: true,
},
configSchema: DevinSettings,
defaultConfig: (): DevinSettings => decodeDevinSettings({}),
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const serverConfig = yield* ServerConfig;
const { cwd } = serverConfig;
const baseEnv = mergeProviderInstanceEnvironment(environment);
const resolvedProfile = yield* resolveDevinRuntimeProfile({
settings: config,
environment: baseEnv,
});
const continuationIdentity: ProviderContinuationIdentity = {
driverKind: DRIVER_KIND,
continuationKey: resolvedProfile.identity,
};
const stampIdentity = withInstanceIdentity({
instanceId,
driverKind: DRIVER_KIND,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies DevinSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: resolvedProfile.environment,
});

const adapter = yield* makeDevinAdapter(effectiveConfig, {
environment: resolvedProfile.environment,
instanceId,
attachmentsDir: serverConfig.attachmentsDir,
});
const textGeneration = yield* makeDevinTextGeneration(
effectiveConfig,
resolvedProfile.environment,
);

const checkProvider = checkDevinProviderStatus(
effectiveConfig,
resolvedProfile.environment,
cwd,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(Path.Path, path),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<DevinSettings>>({
maintenanceCapabilities,
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
enrichDevinSnapshot({
snapshot: currentSnapshot,
maintenanceCapabilities,
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
publishSnapshot,
httpClient,
}),
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to build Devin snapshot: ${cause.message ?? String(cause)}`,
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
90 changes: 90 additions & 0 deletions apps/server/src/provider/Drivers/DevinProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as NodeOS from "node:os";

import type { DevinSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

import { expandHomePath } from "../../pathExpansion.ts";

export interface ResolvedDevinRuntimeProfile {
/** Environment passed to every Devin process for this instance. */
readonly environment: NodeJS.ProcessEnv;
/** Absolute path to the Devin config file, if configured. */
readonly configPath?: string;
/** Stable continuation identity derived from the resolved profile. */
readonly identity: string;
}

const DEVIN_HOME_ENV = "DEVIN_HOME";
const DEVIN_CONFIG_ENV = "DEVIN_CONFIG";

function resolveHomePath(path: Path.Path, value: string): string {
const expanded = value.trim() ? expandHomePath(value.trim()) : NodeOS.homedir();
return path.resolve(expanded);
}

function resolveConfigPath(path: Path.Path, value: string): string {
return path.resolve(expandHomePath(value.trim()));
}

function buildProfileIdentity(input: {
readonly settings: DevinSettings;
readonly resolvedHomePath: string | undefined;
readonly resolvedConfigPath: string | undefined;
readonly environmentNames: ReadonlyArray<string>;
}): string {
const parts = [
`devin`,
`binary=${input.settings.binaryPath.trim()}`,
`home=${input.resolvedHomePath || "default"}`,
`config=${input.resolvedConfigPath || ""}`,
`agent=${input.settings.agentType.trim() || "default"}`,
`sandbox=${input.settings.sandbox}`,
`trust=${input.settings.respectWorkspaceTrust}`,
...input.environmentNames.map((name) => `env:${name}`),
];
return parts.join("\0");
}

export const resolveDevinRuntimeProfile = Effect.fn("resolveDevinRuntimeProfile")(
function* (input: {
readonly settings: DevinSettings;
readonly environment?: NodeJS.ProcessEnv;
}): Effect.fn.Return<ResolvedDevinRuntimeProfile, never, Path.Path> {
const path = yield* Path.Path;
const settings = input.settings;
const baseEnv = input.environment ?? process.env;

const resolvedHomePath = settings.homePath.trim()
? resolveHomePath(path, settings.homePath)
: undefined;

const resolvedConfigPath = settings.configPath.trim()
? resolveConfigPath(path, settings.configPath)
: undefined;

const next: NodeJS.ProcessEnv = { ...baseEnv };
if (resolvedHomePath) {
next[DEVIN_HOME_ENV] = resolvedHomePath;
}
if (resolvedConfigPath) {
next[DEVIN_CONFIG_ENV] = resolvedConfigPath;
}

const environmentNames = Object.entries(input.environment ?? {})
.filter(([name]) => name.startsWith("DEVIN_") || name.startsWith("XDG_"))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, value]) => `${name}=${value ?? ""}`);

return {
environment: next,
...(resolvedConfigPath ? { configPath: resolvedConfigPath } : {}),
identity: buildProfileIdentity({
settings,
resolvedHomePath,
resolvedConfigPath,
environmentNames,
}),
};
},
);
25 changes: 25 additions & 0 deletions apps/server/src/provider/Layers/DevinAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { describe, expect } from "vite-plus/test";
import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";

import { makeDevinAdapter } from "../Layers/DevinAdapter.ts";
import { DevinDriver } from "../Drivers/DevinDriver.ts";

describe("DevinAdapter", () => {
it.effect("can be constructed and reports no sessions initially", () =>
Effect.gen(function* () {
const adapter = yield* makeDevinAdapter(DevinDriver.defaultConfig(), {
environment: process.env,
instanceId: ProviderInstanceId.make("devin-adapter-test"),
});

const has = yield* adapter.hasSession(ThreadId.make("unknown-thread"));
expect(has).toBe(false);

const sessions = yield* adapter.listSessions();
expect(sessions).toEqual([]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});
Loading
Loading