feat: installed apps, stop goal, auth fixes, remote commands
- Android: fetch installed apps via PackageManager, send to server on connect - Android: add QUERY_ALL_PACKAGES permission for full app visibility - Android: fix duplicate Intent import, increase accessibility retry window - Android: default server URL to ws:// instead of wss:// - Server: store installed apps in device metadata JSONB - Server: inject installed apps context into LLM prompt - Server: preprocessor resolves app names from device's actual installed apps - Server: add POST /goals/stop endpoint with AbortController cancellation - Server: rewrite session middleware to direct DB token lookup - Server: goals route fetches user's saved LLM config from DB - Web: show installed apps in device detail Overview tab with search - Web: add Stop button for running goals - Web: replace API routes with remote commands (submitGoal, stopGoal) - Web: add error display for goal submission failures - Shared: add InstalledApp type and apps message to protocol
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".DroidClawApp"
|
android:name=".DroidClawApp"
|
||||||
|
|||||||
@@ -41,12 +41,21 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getScreenTree(): List<UIElement> {
|
fun getScreenTree(): List<UIElement> {
|
||||||
val delays = longArrayOf(50, 100, 200)
|
// Retry with increasing delays — apps like Contacts on Vivo
|
||||||
|
// can take 500ms+ to render after a cold launch
|
||||||
|
val delays = longArrayOf(50, 100, 200, 300, 500)
|
||||||
for (delayMs in delays) {
|
for (delayMs in delays) {
|
||||||
val root = rootInActiveWindow
|
val root = rootInActiveWindow
|
||||||
if (root != null) {
|
if (root != null) {
|
||||||
try {
|
try {
|
||||||
val elements = ScreenTreeBuilder.capture(root)
|
val elements = ScreenTreeBuilder.capture(root)
|
||||||
|
// If we got a root but zero elements, the app may still be loading.
|
||||||
|
// Retry unless this is the last attempt.
|
||||||
|
if (elements.isEmpty() && delayMs < delays.last()) {
|
||||||
|
root.recycle()
|
||||||
|
runBlocking { delay(delayMs) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
lastScreenTree.value = elements
|
lastScreenTree.value = elements
|
||||||
return elements
|
return elements
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,7 +64,7 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
runBlocking { delay(delayMs) }
|
runBlocking { delay(delayMs) }
|
||||||
}
|
}
|
||||||
Log.w(TAG, "rootInActiveWindow null after retries")
|
Log.w(TAG, "rootInActiveWindow null or empty after retries")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import com.thisux.droidclaw.model.GoalMessage
|
|||||||
import com.thisux.droidclaw.model.GoalStatus
|
import com.thisux.droidclaw.model.GoalStatus
|
||||||
import com.thisux.droidclaw.model.AgentStep
|
import com.thisux.droidclaw.model.AgentStep
|
||||||
import com.thisux.droidclaw.model.HeartbeatMessage
|
import com.thisux.droidclaw.model.HeartbeatMessage
|
||||||
|
import com.thisux.droidclaw.model.AppsMessage
|
||||||
|
import com.thisux.droidclaw.model.InstalledAppInfo
|
||||||
import com.thisux.droidclaw.util.DeviceInfoHelper
|
import com.thisux.droidclaw.util.DeviceInfoHelper
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -125,6 +128,12 @@ class ConnectionService : LifecycleService() {
|
|||||||
ConnectionState.Disconnected -> "Disconnected"
|
ConnectionState.Disconnected -> "Disconnected"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// Send installed apps list once connected
|
||||||
|
if (state == ConnectionState.Connected) {
|
||||||
|
val apps = getInstalledApps()
|
||||||
|
webSocket?.sendTyped(AppsMessage(apps = apps))
|
||||||
|
Log.i(TAG, "Sent ${apps.size} installed apps to server")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch { ws.errorMessage.collect { errorMessage.value = it } }
|
launch { ws.errorMessage.collect { errorMessage.value = it } }
|
||||||
@@ -179,6 +188,17 @@ class ConnectionService : LifecycleService() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getInstalledApps(): List<InstalledAppInfo> {
|
||||||
|
val pm = packageManager
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
val activities = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
|
||||||
|
return activities.mapNotNull { resolveInfo ->
|
||||||
|
val pkg = resolveInfo.activityInfo.packageName
|
||||||
|
val label = resolveInfo.loadLabel(pm).toString()
|
||||||
|
InstalledAppInfo(packageName = pkg, label = label)
|
||||||
|
}.distinctBy { it.packageName }.sortedBy { it.label.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
prefs[SettingsKeys.SERVER_URL] ?: "wss://localhost:8080"
|
prefs[SettingsKeys.SERVER_URL] ?: "ws://localhost:8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
val deviceName: Flow<String> = context.dataStore.data.map { prefs ->
|
val deviceName: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ data class HeartbeatMessage(
|
|||||||
val isCharging: Boolean
|
val isCharging: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class InstalledAppInfo(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppsMessage(
|
||||||
|
val type: String = "apps",
|
||||||
|
val apps: List<InstalledAppInfo>
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ServerMessage(
|
data class ServerMessage(
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fun SettingsScreen() {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val apiKey by app.settingsStore.apiKey.collectAsState(initial = "")
|
val apiKey by app.settingsStore.apiKey.collectAsState(initial = "")
|
||||||
val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "wss://localhost:8080")
|
val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "ws://localhost:8080")
|
||||||
|
|
||||||
var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) }
|
var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) }
|
||||||
var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }
|
var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UIElement, DeviceInfo } from "./types.js";
|
import type { UIElement, DeviceInfo, InstalledApp } from "./types.js";
|
||||||
|
|
||||||
export type DeviceMessage =
|
export type DeviceMessage =
|
||||||
| { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo }
|
| { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo }
|
||||||
@@ -6,7 +6,8 @@ export type DeviceMessage =
|
|||||||
| { type: "result"; requestId: string; success: boolean; error?: string; data?: string }
|
| { type: "result"; requestId: string; success: boolean; error?: string; data?: string }
|
||||||
| { type: "goal"; text: string }
|
| { type: "goal"; text: string }
|
||||||
| { type: "pong" }
|
| { type: "pong" }
|
||||||
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean };
|
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean }
|
||||||
|
| { type: "apps"; apps: InstalledApp[] };
|
||||||
|
|
||||||
export type ServerToDeviceMessage =
|
export type ServerToDeviceMessage =
|
||||||
| { type: "auth_ok"; deviceId: string }
|
| { type: "auth_ok"; deviceId: string }
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface DeviceInfo {
|
|||||||
isCharging: boolean;
|
isCharging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstalledApp {
|
||||||
|
packageName: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScreenState {
|
export interface ScreenState {
|
||||||
elements: UIElement[];
|
elements: UIElement[];
|
||||||
screenshot?: string;
|
screenshot?: string;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from "./llm.js";
|
} from "./llm.js";
|
||||||
import { createStuckDetector } from "./stuck.js";
|
import { createStuckDetector } from "./stuck.js";
|
||||||
import { db } from "../db.js";
|
import { db } from "../db.js";
|
||||||
import { agentSession, agentStep } from "../schema.js";
|
import { agentSession, agentStep, device as deviceTable } from "../schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { UIElement, ActionDecision } from "@droidclaw/shared";
|
import type { UIElement, ActionDecision } from "@droidclaw/shared";
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ export interface AgentLoopOptions {
|
|||||||
originalGoal?: string;
|
originalGoal?: string;
|
||||||
llmConfig: LLMConfig;
|
llmConfig: LLMConfig;
|
||||||
maxSteps?: number;
|
maxSteps?: number;
|
||||||
|
/** Abort signal for cancellation */
|
||||||
|
signal?: AbortSignal;
|
||||||
onStep?: (step: AgentStep) => void;
|
onStep?: (step: AgentStep) => void;
|
||||||
onComplete?: (result: AgentResult) => void;
|
onComplete?: (result: AgentResult) => void;
|
||||||
}
|
}
|
||||||
@@ -224,6 +226,7 @@ export async function runAgentLoop(
|
|||||||
originalGoal,
|
originalGoal,
|
||||||
llmConfig,
|
llmConfig,
|
||||||
maxSteps = 30,
|
maxSteps = 30,
|
||||||
|
signal,
|
||||||
onStep,
|
onStep,
|
||||||
onComplete,
|
onComplete,
|
||||||
} = options;
|
} = options;
|
||||||
@@ -239,6 +242,28 @@ export async function runAgentLoop(
|
|||||||
const recentActions: string[] = [];
|
const recentActions: string[] = [];
|
||||||
let lastActionFeedback = "";
|
let lastActionFeedback = "";
|
||||||
|
|
||||||
|
// Fetch installed apps from device metadata for LLM context
|
||||||
|
let installedAppsContext = "";
|
||||||
|
if (persistentDeviceId) {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({ info: deviceTable.deviceInfo })
|
||||||
|
.from(deviceTable)
|
||||||
|
.where(eq(deviceTable.id, persistentDeviceId))
|
||||||
|
.limit(1);
|
||||||
|
const info = rows[0]?.info as Record<string, unknown> | null;
|
||||||
|
const apps = info?.installedApps as Array<{ packageName: string; label: string }> | undefined;
|
||||||
|
if (apps && apps.length > 0) {
|
||||||
|
installedAppsContext =
|
||||||
|
`\nINSTALLED_APPS (use exact packageName for "launch" action):\n` +
|
||||||
|
apps.map((a) => ` ${a.label}: ${a.packageName}`).join("\n") +
|
||||||
|
"\n";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — continue without apps context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist session to DB
|
// Persist session to DB
|
||||||
if (persistentDeviceId) {
|
if (persistentDeviceId) {
|
||||||
try {
|
try {
|
||||||
@@ -268,6 +293,12 @@ export async function runAgentLoop(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (let step = 0; step < maxSteps; step++) {
|
for (let step = 0; step < maxSteps; step++) {
|
||||||
|
// Check for cancellation
|
||||||
|
if (signal?.aborted) {
|
||||||
|
console.log(`[Agent ${sessionId}] Stopped by user at step ${step + 1}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
stepsUsed = step + 1;
|
stepsUsed = step + 1;
|
||||||
|
|
||||||
// ── 1. Get screen state from device ─────────────────────
|
// ── 1. Get screen state from device ─────────────────────
|
||||||
@@ -371,6 +402,7 @@ export async function runAgentLoop(
|
|||||||
let userPrompt =
|
let userPrompt =
|
||||||
`GOAL: ${goal}\n\n` +
|
`GOAL: ${goal}\n\n` +
|
||||||
`STEP: ${step + 1}/${maxSteps}\n\n` +
|
`STEP: ${step + 1}/${maxSteps}\n\n` +
|
||||||
|
installedAppsContext +
|
||||||
foregroundLine +
|
foregroundLine +
|
||||||
actionFeedbackLine +
|
actionFeedbackLine +
|
||||||
`SCREEN_CONTEXT:\n${JSON.stringify(elements, null, 2)}` +
|
`SCREEN_CONTEXT:\n${JSON.stringify(elements, null, 2)}` +
|
||||||
|
|||||||
@@ -8,10 +8,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { sessions } from "../ws/sessions.js";
|
import { sessions } from "../ws/sessions.js";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { device as deviceTable } from "../schema.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
// ─── App Name → Package Name Map ────────────────────────────
|
// ─── Installed App type ──────────────────────────────────────
|
||||||
|
|
||||||
const APP_PACKAGES: Record<string, string> = {
|
interface InstalledApp {
|
||||||
|
packageName: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fallback App Name → Package Name Map ────────────────────
|
||||||
|
// Used only when device has no installed apps data in DB.
|
||||||
|
|
||||||
|
const FALLBACK_PACKAGES: Record<string, string> = {
|
||||||
youtube: "com.google.android.youtube",
|
youtube: "com.google.android.youtube",
|
||||||
gmail: "com.google.android.gm",
|
gmail: "com.google.android.gm",
|
||||||
chrome: "com.android.chrome",
|
chrome: "com.android.chrome",
|
||||||
@@ -79,12 +90,32 @@ interface PreprocessResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to find a known app name at the start of a goal string.
|
* Build a label→packageName lookup from the device's installed apps.
|
||||||
|
* Keys are lowercase app labels (e.g. "youtube", "play store").
|
||||||
|
*/
|
||||||
|
function buildInstalledAppMap(apps: InstalledApp[]): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const app of apps) {
|
||||||
|
map[app.label.toLowerCase()] = app.packageName;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find an app name at the start of a goal string.
|
||||||
|
* Checks device's installed apps first, then falls back to hardcoded map.
|
||||||
* Returns the package name and remaining text, or null.
|
* Returns the package name and remaining text, or null.
|
||||||
*/
|
*/
|
||||||
function matchAppName(lower: string): { pkg: string; appName: string; rest: string } | null {
|
function matchAppName(
|
||||||
|
lower: string,
|
||||||
|
installedApps: InstalledApp[]
|
||||||
|
): { pkg: string; appName: string; rest: string } | null {
|
||||||
|
// Build combined lookup: installed apps take priority over fallback
|
||||||
|
const installedMap = buildInstalledAppMap(installedApps);
|
||||||
|
const combined: Record<string, string> = { ...FALLBACK_PACKAGES, ...installedMap };
|
||||||
|
|
||||||
// Try longest app names first (e.g. "google meet" before "meet")
|
// Try longest app names first (e.g. "google meet" before "meet")
|
||||||
const sorted = Object.keys(APP_PACKAGES).sort((a, b) => b.length - a.length);
|
const sorted = Object.keys(combined).sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
for (const name of sorted) {
|
for (const name of sorted) {
|
||||||
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
|
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
|
||||||
@@ -93,7 +124,7 @@ function matchAppName(lower: string): { pkg: string; appName: string; rest: stri
|
|||||||
);
|
);
|
||||||
const m = lower.match(pattern);
|
const m = lower.match(pattern);
|
||||||
if (m) {
|
if (m) {
|
||||||
return { pkg: APP_PACKAGES[name], appName: name, rest: m[1]?.trim() ?? "" };
|
return { pkg: combined[name], appName: name, rest: m[1]?.trim() ?? "" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -103,6 +134,23 @@ function escapeRegex(s: string): string {
|
|||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch installed apps from the device record in DB.
|
||||||
|
*/
|
||||||
|
async function fetchInstalledApps(persistentDeviceId: string): Promise<InstalledApp[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({ info: deviceTable.deviceInfo })
|
||||||
|
.from(deviceTable)
|
||||||
|
.where(eq(deviceTable.id, persistentDeviceId))
|
||||||
|
.limit(1);
|
||||||
|
const info = rows[0]?.info as Record<string, unknown> | null;
|
||||||
|
return (info?.installedApps as InstalledApp[]) ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to preprocess a goal before the LLM loop.
|
* Attempt to preprocess a goal before the LLM loop.
|
||||||
*
|
*
|
||||||
@@ -113,12 +161,16 @@ function escapeRegex(s: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function preprocessGoal(
|
export async function preprocessGoal(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
goal: string
|
goal: string,
|
||||||
|
persistentDeviceId?: string
|
||||||
): Promise<PreprocessResult> {
|
): Promise<PreprocessResult> {
|
||||||
const lower = goal.toLowerCase().trim();
|
const lower = goal.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Fetch device's actual installed apps for accurate package resolution
|
||||||
|
const installedApps = persistentDeviceId ? await fetchInstalledApps(persistentDeviceId) : [];
|
||||||
|
|
||||||
// ── Pattern: "open <app> [and <remaining>]" ───────────────
|
// ── Pattern: "open <app> [and <remaining>]" ───────────────
|
||||||
const appMatch = matchAppName(lower);
|
const appMatch = matchAppName(lower, installedApps);
|
||||||
|
|
||||||
if (appMatch) {
|
if (appMatch) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Context, Next } from "hono";
|
import type { Context, Next } from "hono";
|
||||||
import { auth } from "../auth.js";
|
import { db } from "../db.js";
|
||||||
|
import { session as sessionTable, user as userTable } from "../schema.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
|
|
||||||
/** Hono Env type for routes protected by sessionMiddleware */
|
/** Hono Env type for routes protected by sessionMiddleware */
|
||||||
export type AuthEnv = {
|
export type AuthEnv = {
|
||||||
@@ -10,15 +13,43 @@ export type AuthEnv = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function sessionMiddleware(c: Context, next: Next) {
|
export async function sessionMiddleware(c: Context, next: Next) {
|
||||||
const session = await auth.api.getSession({
|
// Extract session token from cookie (same approach as dashboard WS auth)
|
||||||
headers: c.req.raw.headers,
|
const rawCookie = getCookie(c, "better-auth.session_token");
|
||||||
});
|
if (!rawCookie) {
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
c.set("user", session.user);
|
// Token may have a signature appended after a dot — use only the token part
|
||||||
c.set("session", session.session);
|
const token = rawCookie.split(".")[0];
|
||||||
|
|
||||||
|
// Direct DB lookup (proven to work, unlike auth.api.getSession)
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
sessionId: sessionTable.id,
|
||||||
|
userId: sessionTable.userId,
|
||||||
|
})
|
||||||
|
.from(sessionTable)
|
||||||
|
.where(eq(sessionTable.token, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId, userId } = rows[0];
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const users = await db
|
||||||
|
.select({ id: userTable.id, name: userTable.name, email: userTable.email })
|
||||||
|
.from(userTable)
|
||||||
|
.where(eq(userTable.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", users[0]);
|
||||||
|
c.set("session", { id: sessionId, userId });
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||||
import { sessions } from "../ws/sessions.js";
|
import { sessions } from "../ws/sessions.js";
|
||||||
import { runAgentLoop, type AgentLoopOptions } from "../agent/loop.js";
|
import { runAgentLoop, type AgentLoopOptions } from "../agent/loop.js";
|
||||||
import type { LLMConfig } from "../agent/llm.js";
|
import type { LLMConfig } from "../agent/llm.js";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { llmConfig as llmConfigTable } from "../schema.js";
|
||||||
|
|
||||||
const goals = new Hono<AuthEnv>();
|
const goals = new Hono<AuthEnv>();
|
||||||
goals.use("*", sessionMiddleware);
|
goals.use("*", sessionMiddleware);
|
||||||
|
|
||||||
/** Track running agent sessions so we can prevent duplicates */
|
/** Track running agent sessions so we can prevent duplicates and cancel them */
|
||||||
const activeSessions = new Map<string, { sessionId: string; goal: string }>();
|
const activeSessions = new Map<string, { sessionId: string; goal: string; abort: AbortController }>();
|
||||||
|
|
||||||
goals.post("/", async (c) => {
|
goals.post("/", async (c) => {
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
@@ -46,15 +49,39 @@ goals.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build LLM config from request body or environment defaults
|
// Build LLM config: request body → user's DB config → env defaults
|
||||||
const llmConfig: LLMConfig = {
|
let llmCfg: LLMConfig;
|
||||||
provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai",
|
|
||||||
apiKey: body.llmApiKey ?? process.env.LLM_API_KEY ?? "",
|
|
||||||
model: body.llmModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!llmConfig.apiKey) {
|
if (body.llmApiKey) {
|
||||||
return c.json({ error: "LLM API key is required (provide llmApiKey or set LLM_API_KEY env var)" }, 400);
|
llmCfg = {
|
||||||
|
provider: body.llmProvider ?? process.env.LLM_PROVIDER ?? "openai",
|
||||||
|
apiKey: body.llmApiKey,
|
||||||
|
model: body.llmModel,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Fetch user's saved LLM config from DB (same as device WS handler)
|
||||||
|
const configs = await db
|
||||||
|
.select()
|
||||||
|
.from(llmConfigTable)
|
||||||
|
.where(eq(llmConfigTable.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (configs.length > 0) {
|
||||||
|
const cfg = configs[0];
|
||||||
|
llmCfg = {
|
||||||
|
provider: cfg.provider,
|
||||||
|
apiKey: cfg.apiKey,
|
||||||
|
model: body.llmModel ?? cfg.model ?? undefined,
|
||||||
|
};
|
||||||
|
} else if (process.env.LLM_API_KEY) {
|
||||||
|
llmCfg = {
|
||||||
|
provider: process.env.LLM_PROVIDER ?? "openai",
|
||||||
|
apiKey: process.env.LLM_API_KEY,
|
||||||
|
model: body.llmModel,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "No LLM provider configured. Set it up in the web dashboard Settings." }, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const options: AgentLoopOptions = {
|
const options: AgentLoopOptions = {
|
||||||
@@ -62,16 +89,20 @@ goals.post("/", async (c) => {
|
|||||||
persistentDeviceId: device.persistentDeviceId,
|
persistentDeviceId: device.persistentDeviceId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
goal: body.goal,
|
goal: body.goal,
|
||||||
llmConfig,
|
llmConfig: llmCfg,
|
||||||
maxSteps: body.maxSteps,
|
maxSteps: body.maxSteps,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create abort controller for this session
|
||||||
|
const abort = new AbortController();
|
||||||
|
options.signal = abort.signal;
|
||||||
|
|
||||||
// Start the agent loop in the background (fire-and-forget).
|
// Start the agent loop in the background (fire-and-forget).
|
||||||
// The client observes progress via the /ws/dashboard WebSocket.
|
// The client observes progress via the /ws/dashboard WebSocket.
|
||||||
const loopPromise = runAgentLoop(options);
|
const loopPromise = runAgentLoop(options);
|
||||||
|
|
||||||
// Track as active until it completes
|
// Track as active until it completes
|
||||||
const sessionPlaceholder = { sessionId: "pending", goal: body.goal };
|
const sessionPlaceholder = { sessionId: "pending", goal: body.goal, abort };
|
||||||
activeSessions.set(trackingKey, sessionPlaceholder);
|
activeSessions.set(trackingKey, sessionPlaceholder);
|
||||||
|
|
||||||
loopPromise
|
loopPromise
|
||||||
@@ -96,4 +127,33 @@ goals.post("/", async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
goals.post("/stop", async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const body = await c.req.json<{ deviceId: string }>();
|
||||||
|
|
||||||
|
if (!body.deviceId) {
|
||||||
|
return c.json({ error: "deviceId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up device to verify ownership
|
||||||
|
const device = sessions.getDevice(body.deviceId)
|
||||||
|
?? sessions.getDeviceByPersistentId(body.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return c.json({ error: "device not connected" }, 404);
|
||||||
|
}
|
||||||
|
if (device.userId !== user.id) {
|
||||||
|
return c.json({ error: "device does not belong to you" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingKey = device.persistentDeviceId ?? device.deviceId;
|
||||||
|
const active = activeSessions.get(trackingKey);
|
||||||
|
if (!active) {
|
||||||
|
return c.json({ error: "no agent running on this device" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
active.abort.abort();
|
||||||
|
console.log(`[Agent] Stop requested for device ${body.deviceId}`);
|
||||||
|
return c.json({ status: "stopping" });
|
||||||
|
});
|
||||||
|
|
||||||
export { goals };
|
export { goals };
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export async function handleDeviceMessage(
|
|||||||
// Preprocess: handle simple goals directly, or extract "open X" prefix
|
// Preprocess: handle simple goals directly, or extract "open X" prefix
|
||||||
let effectiveGoal = goal;
|
let effectiveGoal = goal;
|
||||||
try {
|
try {
|
||||||
const preResult = await preprocessGoal(deviceId, goal);
|
const preResult = await preprocessGoal(deviceId, goal, persistentDeviceId);
|
||||||
if (preResult.handled) {
|
if (preResult.handled) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
@@ -328,6 +328,26 @@ export async function handleDeviceMessage(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "apps": {
|
||||||
|
const persistentDeviceId = ws.data.persistentDeviceId;
|
||||||
|
if (persistentDeviceId) {
|
||||||
|
const apps = (msg as unknown as { apps: Array<{ packageName: string; label: string }> }).apps;
|
||||||
|
// Merge apps into existing deviceInfo
|
||||||
|
db.update(device)
|
||||||
|
.set({
|
||||||
|
deviceInfo: {
|
||||||
|
...(await db.select({ info: device.deviceInfo }).from(device).where(eq(device.id, persistentDeviceId)).limit(1).then(r => (r[0]?.info as Record<string, unknown>) ?? {})),
|
||||||
|
installedApps: apps,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(device.id, persistentDeviceId))
|
||||||
|
.catch((err) => console.error(`[DB] Failed to store installed apps: ${err}`));
|
||||||
|
|
||||||
|
console.log(`[Device] Received ${apps.length} installed apps for device ${persistentDeviceId}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "heartbeat": {
|
case "heartbeat": {
|
||||||
const persistentDeviceId = ws.data.persistentDeviceId;
|
const persistentDeviceId = ws.data.persistentDeviceId;
|
||||||
const userId = ws.data.userId;
|
const userId = ws.data.userId;
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { building } from '$app/environment';
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const session = await auth.api.getSession({
|
try {
|
||||||
headers: event.request.headers
|
const session = await auth.api.getSession({
|
||||||
});
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
event.locals.session = session.session;
|
event.locals.session = session.session;
|
||||||
event.locals.user = session.user;
|
event.locals.user = session.user;
|
||||||
|
} else if (event.url.pathname.startsWith('/api/')) {
|
||||||
|
console.log(`[Auth] No session for ${event.request.method} ${event.url.pathname}`);
|
||||||
|
console.log(`[Auth] Cookie header: ${event.request.headers.get('cookie')?.slice(0, 80) ?? 'NONE'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Auth] getSession error for ${event.request.method} ${event.url.pathname}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth, building });
|
return svelteKitHandler({ event, resolve, auth, building });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { query, getRequestEvent } from '$app/server';
|
import { query, command, getRequestEvent } from '$app/server';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { device, agentSession, agentStep } from '$lib/server/db/schema';
|
import { device, agentSession, agentStep } from '$lib/server/db/schema';
|
||||||
import { eq, desc, and, count, avg, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, count, avg, sql, inArray } from 'drizzle-orm';
|
||||||
@@ -85,7 +86,8 @@ export const getDevice = query(v.string(), async (deviceId) => {
|
|||||||
screenHeight: (info?.screenHeight as number) ?? null,
|
screenHeight: (info?.screenHeight as number) ?? null,
|
||||||
batteryLevel: (info?.batteryLevel as number) ?? null,
|
batteryLevel: (info?.batteryLevel as number) ?? null,
|
||||||
isCharging: (info?.isCharging as boolean) ?? false,
|
isCharging: (info?.isCharging as boolean) ?? false,
|
||||||
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString()
|
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString(),
|
||||||
|
installedApps: (info?.installedApps as Array<{ packageName: string; label: string }>) ?? []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,3 +157,37 @@ export const listSessionSteps = query(
|
|||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Commands (write operations) ─────────────────────────────
|
||||||
|
|
||||||
|
const SERVER_URL = () => env.SERVER_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
/** Forward a request to the DroidClaw server with auth cookies */
|
||||||
|
async function serverFetch(path: string, body: Record<string, unknown>) {
|
||||||
|
const { request } = getRequestEvent();
|
||||||
|
const res = await fetch(`${SERVER_URL()}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
cookie: request.headers.get('cookie') ?? ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `Error ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const submitGoal = command(
|
||||||
|
v.object({ deviceId: v.string(), goal: v.string() }),
|
||||||
|
async ({ deviceId, goal }) => {
|
||||||
|
return serverFetch('/goals', { deviceId, goal });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const stopGoal = command(
|
||||||
|
v.object({ deviceId: v.string() }),
|
||||||
|
async ({ deviceId }) => {
|
||||||
|
return serverFetch('/goals/stop', { deviceId });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import type { RequestHandler } from './$types';
|
|
||||||
|
|
||||||
const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
|
||||||
if (!locals.user) {
|
|
||||||
return error(401, 'Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${SERVER_URL}/goals`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
cookie: request.headers.get('cookie') ?? ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
|
||||||
return error(res.status, err.error ?? 'Failed to submit goal');
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(await res.json());
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
getDevice,
|
getDevice,
|
||||||
listDeviceSessions,
|
listDeviceSessions,
|
||||||
listSessionSteps,
|
listSessionSteps,
|
||||||
getDeviceStats
|
getDeviceStats,
|
||||||
|
submitGoal as submitGoalCmd,
|
||||||
|
stopGoal as stopGoalCmd
|
||||||
} from '$lib/api/devices.remote';
|
} from '$lib/api/devices.remote';
|
||||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
batteryLevel: number | null;
|
batteryLevel: number | null;
|
||||||
isCharging: boolean;
|
isCharging: boolean;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
installedApps: Array<{ packageName: string; label: string }>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
// Device stats
|
// Device stats
|
||||||
@@ -80,23 +83,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let runError = $state('');
|
||||||
|
|
||||||
async function submitGoal() {
|
async function submitGoal() {
|
||||||
if (!goal.trim()) return;
|
if (!goal.trim()) return;
|
||||||
runStatus = 'running';
|
runStatus = 'running';
|
||||||
|
runError = '';
|
||||||
currentGoal = goal;
|
currentGoal = goal;
|
||||||
steps = [];
|
steps = [];
|
||||||
|
|
||||||
const res = await fetch('/api/goals', {
|
try {
|
||||||
method: 'POST',
|
await submitGoalCmd({ deviceId, goal });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} catch (e: any) {
|
||||||
body: JSON.stringify({ deviceId, goal })
|
runError = e.message ?? String(e);
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
runStatus = 'failed';
|
runStatus = 'failed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopGoal() {
|
||||||
|
try {
|
||||||
|
await stopGoalCmd({ deviceId });
|
||||||
|
runStatus = 'failed';
|
||||||
|
runError = 'Stopped by user';
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const unsub = dashboardWs.subscribe((msg) => {
|
const unsub = dashboardWs.subscribe((msg) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@@ -159,6 +172,16 @@
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let appSearch = $state('');
|
||||||
|
const filteredApps = $derived(
|
||||||
|
(deviceData?.installedApps ?? []).filter(
|
||||||
|
(a) =>
|
||||||
|
!appSearch ||
|
||||||
|
a.label.toLowerCase().includes(appSearch.toLowerCase()) ||
|
||||||
|
a.packageName.toLowerCase().includes(appSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const battery = $derived(liveBattery ?? (deviceData?.batteryLevel as number | null));
|
const battery = $derived(liveBattery ?? (deviceData?.batteryLevel as number | null));
|
||||||
const charging = $derived(liveCharging || (deviceData?.isCharging as boolean));
|
const charging = $derived(liveCharging || (deviceData?.isCharging as boolean));
|
||||||
</script>
|
</script>
|
||||||
@@ -279,6 +302,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Installed Apps -->
|
||||||
|
{#if deviceData && deviceData.installedApps.length > 0}
|
||||||
|
<div class="mt-4 rounded-lg border border-neutral-200">
|
||||||
|
<div class="flex items-center justify-between border-b border-neutral-100 px-5 py-3">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Installed Apps
|
||||||
|
<span class="ml-1 font-normal normal-case text-neutral-400">({deviceData.installedApps.length})</span>
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={appSearch}
|
||||||
|
placeholder="Search apps..."
|
||||||
|
class="w-48 rounded border border-neutral-200 px-2.5 py-1 text-xs focus:border-neutral-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-72 overflow-y-auto">
|
||||||
|
{#each filteredApps as app (app.packageName)}
|
||||||
|
<div class="flex items-center justify-between px-5 py-2 text-sm hover:bg-neutral-50">
|
||||||
|
<span class="font-medium">{app.label}</span>
|
||||||
|
<span class="font-mono text-xs text-neutral-400">{app.packageName}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="px-5 py-3 text-xs text-neutral-400">No apps match "{appSearch}"</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Sessions Tab -->
|
<!-- Sessions Tab -->
|
||||||
{:else if activeTab === 'sessions'}
|
{:else if activeTab === 'sessions'}
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
@@ -359,13 +410,21 @@
|
|||||||
disabled={runStatus === 'running'}
|
disabled={runStatus === 'running'}
|
||||||
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
||||||
/>
|
/>
|
||||||
<button
|
{#if runStatus === 'running'}
|
||||||
onclick={submitGoal}
|
<button
|
||||||
disabled={runStatus === 'running'}
|
onclick={stopGoal}
|
||||||
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700 disabled:opacity-50"
|
class="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-500"
|
||||||
>
|
>
|
||||||
{runStatus === 'running' ? 'Running...' : 'Run'}
|
Stop
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={submitGoal}
|
||||||
|
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -391,6 +450,11 @@
|
|||||||
<span class="text-xs text-red-600">Failed</span>
|
<span class="text-xs text-red-600">Failed</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if runError}
|
||||||
|
<div class="border-t border-red-100 bg-red-50 px-5 py-3 text-xs text-red-700">
|
||||||
|
{runError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if steps.length > 0}
|
{#if steps.length > 0}
|
||||||
<div class="divide-y divide-neutral-100">
|
<div class="divide-y divide-neutral-100">
|
||||||
{#each steps as s (s.step)}
|
{#each steps as s (s.step)}
|
||||||
|
|||||||
Reference in New Issue
Block a user