Skip to content

Commit 1dc2272

Browse files
committed
feat(skiller): support external web service
1 parent b4426a3 commit 1dc2272

7 files changed

Lines changed: 256 additions & 13 deletions

File tree

packages/api/src/http.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,10 @@ import {
163163
openSkillerForTerminalSession,
164164
parseSkillerRoute,
165165
proxySkillerTrpc,
166+
readSkillerProjectContext,
166167
serveSkillerApp
167168
} from "./services/skiller.js"
169+
import { resolveDockerGitSkillerBackendUrl } from "./services/skiller-core.js"
168170
import {
169171
commitStateFromRequest,
170172
initStateFromRequest,
@@ -596,6 +598,9 @@ const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): str
596598
return `${proto}://${host}`
597599
}
598600

601+
const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
602+
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))
603+
599604
const resolveFederationContext = (
600605
request: HttpServerRequest.HttpServerRequest,
601606
requestedDomain?: string | undefined
@@ -812,24 +817,43 @@ export const makeRouter = () => {
812817
),
813818
HttpRouter.post(
814819
"/skiller/open",
815-
openSkiller().pipe(
816-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
817-
Effect.catchAll(errorResponse)
818-
)
820+
Effect.gen(function*(_) {
821+
const request = yield* _(HttpServerRequest.HttpServerRequest)
822+
const launch = yield* _(openSkiller(undefined, undefined, resolveSkillerBackendUrl(request)))
823+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
824+
}).pipe(Effect.catchAll(errorResponse))
819825
),
820826
HttpRouter.post(
821827
"/projects/by-key/:projectKey/skiller/open",
828+
Effect.gen(function*(_) {
829+
const request = yield* _(HttpServerRequest.HttpServerRequest)
830+
const { projectKey } = yield* _(projectKeyParams)
831+
const launch = yield* _(openSkiller(projectKey, undefined, resolveSkillerBackendUrl(request)))
832+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
833+
}).pipe(Effect.catchAll(errorResponse))
834+
),
835+
HttpRouter.post(
836+
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open",
837+
Effect.gen(function*(_) {
838+
const request = yield* _(HttpServerRequest.HttpServerRequest)
839+
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
840+
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, resolveSkillerBackendUrl(request)))
841+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
842+
}).pipe(Effect.catchAll(errorResponse))
843+
),
844+
HttpRouter.get(
845+
"/projects/by-key/:projectKey/skiller/context",
822846
projectKeyParams.pipe(
823-
Effect.flatMap(({ projectKey }) => openSkiller(projectKey)),
824-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
847+
Effect.flatMap(({ projectKey }) => readSkillerProjectContext(projectKey, null)),
848+
Effect.flatMap((context) => jsonResponse({ ok: true, ...context }, 200)),
825849
Effect.catchAll(errorResponse)
826850
)
827851
),
828-
HttpRouter.post(
829-
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open",
852+
HttpRouter.get(
853+
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/context",
830854
terminalSessionByProjectKeyParams.pipe(
831-
Effect.flatMap(({ projectKey, sessionId }) => openSkillerForTerminalSession(projectKey, sessionId)),
832-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
855+
Effect.flatMap(({ projectKey, sessionId }) => readSkillerProjectContext(projectKey, sessionId)),
856+
Effect.flatMap((context) => jsonResponse({ ok: true, ...context }, 200)),
833857
Effect.catchAll(errorResponse)
834858
)
835859
),

packages/api/src/services/skiller-core.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,65 @@ export type SkillerBrowserScope = {
3535
readonly sessionId: string | null
3636
}
3737

38+
export type ConfiguredSkillerWebUrl =
39+
| { readonly _tag: "Disabled" }
40+
| { readonly _tag: "Enabled"; readonly baseUrl: string }
41+
| { readonly _tag: "Invalid"; readonly message: string }
42+
43+
export type ExternalSkillerLaunchUrlInput = {
44+
readonly backendUrl: string
45+
readonly projectKey: string | undefined
46+
readonly sessionId: string | undefined
47+
readonly skillerWebUrl: string
48+
}
49+
50+
const trimTrailingSlashes = (value: string): string =>
51+
value.replace(/\/+$/u, "")
52+
53+
export const resolveConfiguredSkillerWebUrl = (
54+
env: Record<string, string | undefined>
55+
): ConfiguredSkillerWebUrl => {
56+
const raw = env["DOCKER_GIT_SKILLER_WEB_URL"]?.trim()
57+
if (raw === undefined || raw.length === 0) {
58+
return { _tag: "Disabled" }
59+
}
60+
if (!URL.canParse(raw)) {
61+
return { _tag: "Invalid", message: `Invalid DOCKER_GIT_SKILLER_WEB_URL: ${raw}` }
62+
}
63+
const parsed = new URL(raw)
64+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
65+
return { _tag: "Invalid", message: "DOCKER_GIT_SKILLER_WEB_URL must use http or https." }
66+
}
67+
parsed.hash = ""
68+
parsed.search = ""
69+
return { _tag: "Enabled", baseUrl: trimTrailingSlashes(parsed.toString()) }
70+
}
71+
72+
export const resolveDockerGitSkillerBackendUrl = (
73+
env: Record<string, string | undefined>,
74+
requestOrigin: string
75+
): string => {
76+
const configured = [
77+
env["DOCKER_GIT_SKILLER_BACKEND_URL"],
78+
env["DOCKER_GIT_API_PUBLIC_URL"]
79+
]
80+
.map((value) => value?.trim())
81+
.find((value) => value !== undefined && value.length > 0)
82+
return configured ?? requestOrigin
83+
}
84+
85+
export const externalSkillerLaunchUrl = (input: ExternalSkillerLaunchUrlInput): string => {
86+
const url = new URL(`${trimTrailingSlashes(input.skillerWebUrl)}/launch`)
87+
url.searchParams.set("backendUrl", input.backendUrl)
88+
if (input.projectKey !== undefined) {
89+
url.searchParams.set("projectKey", input.projectKey)
90+
}
91+
if (input.sessionId !== undefined) {
92+
url.searchParams.set("sessionId", input.sessionId)
93+
}
94+
return url.toString()
95+
}
96+
3897
export const parseDockerMountLines = (output: string): ReadonlyArray<DockerContainerMount> =>
3998
output
4099
.split(/\r?\n/u)

