Add link session preparation flow

- wire front link resolve/prepare routes
- add orchestrator session command handling
- update admin dashboards and device/link logic
This commit is contained in:
user
2026-03-28 17:47:03 +02:00
parent 5da61ed853
commit 0a11be5006
20 changed files with 1099 additions and 175 deletions

View File

@@ -35,8 +35,8 @@ Currently in alpha. Greenfield. Subject to change.
### Device Management (Orchestrator + Admin) ### Device Management (Orchestrator + Admin)
- [ ] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.) - [x] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.)
- [ ] Device domain in `@pkg/logic` — controller + repository + errors - [x] Device domain in `@pkg/logic` — controller + repository + errors
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls: - [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
- [ ] `POST /devices/:id/start` — start a Docker-Android container - [ ] `POST /devices/:id/start` — start a Docker-Android container
- [ ] `POST /devices/:id/stop` — stop a container - [ ] `POST /devices/:id/stop` — stop a container
@@ -45,14 +45,14 @@ Currently in alpha. Greenfield. Subject to change.
- [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy) - [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy)
- [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session - [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session
- [ ] Device release logic — clear `inUse` when a session ends or fails during setup - [ ] Device release logic — clear `inUse` when a session ends or fails during setup
- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart - [x] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator` - [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
### Link Management (Admin + Front App) ### Link Management (Admin + Front App)
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity) - [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
- [ ] Link domain in `@pkg/logic` — controller + repository + errors - [x] Link domain in `@pkg/logic` — controller + repository + errors
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete - [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
- [ ] `apps/front`: validate incoming link token on request - [ ] `apps/front`: validate incoming link token on request
- [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse` - [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse`
- [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session - [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session
@@ -69,8 +69,8 @@ Currently in alpha. Greenfield. Subject to change.
### Android Streaming (scrcpy + ws-scrcpy) ### Android Streaming (scrcpy + ws-scrcpy)
- [ ] Docker-Android image setup and validation on VPS - [x] Docker-Android image setup and validation on VPS
- [ ] ws-scrcpy WebSocket server running per container, exposed via orchestrator - [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator
- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser - [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container) - [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
- [ ] Session timeout + stream teardown on inactivity - [ ] Session timeout + stream teardown on inactivity

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Mobile Proxy Test — Bun Reverse Proxy Server * Mobile Proxy Test — Bun Reverse Proxy Server
* *

View File

@@ -0,0 +1,54 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { ERROR_CODES, errorStatusMap, type Err } from "@pkg/result";
import { randomUUID } from "node:crypto";
import type { ContentfulStatusCode } from "hono/utils/http-status";
export function buildFlowExecCtx(): FlowExecCtx {
return { flowId: randomUUID() };
}
export function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
export function createAppError(
fctx: FlowExecCtx,
code: string,
message: string,
description: string,
detail: string,
actionable?: boolean,
): Err {
return {
flowId: fctx.flowId,
code,
message,
description,
detail,
actionable,
};
}
export function jsonError(error: Err, status?: number) {
return {
body: {
data: null,
error: {
...error,
},
},
status:
status ||
errorStatusMap[error.code] ||
errorStatusMap[ERROR_CODES.INTERNAL_SERVER_ERROR] ||
500,
};
}
export function toStatusCode(status: number): ContentfulStatusCode {
return status as ContentfulStatusCode;
}
export function isErrPayload(value: unknown): value is Err {
return !!value && typeof value === "object" && "code" in value;
}

View File

@@ -0,0 +1,13 @@
import { Hono } from "hono";
import { buildFlowExecCtx, toStatusCode } from "../../core/utils";
import { prepareLink, resolveLink } from "./service";
export const linksRouter = new Hono()
.get("/links/:token/resolve", async (c) => {
const response = await resolveLink(buildFlowExecCtx(), c.req.param("token"));
return c.json(response.body, toStatusCode(response.status));
})
.post("/links/:token/prepare", async (c) => {
const response = await prepareLink(buildFlowExecCtx(), c.req.param("token"));
return c.json(response.body, toStatusCode(response.status));
});

View File

@@ -0,0 +1,297 @@
import { getLinkController } from "@pkg/logic/domains/link/controller";
import { DeviceStatus } from "@pkg/logic/domains/device/data";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { ERROR_CODES, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import {
createAppError,
isErrPayload,
jsonError,
normalizeBaseUrl,
} from "../../core/utils";
const linkController = getLinkController();
type OrchestratorPreparePayload = {
deviceId: number;
packageName: string;
linkToken: string;
};
type OrchestratorPrepareResponse = {
data: {
deviceId: number;
containerId: string;
packageName: string;
serial: string;
status: string;
};
error: null;
};
async function callOrchestratorPrepare(
fctx: FlowExecCtx,
payload: OrchestratorPreparePayload,
): Promise<OrchestratorPrepareResponse> {
const response = await fetch(
`${normalizeBaseUrl(settings.orchestratorApiUrl)}/internal/sessions/prepare`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-internal-api-key": settings.internalApiKey,
"x-flow-id": fctx.flowId,
},
body: JSON.stringify(payload),
},
);
let body: unknown = null;
try {
body = await response.json();
} catch (error) {
throw createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Invalid orchestrator response",
"The orchestrator returned a response that could not be parsed",
formatErrorDetail(error),
);
}
if (!response.ok && body && typeof body === "object" && "error" in body) {
const payloadError = (body as { error?: unknown }).error;
if (isErrPayload(payloadError)) {
throw payloadError;
}
}
if (
!body ||
typeof body !== "object" ||
!("data" in body) ||
("error" in body && body.error)
) {
throw createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Unexpected orchestrator response",
"The orchestrator response was missing the expected data payload",
JSON.stringify(body),
);
}
return body as OrchestratorPrepareResponse;
}
export async function resolveLink(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_resolve.started",
fctx,
meta: { token },
});
const linkResult = await linkController.validate(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_resolve.rejected",
fctx,
error: linkResult.error,
meta: { token },
});
return jsonError(linkResult.error);
}
const link = linkResult.value;
if (!link.device) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to a device",
"This link cannot start a session because no device is assigned",
`token=${token}`,
true,
),
);
}
if (!link.supportedApp) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to an app",
"This link cannot start a session because no app is assigned",
`token=${token}`,
true,
),
);
}
return {
body: {
data: {
link: {
id: link.id,
token: link.token,
status: link.status,
expiresAt: link.expiresAt,
},
device: {
id: link.device.id,
title: link.device.title,
status: link.device.status,
inUse: link.device.inUse,
isAvailable:
link.device.status === DeviceStatus.ONLINE && !link.device.inUse,
},
supportedApp: {
id: link.supportedApp.id,
title: link.supportedApp.title,
packageName: link.supportedApp.packageName,
},
},
error: null,
},
status: 200,
};
}
export async function prepareLink(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_prepare.started",
fctx,
meta: { token },
});
const linkResult = await linkController.validate(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_prepare.rejected",
fctx,
error: linkResult.error,
meta: { token },
});
return jsonError(linkResult.error);
}
const link = linkResult.value;
if (!link.device) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to a device",
"This link cannot start a session because no device is assigned",
`token=${token}`,
true,
),
);
}
if (!link.supportedApp) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to an app",
"This link cannot start a session because no app is assigned",
`token=${token}`,
true,
),
);
}
if (link.device.status !== DeviceStatus.ONLINE || link.device.inUse) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Device is not available",
"The assigned device is currently busy or offline",
`deviceId=${link.device.id} status=${link.device.status} inUse=${link.device.inUse}`,
true,
),
);
}
try {
const orchestratorResponse = await callOrchestratorPrepare(fctx, {
deviceId: link.device.id,
packageName: link.supportedApp.packageName,
linkToken: link.token,
});
logDomainEvent({
event: "front.link_prepare.succeeded",
fctx,
meta: {
token,
deviceId: link.device.id,
packageName: link.supportedApp.packageName,
},
});
return {
body: {
data: {
link: {
id: link.id,
token: link.token,
},
device: {
id: link.device.id,
title: link.device.title,
host: link.device.host,
wsPort: link.device.wsPort,
},
supportedApp: {
id: link.supportedApp.id,
title: link.supportedApp.title,
packageName: link.supportedApp.packageName,
},
session: orchestratorResponse.data,
},
error: null,
},
status: 200,
};
} catch (error) {
const err: Err = isErrPayload(error)
? error
: createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Failed to prepare session",
"The front server could not prepare the assigned Android session",
formatErrorDetail(error),
);
logDomainEvent({
level: "error",
event: "front.link_prepare.failed",
fctx,
error: err,
meta: {
token,
orchestratorUrl: settings.orchestratorApiUrl,
},
});
return jsonError(
err.flowId
? err
: {
...err,
flowId: fctx.flowId,
},
err.code === ERROR_CODES.EXTERNAL_SERVICE_ERROR ? 502 : undefined,
);
}
}

View File

@@ -1,33 +1,15 @@
import "./instrumentation.js"; import "./instrumentation.js";
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry"; import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { logDomainEvent } from "@pkg/logger";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { settings } from "@pkg/settings";
import { randomUUID } from "node:crypto";
import { Hono } from "hono"; import { Hono } from "hono";
import { linksRouter } from "./domains/links/router";
const app = new Hono().use("*", createHttpTelemetryMiddleware("front")); const app = new Hono().use("*", createHttpTelemetryMiddleware("front"));
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000"); const port = Number(process.env.PORT || "3000");
function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function buildFlowExecCtx(): FlowExecCtx {
return { flowId: randomUUID() };
}
function getClientDownloadedApkName(): string {
const filename = settings.clientDownloadedApkName.trim();
return filename.toLowerCase().endsWith(".apk")
? filename
: `${filename}.apk`;
}
app.get("/health", (c) => { app.get("/health", (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
@@ -36,110 +18,7 @@ app.get("/ping", (c) => {
return c.text("pong"); return c.text("pong");
}); });
app.get("/downloads/file/:buildId", async (c) => { app.route("/", linksRouter);
const fctx = buildFlowExecCtx();
const buildId = c.req.param("buildId");
logDomainEvent({
event: "processor.apk_download.started",
fctx,
meta: { buildId },
});
const buildResult = await mobileBuildController.validateActiveBuildId(
fctx,
buildId,
);
if (buildResult.isErr()) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.rejected",
fctx,
error: buildResult.error,
meta: { buildId },
});
return c.json(
{
data: null,
error: { ...buildResult.error, flowId: fctx.flowId },
},
404,
);
}
const build = buildResult.value;
if (!build.apkAssetPath) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.missing_artifact",
fctx,
meta: { buildId },
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "NOT_FOUND",
message: "APK not available",
description: "This build does not have a generated APK yet",
detail: `buildId=${buildId}`,
},
},
404,
);
}
const assetUrl = `${normalizeBaseUrl(settings.appBuilderApiUrl)}${build.apkAssetPath}`;
const assetResponse = await fetch(assetUrl);
if (!assetResponse.ok || !assetResponse.body) {
logDomainEvent({
level: "error",
event: "processor.apk_download.fetch_failed",
fctx,
meta: {
buildId,
assetUrl,
status: assetResponse.status,
},
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch APK artifact",
description: "Please try again later",
detail: `assetUrl=${assetUrl} status=${assetResponse.status}`,
},
},
502,
);
}
logDomainEvent({
event: "processor.apk_download.succeeded",
fctx,
meta: {
buildId,
assetUrl,
downloadName: getClientDownloadedApkName(),
},
});
return new Response(assetResponse.body, {
status: 200,
headers: {
"content-type":
assetResponse.headers.get("content-type") ||
"application/vnd.android.package-archive",
"content-disposition": `attachment; filename="${getClientDownloadedApkName()}"`,
"cache-control": "no-store",
},
});
});
serve( serve(
{ {

View File

@@ -27,7 +27,7 @@ function buildStreamUrl(host: string, wsPort: string): string | null {
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
const wsParam = const wsParam =
`${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`; `${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`;
const hash = const hash =
`#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`; `#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;

View File

@@ -26,10 +26,27 @@ class LinkViewModel {
revokingId = $state<number | null>(null); revokingId = $state<number | null>(null);
showCreateDialog = $state(false); showCreateDialog = $state(false);
/**
* SvelteKit's query() caches the RemoteQuery object in an internal query_map.
* Subsequent calls to listLinksSQ() return the same cached Query with stale data.
* We must call refresh() on the cached query to force a fresh server request.
*/
private _linksQuery: ReturnType<typeof listLinksSQ> | null = null;
async fetchLinks() { async fetchLinks() {
this.loading = true; this.loading = true;
try { try {
const result = await listLinksSQ(); const query = listLinksSQ();
// On subsequent calls the query is cached and stale — refresh it
if (query.ready) {
await query.refresh();
}
// After refresh, read .current; on first load, await the initial fetch
const result = query.ready ? query.current : await query;
this._linksQuery = query;
if (result?.error || !result?.data) { if (result?.error || !result?.data) {
toast.error( toast.error(
result?.error?.message || "Failed to fetch links", result?.error?.message || "Failed to fetch links",

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
@@ -6,12 +7,11 @@
import * as Card from "$lib/components/ui/card"; import * as Card from "$lib/components/ui/card";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { Skeleton } from "$lib/components/ui/skeleton"; import { Skeleton } from "$lib/components/ui/skeleton";
import { mainNavTree } from "$lib/core/constants"; import { mainNavTree, WS_SCRCPY_URL } from "$lib/core/constants";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { deviceDetailsVM } from "$lib/domains/device/device-details.vm.svelte"; import { deviceDetailsVM } from "$lib/domains/device/device-details.vm.svelte";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { deviceVM } from "$lib/domains/device/device.vm.svelte"; import { deviceVM } from "$lib/domains/device/device.vm.svelte";
import { breadcrumbs } from "$lib/global.stores"; import { breadcrumbs } from "$lib/global.stores";
import { page } from "$app/state";
import ExternalLink from "@lucide/svelte/icons/external-link"; import ExternalLink from "@lucide/svelte/icons/external-link";
import MonitorSmartphone from "@lucide/svelte/icons/monitor-smartphone"; import MonitorSmartphone from "@lucide/svelte/icons/monitor-smartphone";
import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import RefreshCw from "@lucide/svelte/icons/refresh-cw";
@@ -29,11 +29,9 @@
let editWsPort = $state(""); let editWsPort = $state("");
let editIsActive = $state(false); let editIsActive = $state(false);
let editInUse = $state(false); let editInUse = $state(false);
let editStatus = $state("offline" as let editStatus = $state(
| "online" "offline" as "online" | "offline" | "busy" | "error",
| "offline" );
| "busy"
| "error");
function formatDate(value: Date | string | null | undefined): string { function formatDate(value: Date | string | null | undefined): string {
if (!value) return "—"; if (!value) return "—";
@@ -133,7 +131,8 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" /> <Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
<Card.Title> <Card.Title>
{currentDevice?.title || `Device #${page.params.id}`} {currentDevice?.title ||
`Device #${page.params.id}`}
</Card.Title> </Card.Title>
</div> </div>
@@ -141,7 +140,9 @@
<div <div
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm" class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
> >
<Badge variant={statusVariant(currentDevice.status)}> <Badge
variant={statusVariant(currentDevice.status)}
>
{currentDevice.status} {currentDevice.status}
</Badge> </Badge>
<span> <span>
@@ -226,7 +227,10 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={Server} cls="h-4 w-4 text-primary" /> <Icon
icon={Server}
cls="h-4 w-4 text-primary"
/>
<Card.Title class="text-base"> <Card.Title class="text-base">
Metadata Metadata
</Card.Title> </Card.Title>
@@ -235,27 +239,45 @@
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
<div class="grid gap-3 text-sm"> <div class="grid gap-3 text-sm">
<div> <div>
<p class="text-muted-foreground text-xs">Device ID</p> <p class="text-muted-foreground text-xs">
<p class="font-medium">{currentDevice.id}</p> Device ID
</p>
<p class="font-medium">
{currentDevice.id}
</p>
</div> </div>
<div> <div>
<p class="text-muted-foreground text-xs">Host</p> <p class="text-muted-foreground text-xs">
Host
</p>
<p class="break-all font-mono text-xs"> <p class="break-all font-mono text-xs">
{currentDevice.host} {currentDevice.host}
</p> </p>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<p class="text-muted-foreground text-xs">WS Port</p> <p
class="text-muted-foreground text-xs"
>
WS Port
</p>
<p>{currentDevice.wsPort}</p> <p>{currentDevice.wsPort}</p>
</div> </div>
<div> <div>
<p class="text-muted-foreground text-xs">In Use</p> <p
<p>{currentDevice.inUse ? "Yes" : "No"}</p> class="text-muted-foreground text-xs"
>
In Use
</p>
<p>
{currentDevice.inUse ? "Yes" : "No"}
</p>
</div> </div>
</div> </div>
<div> <div>
<p class="text-muted-foreground text-xs">Container ID</p> <p class="text-muted-foreground text-xs">
Container ID
</p>
<p class="break-all font-mono text-xs"> <p class="break-all font-mono text-xs">
{currentDevice.containerId} {currentDevice.containerId}
</p> </p>
@@ -267,12 +289,28 @@
<div class="grid gap-3 text-sm"> <div class="grid gap-3 text-sm">
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<p class="text-muted-foreground text-xs">Created</p> <p
<p class="text-xs">{formatDate(currentDevice.createdAt)}</p> class="text-muted-foreground text-xs"
>
Created
</p>
<p class="text-xs">
{formatDate(
currentDevice.createdAt,
)}
</p>
</div> </div>
<div> <div>
<p class="text-muted-foreground text-xs">Updated</p> <p
<p class="text-xs">{formatDate(currentDevice.updatedAt)}</p> class="text-muted-foreground text-xs"
>
Updated
</p>
<p class="text-xs">
{formatDate(
currentDevice.updatedAt,
)}
</p>
</div> </div>
</div> </div>
<div> <div>
@@ -280,7 +318,8 @@
Viewer URL Viewer URL
</p> </p>
<p class="break-all font-mono text-xs"> <p class="break-all font-mono text-xs">
{streamUrl || "Missing host configuration"} {streamUrl ||
"Missing host configuration"}
</p> </p>
</div> </div>
</div> </div>
@@ -290,16 +329,25 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={RefreshCw} cls="h-4 w-4 text-primary" /> <Icon
icon={RefreshCw}
cls="h-4 w-4 text-primary"
/>
<Card.Title class="text-base"> <Card.Title class="text-base">
Edit Device Edit Device
</Card.Title> </Card.Title>
</div> </div>
<Card.Description> <Card.Description>
Manually update connection details or override Manually update connection details or override
<code class="bg-muted rounded px-1 text-xs">isActive</code>, <code class="bg-muted rounded px-1 text-xs"
<code class="bg-muted rounded px-1 text-xs">inUse</code>, and >isActive</code
<code class="bg-muted rounded px-1 text-xs">status</code> when needed. >,
<code class="bg-muted rounded px-1 text-xs"
>inUse</code
>, and
<code class="bg-muted rounded px-1 text-xs"
>status</code
> when needed.
</Card.Description> </Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
@@ -335,6 +383,22 @@
Live Device Session Live Device Session
</Card.Title> </Card.Title>
</div> </div>
<div class="flex items-center gap-1">
<a
href={WS_SCRCPY_URL}
target="_blank"
rel="noreferrer"
class={buttonVariants({
variant: "ghost",
size: "sm",
})}
>
<Icon
icon={ExternalLink}
cls="mr-1.5 h-3.5 w-3.5"
/>
Device Viewer Home
</a>
{#if streamUrl} {#if streamUrl}
<a <a
href={streamUrl} href={streamUrl}
@@ -345,11 +409,15 @@
size: "sm", size: "sm",
})} })}
> >
<Icon icon={ExternalLink} cls="mr-1.5 h-3.5 w-3.5" /> <Icon
icon={ExternalLink}
cls="mr-1.5 h-3.5 w-3.5"
/>
Pop out Pop out
</a> </a>
{/if} {/if}
</div> </div>
</div>
<Card.Description> <Card.Description>
Full interactive device access is embedded here so Full interactive device access is embedded here so
admins do not need to leave the dashboard. admins do not need to leave the dashboard.

View File

@@ -0,0 +1,136 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail } from "@pkg/logger";
import { ERROR_CODES, errorStatusMap, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import { randomUUID } from "node:crypto";
import type { MiddlewareHandler } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import * as v from "valibot";
export type AppBindings = {
Variables: {
fctx: FlowExecCtx;
};
};
export function buildFlowExecCtx(flowId?: string): FlowExecCtx {
return { flowId: flowId || randomUUID() };
}
export function createAppError(
fctx: FlowExecCtx,
code: string,
message: string,
description: string,
detail: string,
actionable?: boolean,
): Err {
return {
flowId: fctx.flowId,
code,
message,
description,
detail,
actionable,
};
}
export function jsonError(error: Err, status?: number) {
return {
body: {
data: null,
error: {
...error,
},
},
status:
status ||
errorStatusMap[error.code] ||
errorStatusMap[ERROR_CODES.INTERNAL_SERVER_ERROR] ||
500,
};
}
export function toStatusCode(status: number): ContentfulStatusCode {
return status as ContentfulStatusCode;
}
export function isErrPayload(value: unknown): value is Err {
return !!value && typeof value === "object" && "code" in value;
}
export function parseDeviceId(rawId: string, fctx: FlowExecCtx): number | Err {
const parsed = Number(rawId);
if (!Number.isInteger(parsed) || parsed <= 0) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid device id",
"The provided device id is not valid",
`deviceId=${rawId}`,
true,
);
}
return parsed;
}
export async function parseJsonBody<TInput, TSchema extends v.GenericSchema<TInput>>(
request: Request,
schema: TSchema,
fctx: FlowExecCtx,
): Promise<TInput | Err> {
let payload: unknown;
try {
payload = await request.json();
} catch (error) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid JSON body",
"The request body must be valid JSON",
formatErrorDetail(error),
true,
);
}
const parsed = v.safeParse(schema, payload);
if (!parsed.success) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid request body",
"The request payload did not match the expected shape",
parsed.issues.map((issue) => issue.message).join("; "),
true,
);
}
return parsed.output;
}
export const requireInternalApiKey: MiddlewareHandler<AppBindings> = async (
c,
next,
) => {
const fctx = buildFlowExecCtx(c.req.header("x-flow-id"));
c.set("fctx", fctx);
const apiKey = c.req.header("x-internal-api-key");
if (apiKey !== settings.internalApiKey) {
const error = createAppError(
fctx,
ERROR_CODES.AUTH_ERROR,
"Unauthorized",
"The internal API key is invalid",
"Missing or invalid x-internal-api-key header",
true,
);
const response = jsonError(error, 403);
return c.json(response.body, toStatusCode(response.status));
}
await next();
};

View File

@@ -0,0 +1,57 @@
import type { Device } from "@pkg/logic/domains/device/data";
import { formatErrorDetail } from "@pkg/logger";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
export type InternalAction = "start" | "stop" | "restart";
const execFileAsync = promisify(execFile);
export class AndroidCommandExecutor {
constructor(
private readonly binary: string,
private readonly timeoutMs: number,
) {}
async prepareAssignedApp(device: Device, packageName: string) {
const serial = `${device.host}:${device.wsPort}`;
await this.run(["connect", serial]);
await this.run(["-s", serial, "wait-for-device"]);
await this.run(
["-s", serial, "shell", "am", "force-stop", packageName],
{ allowFailure: true },
);
await this.run([
"-s",
serial,
"shell",
"monkey",
"-p",
packageName,
"-c",
"android.intent.category.LAUNCHER",
"1",
]);
return { serial };
}
async disconnect(device: Device) {
const serial = `${device.host}:${device.wsPort}`;
await this.run(["disconnect", serial], { allowFailure: true });
}
private async run(args: string[], options?: { allowFailure?: boolean }) {
try {
return await execFileAsync(this.binary, args, {
timeout: this.timeoutMs,
});
} catch (error) {
if (options?.allowFailure) {
return { stdout: "", stderr: formatErrorDetail(error) };
}
throw error;
}
}
}

View File

@@ -0,0 +1,74 @@
import { Hono } from "hono";
import type { AppBindings } from "../../core/utils";
import { jsonError, parseDeviceId, toStatusCode } from "../../core/utils";
import {
getDeviceById,
handleContainerAction,
listDevices,
} from "./service";
export const commandRouter = new Hono<AppBindings>()
.get("/devices", async (c) => {
const fctx = c.get("fctx");
const result = await listDevices(fctx);
if (result.isErr()) {
const response = jsonError(result.error);
return c.json(response.body, toStatusCode(response.status));
}
return c.json({ data: result.value, error: null });
})
.get("/devices/:id", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const result = await getDeviceById(fctx, parsedId);
if (result.isErr()) {
const response = jsonError(result.error);
return c.json(response.body, toStatusCode(response.status));
}
return c.json({ data: result.value, error: null });
})
.post("/devices/:id/start", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("start", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
})
.post("/devices/:id/stop", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("stop", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
})
.post("/devices/:id/restart", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("restart", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
});

View File

@@ -0,0 +1,64 @@
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import type { Device } from "@pkg/logic/domains/device/data";
import { createAppError, jsonError } from "../../core/utils";
import { AndroidCommandExecutor } from "./executor";
import { logDomainEvent } from "@pkg/logger";
import { ERROR_CODES } from "@pkg/result";
export type InternalAction = "start" | "stop" | "restart";
const deviceController = getDeviceController();
const adbBinary = process.env.ADB_BIN || "adb";
const adbTimeoutMs = Number(process.env.ADB_TIMEOUT_MS || "15000");
const androidExecutor = new AndroidCommandExecutor(adbBinary, adbTimeoutMs);
export async function handleContainerAction(
action: InternalAction,
deviceId: number,
fctx: FlowExecCtx,
) {
const deviceResult = await deviceController.getById(fctx, deviceId);
if (deviceResult.isErr()) {
return jsonError(deviceResult.error);
}
logDomainEvent({
level: "warn",
event: "orchestrator.device_container.stubbed",
fctx,
meta: {
action,
deviceId,
containerId: deviceResult.value.containerId,
},
});
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_IMPLEMENTED,
"Container action not implemented",
"This container control endpoint is currently a stub",
`action=${action} deviceId=${deviceId}`,
),
501,
);
}
export async function listDevices(fctx: FlowExecCtx) {
return deviceController.list(fctx);
}
export async function getDeviceById(fctx: FlowExecCtx, deviceId: number) {
return deviceController.getById(fctx, deviceId);
}
export async function prepareAssignedApp(device: Device, packageName: string) {
return androidExecutor.prepareAssignedApp(device, packageName);
}
export async function disconnectDevice(device: Device) {
return androidExecutor.disconnect(device);
}

View File

@@ -0,0 +1,9 @@
import * as v from "valibot";
export const sessionPrepareSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
packageName: v.pipe(v.string(), v.minLength(1)),
linkToken: v.optional(v.pipe(v.string(), v.minLength(1))),
});
export type SessionPrepareInput = v.InferOutput<typeof sessionPrepareSchema>;

View File

@@ -0,0 +1,30 @@
import { Hono } from "hono";
import type { AppBindings } from "../../core/utils";
import {
isErrPayload,
jsonError,
parseJsonBody,
toStatusCode,
} from "../../core/utils";
import { type SessionPrepareInput, sessionPrepareSchema } from "./data";
import { prepareSession } from "./service";
export const orchestrateRouter = new Hono<AppBindings>().post(
"/sessions/prepare",
async (c) => {
const fctx = c.get("fctx");
const body = await parseJsonBody<SessionPrepareInput, typeof sessionPrepareSchema>(
c.req.raw,
sessionPrepareSchema,
fctx,
);
if (isErrPayload(body)) {
const response = jsonError(body);
return c.json(response.body, toStatusCode(response.status));
}
const response = await prepareSession(fctx, body);
return c.json(response.body, toStatusCode(response.status));
},
);

View File

@@ -0,0 +1,134 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { createAppError, jsonError } from "../../core/utils";
import type { SessionPrepareInput } from "./data";
import { ERROR_CODES } from "@pkg/result";
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import { disconnectDevice, prepareAssignedApp } from "../command/service";
const deviceController = getDeviceController();
export async function prepareSession(
fctx: FlowExecCtx,
input: SessionPrepareInput,
) {
logDomainEvent({
event: "orchestrator.session_prepare.started",
fctx,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
linkToken: input.linkToken,
},
});
const deviceResult = await deviceController.getById(fctx, input.deviceId);
if (deviceResult.isErr()) {
logDomainEvent({
level: "warn",
event: "orchestrator.session_prepare.device_lookup_failed",
fctx,
error: deviceResult.error,
meta: {
deviceId: input.deviceId,
},
});
return jsonError(deviceResult.error);
}
const allocatedDevice = await deviceController.allocate(
fctx,
input.deviceId,
);
if (allocatedDevice.isErr()) {
logDomainEvent({
level: "warn",
event: "orchestrator.session_prepare.device_unavailable",
fctx,
error: allocatedDevice.error,
meta: {
deviceId: input.deviceId,
},
});
return jsonError(allocatedDevice.error);
}
let releaseRequired = true;
try {
const prepResult = await prepareAssignedApp(
allocatedDevice.value,
input.packageName,
);
logDomainEvent({
event: "orchestrator.session_prepare.succeeded",
fctx,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
serial: prepResult.serial,
},
});
releaseRequired = false;
return {
body: {
data: {
deviceId: input.deviceId,
containerId: allocatedDevice.value.containerId,
packageName: input.packageName,
serial: prepResult.serial,
status: "prepared",
},
error: null,
},
status: 200,
};
} catch (error) {
logDomainEvent({
level: "error",
event: "orchestrator.session_prepare.failed",
fctx,
error,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
},
});
return jsonError(
createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Failed to prepare Android session",
"The orchestrator could not connect to the Android device or launch the app",
formatErrorDetail(error),
),
502,
);
} finally {
await disconnectDevice(allocatedDevice.value);
if (releaseRequired) {
const releaseResult = await deviceController.release(
fctx,
input.deviceId,
);
if (releaseResult.isErr()) {
logDomainEvent({
level: "error",
event: "orchestrator.session_prepare.release_failed",
fctx,
error: releaseResult.error,
meta: {
deviceId: input.deviceId,
},
});
}
}
}
}

