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:
16
README.md
16
README.md
@@ -35,8 +35,8 @@ Currently in alpha. Greenfield. Subject to change.
|
||||
|
||||
### Device Management (Orchestrator + Admin)
|
||||
|
||||
- [ ] 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 schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.)
|
||||
- [x] Device domain in `@pkg/logic` — controller + repository + errors
|
||||
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
|
||||
- [ ] `POST /devices/:id/start` — start a Docker-Android 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)
|
||||
- [ ] 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
|
||||
- [ ] 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`
|
||||
|
||||
### Link Management (Admin + Front App)
|
||||
|
||||
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
|
||||
- [ ] Link domain in `@pkg/logic` — controller + repository + errors
|
||||
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
|
||||
- [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
|
||||
- [x] Link domain in `@pkg/logic` — controller + repository + errors
|
||||
- [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`: 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
|
||||
@@ -69,8 +69,8 @@ Currently in alpha. Greenfield. Subject to change.
|
||||
|
||||
### Android Streaming (scrcpy + ws-scrcpy)
|
||||
|
||||
- [ ] Docker-Android image setup and validation on VPS
|
||||
- [ ] ws-scrcpy WebSocket server running per container, exposed via orchestrator
|
||||
- [x] Docker-Android image setup and validation on VPS
|
||||
- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator
|
||||
- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser
|
||||
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
|
||||
- [ ] Session timeout + stream teardown on inactivity
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Mobile Proxy Test — Bun Reverse Proxy Server
|
||||
*
|
||||
|
||||
54
apps/front/src/core/utils.ts
Normal file
54
apps/front/src/core/utils.ts
Normal 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;
|
||||
}
|
||||
13
apps/front/src/domains/links/router.ts
Normal file
13
apps/front/src/domains/links/router.ts
Normal 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));
|
||||
});
|
||||
297
apps/front/src/domains/links/service.ts
Normal file
297
apps/front/src/domains/links/service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,15 @@
|
||||
import "./instrumentation.js";
|
||||
|
||||
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 { settings } from "@pkg/settings";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { linksRouter } from "./domains/links/router";
|
||||
|
||||
const app = new Hono().use("*", createHttpTelemetryMiddleware("front"));
|
||||
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
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) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -36,110 +18,7 @@ app.get("/ping", (c) => {
|
||||
return c.text("pong");
|
||||
});
|
||||
|
||||
app.get("/downloads/file/:buildId", async (c) => {
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
app.route("/", linksRouter);
|
||||
|
||||
serve(
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildStreamUrl(host: string, wsPort: string): string | null {
|
||||
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
|
||||
|
||||
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 =
|
||||
`#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;
|
||||
|
||||
@@ -26,10 +26,27 @@ class LinkViewModel {
|
||||
revokingId = $state<number | null>(null);
|
||||
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() {
|
||||
this.loading = true;
|
||||
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) {
|
||||
toast.error(
|
||||
result?.error?.message || "Failed to fetch links",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
@@ -6,12 +7,11 @@
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import DeviceForm from "$lib/domains/device/device-form.svelte";
|
||||
import { mainNavTree, WS_SCRCPY_URL } from "$lib/core/constants";
|
||||
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 { breadcrumbs } from "$lib/global.stores";
|
||||
import { page } from "$app/state";
|
||||
import ExternalLink from "@lucide/svelte/icons/external-link";
|
||||
import MonitorSmartphone from "@lucide/svelte/icons/monitor-smartphone";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
@@ -29,11 +29,9 @@
|
||||
let editWsPort = $state("");
|
||||
let editIsActive = $state(false);
|
||||
let editInUse = $state(false);
|
||||
let editStatus = $state("offline" as
|
||||
| "online"
|
||||
| "offline"
|
||||
| "busy"
|
||||
| "error");
|
||||
let editStatus = $state(
|
||||
"offline" as "online" | "offline" | "busy" | "error",
|
||||
);
|
||||
|
||||
function formatDate(value: Date | string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
@@ -133,7 +131,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>
|
||||
{currentDevice?.title || `Device #${page.params.id}`}
|
||||
{currentDevice?.title ||
|
||||
`Device #${page.params.id}`}
|
||||
</Card.Title>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +140,9 @@
|
||||
<div
|
||||
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}
|
||||
</Badge>
|
||||
<span>
|
||||
@@ -226,7 +227,10 @@
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<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">
|
||||
Metadata
|
||||
</Card.Title>
|
||||
@@ -235,27 +239,45 @@
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Device ID</p>
|
||||
<p class="font-medium">{currentDevice.id}</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Device ID
|
||||
</p>
|
||||
<p class="font-medium">
|
||||
{currentDevice.id}
|
||||
</p>
|
||||
</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">
|
||||
{currentDevice.host}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">In Use</p>
|
||||
<p>{currentDevice.inUse ? "Yes" : "No"}</p>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
In Use
|
||||
</p>
|
||||
<p>
|
||||
{currentDevice.inUse ? "Yes" : "No"}
|
||||
</p>
|
||||
</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">
|
||||
{currentDevice.containerId}
|
||||
</p>
|
||||
@@ -267,12 +289,28 @@
|
||||
<div class="grid gap-3 text-sm">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Created</p>
|
||||
<p class="text-xs">{formatDate(currentDevice.createdAt)}</p>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Created
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{formatDate(
|
||||
currentDevice.createdAt,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Updated</p>
|
||||
<p class="text-xs">{formatDate(currentDevice.updatedAt)}</p>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Updated
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{formatDate(
|
||||
currentDevice.updatedAt,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -280,7 +318,8 @@
|
||||
Viewer URL
|
||||
</p>
|
||||
<p class="break-all font-mono text-xs">
|
||||
{streamUrl || "Missing host configuration"}
|
||||
{streamUrl ||
|
||||
"Missing host configuration"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,16 +329,25 @@
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<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">
|
||||
Edit Device
|
||||
</Card.Title>
|
||||
</div>
|
||||
<Card.Description>
|
||||
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">inUse</code>, and
|
||||
<code class="bg-muted rounded px-1 text-xs">status</code> when needed.
|
||||
<code class="bg-muted rounded px-1 text-xs"
|
||||
>isActive</code
|
||||
>,
|
||||
<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.Header>
|
||||
<Card.Content>
|
||||
@@ -335,9 +383,9 @@
|
||||
Live Device Session
|
||||
</Card.Title>
|
||||
</div>
|
||||
{#if streamUrl}
|
||||
<div class="flex items-center gap-1">
|
||||
<a
|
||||
href={streamUrl}
|
||||
href={WS_SCRCPY_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class={buttonVariants({
|
||||
@@ -345,10 +393,30 @@
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
<Icon icon={ExternalLink} cls="mr-1.5 h-3.5 w-3.5" />
|
||||
Pop out
|
||||
<Icon
|
||||
icon={ExternalLink}
|
||||
cls="mr-1.5 h-3.5 w-3.5"
|
||||
/>
|
||||
Device Viewer Home
|
||||
</a>
|
||||
{/if}
|
||||
{#if streamUrl}
|
||||
<a
|
||||
href={streamUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class={buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
icon={ExternalLink}
|
||||
cls="mr-1.5 h-3.5 w-3.5"
|
||||
/>
|
||||
Pop out
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Card.Description>
|
||||
Full interactive device access is embedded here so
|
||||
|
||||
136
apps/orchestrator/src/core/utils.ts
Normal file
136
apps/orchestrator/src/core/utils.ts
Normal 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();
|
||||
};
|
||||
57
apps/orchestrator/src/domains/command/executor.ts
Normal file
57
apps/orchestrator/src/domains/command/executor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
apps/orchestrator/src/domains/command/router.ts
Normal file
74
apps/orchestrator/src/domains/command/router.ts
Normal 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));
|
||||
});
|
||||
64
apps/orchestrator/src/domains/command/service.ts
Normal file
64
apps/orchestrator/src/domains/command/service.ts
Normal 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);
|
||||
}
|
||||
9
apps/orchestrator/src/domains/orchestrate/data.ts
Normal file
9
apps/orchestrator/src/domains/orchestrate/data.ts
Normal 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>;
|
||||
30
apps/orchestrator/src/domains/orchestrate/router.ts
Normal file
30
apps/orchestrator/src/domains/orchestrate/router.ts
Normal 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));
|
||||
},
|
||||
);
|
||||
134
apps/orchestrator/src/domains/orchestrate/service.ts
Normal file
134
apps/orchestrator/src/domains/orchestrate/service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,15 @@ import "./instrumentation.js";
|
||||
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
|
||||
import { serve } from "@hono/node-server";
|
||||
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 port = Number(process.env.PORT || "3000");
|
||||
@@ -17,6 +24,10 @@ app.get("/ping", (c) => {
|
||||
return c.text("pong");
|
||||
});
|
||||
|
||||
app.use("/internal/*", requireInternalApiKey);
|
||||
app.route("/internal", commandRouter);
|
||||
app.route("/internal", orchestrateRouter);
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -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`
|
||||
- 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
|
||||
|
||||
### 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`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { errAsync, ResultAsync } from "neverthrow";
|
||||
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 { CreateDevice, Device, DeviceStatus, UpdateDevice } from "./data";
|
||||
import { DeviceRepository } from "./repository";
|
||||
@@ -46,14 +46,20 @@ export class DeviceController {
|
||||
* Only succeeds if the device is currently online.
|
||||
*/
|
||||
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
|
||||
return this.repo.getById(fctx, id).andThen((dev) => {
|
||||
if (dev.status !== DeviceStatus.ONLINE || dev.inUse) {
|
||||
return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
|
||||
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) {
|
||||
return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
|
||||
}
|
||||
return errAsync(error);
|
||||
})
|
||||
.orElse(() => errAsync(error));
|
||||
}
|
||||
return this.repo.update(fctx, id, {
|
||||
status: DeviceStatus.BUSY,
|
||||
inUse: true,
|
||||
});
|
||||
|
||||
return errAsync(error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ResultAsync, errAsync, okAsync } from "neverthrow";
|
||||
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 { type Err } from "@pkg/result";
|
||||
import { logger } from "@pkg/logger";
|
||||
@@ -135,4 +135,40 @@ export class DeviceRepository {
|
||||
): ResultAsync<Device, Err> {
|
||||
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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user