packages/api/src/services/skiller.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import * as Stream from "effect/Stream"
1919
import { ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js"
2020
import {
2121
containerCodexSkillsPath,
22+
externalSkillerLaunchUrl,
2223
parseDockerMountLines,
2324
remapContainerPathToMountedHost,
25+
resolveConfiguredSkillerWebUrl,
2426
sameSkillerScope,
2527
skillerBrowserScopeForContainer,
28+
type SkillerBrowserScope,
2629
type SkillerContainerScope
2730
} from "./skiller-core.js"
2831
import { getProjectItemByKey } from "./projects.js"
@@ -31,14 +34,21 @@ import { getProjectTerminalSession } from "./terminal-sessions.js"
3134
export type SkillerLaunch = {
3235
readonly alreadyRunning: boolean
3336
readonly appPath: string
37+
readonly backendUrl: string | null
3438
readonly logPath: string
39+
readonly mode: "bundled" | "external"
3540
readonly pid: number | null
3641
readonly scope: SkillerContainerScope | null
3742
readonly startedAtIso: string
3843
readonly trpcBasePath: string
3944
readonly trpcPort: number
4045
}
4146

47+
export type SkillerProjectContext = {
48+
readonly browserScope: SkillerBrowserScope
49+
readonly scope: SkillerContainerScope
50+
}
51+
4252
type SkillerProcess = {
4353
readonly appPath: string
4454
readonly logPath: string
@@ -133,7 +143,9 @@ const toLaunch = (
133143
): SkillerLaunch => ({
134144
alreadyRunning,
135145
appPath: sessionId === undefined ? process.appPath : sessionSkillerAppPath(sessionId),
146+
backendUrl: null,
136147
logPath: process.logPath,
148+
mode: "bundled",
137149
pid: process.process.pid ?? null,
138150
scope: process.scope,
139151
startedAtIso: process.startedAtIso,
@@ -369,6 +381,27 @@ const resolveRequestedSkillerScope = (
369381
Effect.flatMap((project) => resolveSkillerScope(projectKey, project))
370382
)
371383

384+
export const readSkillerProjectContext = (
385+
projectKey: string,
386+
sessionId: string | null
387+
): Effect.Effect<
388+
SkillerProjectContext,
389+
ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError,
390+
ListProjectsContext
391+
> =>
392+
getProjectItemByKey(projectKey).pipe(
393+
Effect.flatMap((project) =>
394+
sessionId === null
395+
? Effect.succeed(project)
396+
: getProjectTerminalSession(project.projectDir, sessionId).pipe(Effect.as(project))
397+
),
398+
Effect.flatMap((project) => resolveSkillerScope(projectKey, project)),
399+
Effect.map((scope) => ({
400+
browserScope: skillerBrowserScopeForContainer(scope, sessionId),
401+
scope
402+
}))
403+
)
404+
372405
const waitForSkillerReady = (trpcPort: number): Effect.Effect<void, ApiInternalError> =>
373406
Effect.tryPromise({
374407
catch: (cause) => new ApiInternalError({
@@ -794,9 +827,43 @@ const touchSkillerActivity = (
794827
)
795828
)
796829

830+
const externalSkillerLaunch = (
831+
scope: SkillerContainerScope | null,
832+
projectKey: string | undefined,
833+
sessionId: string | undefined,
834+
backendUrl: string
835+
): Effect.Effect<SkillerLaunch | null, ApiInternalError> => {
836+
const config = resolveConfiguredSkillerWebUrl(process.env)
837+
if (config._tag === "Disabled") {
838+
return Effect.succeed(null)
839+
}
840+
if (config._tag === "Invalid") {
841+
return Effect.fail(new ApiInternalError({ message: config.message }))
842+
}
843+
const appPath = externalSkillerLaunchUrl({
844+
backendUrl,
845+
projectKey,
846+
sessionId,
847+
skillerWebUrl: config.baseUrl
848+
})
849+
return Effect.succeed({
850+
alreadyRunning: true,
851+
appPath,
852+
backendUrl,
853+
logPath: "",
854+
mode: "external",
855+
pid: null,
856+
scope,
857+
startedAtIso: new Date().toISOString(),
858+
trpcBasePath: `${config.baseUrl}/trpc`,
859+
trpcPort: 0
860+
})
861+
}
862+
797863
export const openSkiller = (
798864
projectKey?: string,
799-
sessionId?: string
865+
sessionId?: string,
866+
backendUrl = "http://localhost:3334"
800867
): Effect.Effect<
801868
SkillerLaunch,
802869
ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError,
@@ -806,6 +873,10 @@ export const openSkiller = (
806873
const scope = yield* _(resolveRequestedSkillerScope(projectKey))
807874
yield* _(touchSkillerActivity(scope))
808875
rememberSessionScope(sessionId, scope)
876+
const externalLaunch = yield* _(externalSkillerLaunch(scope, projectKey, sessionId, backendUrl))
877+
if (externalLaunch !== null) {
878+
return externalLaunch
879+
}
809880
if (currentProcess !== null && isRunning(currentProcess.process)) {
810881
if (sameSkillerScope(currentProcess.scope, scope)) {
811882
yield* _(Effect.try({
@@ -851,7 +922,8 @@ export const hasLiveProjectSkillerSession = (projectId: string): boolean =>
851922

852923
export const openSkillerForTerminalSession = (
853924
projectKey: string,
854-
sessionId: string
925+
sessionId: string,
926+
backendUrl = "http://localhost:3334"
855927
): Effect.Effect<
856928
SkillerLaunch,
857929
ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError,
@@ -863,7 +935,7 @@ export const openSkillerForTerminalSession = (
863935
Effect.as(projectKey)
864936
)
865937
),
866-
Effect.flatMap((resolvedProjectKey) => openSkiller(resolvedProjectKey, sessionId))
938+
Effect.flatMap((resolvedProjectKey) => openSkiller(resolvedProjectKey, sessionId, backendUrl))
867939
)
868940

869941
export const parseSkillerRoute = (pathname: string): SkillerRoute | null => {

packages/api/tests/skiller-core.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,58 @@ import { describe, expect, it } from "@effect/vitest"
22

33
import {
44
containerCodexSkillsPath,
5+
externalSkillerLaunchUrl,
56
parseDockerMountLines,
67
remapContainerPathToMountedHost,
78
remapSkillerBrowserContainerPath,
89
remapSkillerBrowserHostPath,
10+
resolveConfiguredSkillerWebUrl,
11+
resolveDockerGitSkillerBackendUrl,
912
sameSkillerScope,
1013
skillerBrowserScopeForContainer
1114
} from "../src/services/skiller-core.js"
1215

1316
describe("skiller container filesystem mapping", () => {
17+
it("resolves external Skiller web URLs from docker-git environment", () => {
18+
expect(resolveConfiguredSkillerWebUrl({})).toEqual({ _tag: "Disabled" })
19+
expect(resolveConfiguredSkillerWebUrl({ DOCKER_GIT_SKILLER_WEB_URL: " " })).toEqual({ _tag: "Disabled" })
20+
expect(resolveConfiguredSkillerWebUrl({
21+
DOCKER_GIT_SKILLER_WEB_URL: "https://skiller.example/app/?ignored=1#hash"
22+
})).toEqual({
23+
_tag: "Enabled",
24+
baseUrl: "https://skiller.example/app"
25+
})
26+
expect(resolveConfiguredSkillerWebUrl({ DOCKER_GIT_SKILLER_WEB_URL: "file:///tmp/skiller" })._tag).toBe("Invalid")
27+
})
28+
29+
it("builds external Skiller launch URLs with docker-git context parameters", () => {
30+
const launchUrl = new URL(externalSkillerLaunchUrl({
31+
backendUrl: "https://docker-git.example/api",
32+
projectKey: "project one",
33+
sessionId: "session/1",
34+
skillerWebUrl: "https://skiller.example/ui/"
35+
}))
36+
37+
expect(launchUrl.origin).toBe("https://skiller.example")
38+
expect(launchUrl.pathname).toBe("/ui/launch")
39+
expect(launchUrl.searchParams.get("backendUrl")).toBe("https://docker-git.example/api")
40+
expect(launchUrl.searchParams.get("projectKey")).toBe("project one")
41+
expect(launchUrl.searchParams.get("sessionId")).toBe("session/1")
42+
})
43+
44+
it("prefers explicit Skiller backend URLs before request origin", () => {
45+
expect(resolveDockerGitSkillerBackendUrl({
46+
DOCKER_GIT_API_PUBLIC_URL: "https://public-api.example",
47+
DOCKER_GIT_SKILLER_BACKEND_URL: "https://skiller-backend.example"
48+
}, "http://localhost:3334")).toBe("https://skiller-backend.example")
49+
50+
expect(resolveDockerGitSkillerBackendUrl({
51+
DOCKER_GIT_API_PUBLIC_URL: " https://public-api.example "
52+
}, "http://localhost:3334")).toBe("https://public-api.example")
53+
54+
expect(resolveDockerGitSkillerBackendUrl({}, "http://localhost:3334")).toBe("http://localhost:3334")
55+
})
56+
1457
it("maps a project container path through the most specific writable Docker mount", () => {
1558
const mounts = parseDockerMountLines([
1659
"/var/lib/docker/volumes/project-home/_data\t/home/dev\ttrue",

packages/api/tests/skiller-routes.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it } from "@effect/vitest"
2+
import { NodeContext } from "@effect/platform-node"
3+
import { Effect } from "effect"
24

35
import {
6+
openSkiller,
47
parseSkillerRoute,
58
resolveSkillerBrowserScopeSelection,
69
resolveSkillerRouteScopeSelection,
@@ -76,6 +79,33 @@ describe("skiller routes", () => {
7679
expect(launch.userName).toBe("dg-skiller-u2147483001")
7780
})
7881

82+
it("returns an external Skiller Web launch when DOCKER_GIT_SKILLER_WEB_URL is configured", async () => {
83+
const previous = process.env["DOCKER_GIT_SKILLER_WEB_URL"]
84+
process.env["DOCKER_GIT_SKILLER_WEB_URL"] = "https://skiller.example/ui"
85+
try {
86+
const launch = await Effect.runPromise(
87+
openSkiller(undefined, undefined, "https://docker-git.example").pipe(Effect.provide(NodeContext.layer))
88+
)
89+
const launchUrl = new URL(launch.appPath)
90+
91+
expect(launch.mode).toBe("external")
92+
expect(launch.alreadyRunning).toBe(true)
93+
expect(launch.backendUrl).toBe("https://docker-git.example")
94+
expect(launch.pid).toBeNull()
95+
expect(launch.trpcPort).toBe(0)
96+
expect(launchUrl.pathname).toBe("/ui/launch")
97+
expect(launchUrl.searchParams.get("backendUrl")).toBe("https://docker-git.example")
98+
expect(launchUrl.searchParams.has("projectKey")).toBe(false)
99+
expect(launchUrl.searchParams.has("sessionId")).toBe(false)
100+
} finally {
101+
if (previous === undefined) {
102+
delete process.env["DOCKER_GIT_SKILLER_WEB_URL"]
103+
} else {
104+
process.env["DOCKER_GIT_SKILLER_WEB_URL"] = previous
105+
}
106+
}
107+
})
108+
79109
it("fails stalled child processes with a distinct timeout error", () =>
80110
expect(runProcess(
81111
process.execPath,

0 commit comments

Comments
 (0)