View File

@@ -3,8 +3,15 @@ import "./instrumentation.js";
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry"; import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import type { AppBindings } from "./core/utils";
import { requireInternalApiKey } from "./core/utils";
import { commandRouter } from "./domains/command/router";
import { orchestrateRouter } from "./domains/orchestrate/router";
const app = new Hono().use("*", createHttpTelemetryMiddleware("orchestrator")); const app = new Hono<AppBindings>().use(
"*",
createHttpTelemetryMiddleware("orchestrator"),
);
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000"); const port = Number(process.env.PORT || "3000");
@@ -17,6 +24,10 @@ app.get("/ping", (c) => {
return c.text("pong"); return c.text("pong");
}); });
app.use("/internal/*", requireInternalApiKey);
app.route("/internal", commandRouter);
app.route("/internal", orchestrateRouter);
serve( serve(
{ {
fetch: app.fetch, fetch: app.fetch,

View File

@@ -110,3 +110,41 @@ Update rule:
- Refactored `apps/main/src/lib/domains/{device,link,supported-app}` to remove local duplicate data type declarations and import canonical types from `@pkg/logic/domains/*/data` - Refactored `apps/main/src/lib/domains/{device,link,supported-app}` to remove local duplicate data type declarations and import canonical types from `@pkg/logic/domains/*/data`
- Updated device status validation in `device.remote.ts` to reuse `deviceStatusSchema` from logic instead of a duplicated local picklist - Updated device status validation in `device.remote.ts` to reuse `deviceStatusSchema` from logic instead of a duplicated local picklist
- Kept only derived UI helper types (`Pick`/`Omit`) where needed for presentation and transport-shape compatibility - Kept only derived UI helper types (`Pick`/`Omit`) where needed for presentation and transport-shape compatibility
### 15 — Fixed Links Table Not Refreshing After Create / Refresh Click
- Root cause: SvelteKit's `query()` caches `RemoteQuery` objects in an internal `query_map`; subsequent calls to `listLinksSQ()` returned the same stale cached Query
- Fix: in `LinkViewModel.fetchLinks()`, check `query.ready` and call `query.refresh()` on re-fetches to force a fresh server request; read `query.current` for the updated data
- This fixes both the post-create refresh and the manual Refresh button
### 16 — Front/Orchestrator Server Session Prep Flow
- Reworked `apps/front` into the current link/session server: added `GET /links/:token/resolve` and `POST /links/:token/prepare` to validate links, expose assigned device/app data, and call orchestrator server-side
- Implemented `apps/orchestrator` internal API with API-key auth, device list/detail routes, stubbed container `start|stop|restart` endpoints, and a real `POST /internal/sessions/prepare` flow
- Session prepare now allocates the device, runs ADB connect/force-stop/launch commands for the assigned package, and releases the device on failure while keeping it busy on success
- Hardened device allocation in `@pkg/logic` with an atomic `allocateIfAvailable` repository path so concurrent session-start attempts do not rely on a read-then-write race
### 17 — Orchestrator Domain Refactor
- Split `apps/orchestrator` into a thin composition entrypoint plus domain routers/services under `src/domains/{command,orchestrate}`
- Centralized shared Hono app helpers in `apps/orchestrator/src/core/utils.ts` for internal auth, flow context, request parsing, and JSON error shaping
- Kept the existing internal API contract intact while removing the previous monolithic `src/index.ts` implementation
### 18 — Front/Orchestrator Type Error Cleanup
- Fixed Hono response status typing in both `apps/front` and `apps/orchestrator` by normalizing numeric status values before `c.json(...)`
- Fixed orchestrator request-body narrowing in the orchestrate router so parsed payloads and error payloads are typed correctly
- Marked the legacy Bun prototype server in `apps/front/old.server.ts` as excluded from TypeScript checking so it no longer breaks the current Node/Hono apps
- Verified both `apps/orchestrator` and `apps/front` compile cleanly with `tsc --noEmit`
### 19 — Command/Orchestrate Responsibility Split
- Moved the low-level ADB executor and app-launch operations into `apps/orchestrator/src/domains/command/service.ts`
- Simplified `apps/orchestrator/src/domains/orchestrate/service.ts` so it now only coordinates device allocation, invokes command-domain execution, and handles release-on-failure
- Verified the reorganized orchestrator app still compiles cleanly with `tsc --noEmit`
### 20 — Front Domain Cleanup
- Split `apps/front` into a thin entrypoint, shared `src/core/utils.ts`, and a dedicated `src/domains/links/{router,service}.ts` flow
- Moved link resolve/prepare logic and orchestrator calling into the links domain service so `src/index.ts` only wires middleware and routes
- Verified the cleaned `apps/front` app compiles cleanly with `tsc --noEmit`

View File

@@ -1,6 +1,6 @@
import { errAsync, ResultAsync } from "neverthrow"; import { errAsync, ResultAsync } from "neverthrow";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
import { type Err } from "@pkg/result"; import { ERROR_CODES, type Err } from "@pkg/result";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import { CreateDevice, Device, DeviceStatus, UpdateDevice } from "./data"; import { CreateDevice, Device, DeviceStatus, UpdateDevice } from "./data";
import { DeviceRepository } from "./repository"; import { DeviceRepository } from "./repository";
@@ -46,14 +46,20 @@ export class DeviceController {
* Only succeeds if the device is currently online. * Only succeeds if the device is currently online.
*/ */
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> { allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.getById(fctx, id).andThen((dev) => { return this.repo.allocateIfAvailable(fctx, id).orElse((error) => {
if (error.code === ERROR_CODES.NOT_ALLOWED) {
return this.repo
.getById(fctx, id)
.andThen((dev) => {
if (dev.status !== DeviceStatus.ONLINE || dev.inUse) { if (dev.status !== DeviceStatus.ONLINE || dev.inUse) {
return errAsync(deviceErrors.deviceNotAvailable(fctx, id)); return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
} }
return this.repo.update(fctx, id, { return errAsync(error);
status: DeviceStatus.BUSY, })
inUse: true, .orElse(() => errAsync(error));
}); }
return errAsync(error);
}); });
} }

View File

@@ -1,6 +1,6 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import { Database, asc, eq } from "@pkg/db"; import { Database, and, asc, eq } from "@pkg/db";
import { device } from "@pkg/db/schema"; import { device } from "@pkg/db/schema";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { logger } from "@pkg/logger"; import { logger } from "@pkg/logger";
@@ -135,4 +135,40 @@ export class DeviceRepository {
): ResultAsync<Device, Err> { ): ResultAsync<Device, Err> {
return this.update(fctx, id, { status }); return this.update(fctx, id, { status });
} }
allocateIfAvailable(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return traceResultAsync({
name: "device.allocateIfAvailable",
fctx,
fn: () =>
ResultAsync.fromPromise(
this.db
.update(device)
.set({
status: DeviceStatus.BUSY,
inUse: true,
updatedAt: new Date(),
})
.where(
and(
eq(device.id, id),
eq(device.status, DeviceStatus.ONLINE),
eq(device.inUse, false),
),
)
.returning()
.execute(),
(e) =>
deviceErrors.updateFailed(
fctx,
e instanceof Error ? e.message : String(e),
),
).andThen((rows) => {
if (!rows[0]) {
return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
}
return okAsync(rows[0] as Device);
}),
});
}
} }