revert: remove workflow automation, keep overlay and stop_goal

Remove all workflow-related code from PR #6 (input classifier,
workflow parser, notification listener, workflow CRUD handlers).
Keep the overlay, stop_goal, AbortSignal threading, and OkHttp
engine switch. Add v2 design doc for safer workflow implementation.
This commit is contained in:
Sanju Sivalingam
2026-02-18 20:39:04 +05:30
parent d8659cfe17
commit 59ee665088
17 changed files with 125 additions and 904 deletions

View File

@@ -1,54 +0,0 @@
/**
* Classifies user input as either an immediate goal or a workflow (automation rule).
*
* Uses the user's LLM to determine intent. Workflows describe recurring
* automations ("when X happens, do Y"), goals are one-time tasks ("open WhatsApp").
*/
import type { LLMConfig } from "./llm.js";
import { getLlmProvider, parseJsonResponse } from "./llm.js";
export type InputType = "goal" | "workflow";
export interface ClassificationResult {
type: InputType;
}
const CLASSIFIER_PROMPT = `You classify user input for an Android automation agent.
Decide if the input is:
- "goal": A one-time task to execute right now (e.g. "open WhatsApp", "search for pizza", "take a screenshot", "reply to John with hello")
- "workflow": An automation rule that should be saved and triggered later when a condition is met (e.g. "when I get a notification from WhatsApp saying where are you, reply with Bangalore", "whenever someone messages me on Telegram, auto-reply with I'm busy", "reply to all notifications that have a reply button")
Key signals for "workflow":
- Uses words like "when", "whenever", "if", "every time", "automatically", "always"
- Describes a trigger condition + a response action
- Refers to future/recurring events
Key signals for "goal":
- Describes a single task to do now
- Imperative commands ("open", "send", "search", "go to")
- No conditional/temporal trigger
Respond with ONLY: {"type": "goal"} or {"type": "workflow"}`;
export async function classifyInput(
text: string,
llmConfig: LLMConfig
): Promise<ClassificationResult> {
const provider = getLlmProvider(llmConfig);
try {
const raw = await provider.getAction(CLASSIFIER_PROMPT, text);
const parsed = parseJsonResponse(raw);
if (parsed?.type === "workflow") {
return { type: "workflow" };
}
} catch (err) {
console.error(`[Classifier] Failed to classify input, defaulting to goal:`, err);
}
// Default to goal — safer to execute once than to accidentally create a rule
return { type: "goal" };
}

View File

@@ -1,75 +0,0 @@
/**
* Parses a natural-language workflow description into structured
* trigger conditions and a goal template using the user's LLM.
*/
import type { LLMConfig } from "./llm.js";
import { getLlmProvider, parseJsonResponse } from "./llm.js";
export interface ParsedWorkflow {
name: string;
triggerType: "notification";
conditions: Array<{
field: "app_package" | "title" | "text";
matchMode: "contains" | "exact" | "regex";
value: string;
}>;
goalTemplate: string;
}
const PARSER_PROMPT = `You are a workflow parser for an Android automation agent.
The user describes an automation rule in plain English. Parse it into a structured workflow.
A workflow has:
1. **name**: A short human-readable name (3-6 words).
2. **triggerType**: Always "notification" for now.
3. **conditions**: An array of matching rules for incoming notifications. Each condition has:
- "field": one of "app_package", "title", or "text"
- "matchMode": one of "contains", "exact", or "regex"
- "value": the string or regex to match
4. **goalTemplate**: The goal string to send to the agent when triggered. Use {{title}}, {{text}}, {{app}} as placeholders that get filled from the notification.
Example input: "When I get a WhatsApp message saying 'where are you', reply with 'Bangalore'"
Example output:
{
"name": "Auto-reply where are you",
"triggerType": "notification",
"conditions": [
{"field": "app_package", "matchMode": "contains", "value": "whatsapp"},
{"field": "text", "matchMode": "contains", "value": "where are you"}
],
"goalTemplate": "Open the WhatsApp notification from {{title}} and reply with 'Bangalore'"
}
Example input: "Reply to all notifications that have a reply button with 'I am busy'"
Example output:
{
"name": "Auto-reply I am busy",
"triggerType": "notification",
"conditions": [],
"goalTemplate": "Open the notification '{{title}}' from {{app}} and reply with 'I am busy'"
}
Respond with ONLY a valid JSON object. No explanation.`;
export async function parseWorkflowDescription(
description: string,
llmConfig: LLMConfig
): Promise<ParsedWorkflow> {
const provider = getLlmProvider(llmConfig);
const raw = await provider.getAction(PARSER_PROMPT, description);
const parsed = parseJsonResponse(raw);
if (!parsed || !parsed.name || !parsed.goalTemplate) {
throw new Error("Failed to parse workflow description into structured format");
}
return {
name: parsed.name as string,
triggerType: "notification",
conditions: (parsed.conditions as ParsedWorkflow["conditions"]) ?? [],
goalTemplate: parsed.goalTemplate as string,
};
}

View File

@@ -128,24 +128,6 @@ export const agentSession = pgTable("agent_session", {
completedAt: timestamp("completed_at"),
});
export const workflow = pgTable("workflow", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description").notNull(),
triggerType: text("trigger_type").notNull().default("notification"),
conditions: jsonb("conditions").notNull().default("[]"),
goalTemplate: text("goal_template").notNull(),
enabled: boolean("enabled").default(true).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const agentStep = pgTable("agent_step", {
id: text("id").primaryKey(),
sessionId: text("session_id")

View File

@@ -6,14 +6,6 @@ import { apikey, llmConfig, device } from "../schema.js";
import { sessions, type WebSocketData } from "./sessions.js";
import { runPipeline } from "../agent/pipeline.js";
import type { LLMConfig } from "../agent/llm.js";
import {
handleWorkflowCreate,
handleWorkflowUpdate,
handleWorkflowDelete,
handleWorkflowSync,
handleWorkflowTrigger,
} from "./workflow-handlers.js";
import { classifyInput } from "../agent/input-classifier.js";
/**
* Hash an API key the same way better-auth does:
@@ -259,20 +251,6 @@ export async function handleDeviceMessage(
break;
}
// Classify: is this an immediate goal or a workflow?
try {
const classification = await classifyInput(goal, userLlmConfig);
if (classification.type === "workflow") {
console.log(`[Classifier] Input classified as workflow: ${goal}`);
handleWorkflowCreate(ws, goal).catch((err) =>
console.error(`[Workflow] Auto-create error:`, err)
);
break;
}
} catch (err) {
console.warn(`[Classifier] Classification failed, treating as goal:`, err);
}
console.log(`[Pipeline] Starting goal for device ${deviceId}: ${goal}`);
const abortController = new AbortController();
activeSessions.set(deviceId, { goal, abort: abortController });
@@ -383,59 +361,6 @@ export async function handleDeviceMessage(
break;
}
case "workflow_create": {
const description = (msg as unknown as { description: string }).description;
if (description) {
handleWorkflowCreate(ws, description).catch((err) =>
console.error(`[Workflow] Create error:`, err)
);
}
break;
}
case "workflow_update": {
const { workflowId, enabled } = msg as unknown as { workflowId: string; enabled?: boolean };
if (workflowId) {
handleWorkflowUpdate(ws, workflowId, enabled).catch((err) =>
console.error(`[Workflow] Update error:`, err)
);
}
break;
}
case "workflow_delete": {
const { workflowId } = msg as unknown as { workflowId: string };
if (workflowId) {
handleWorkflowDelete(ws, workflowId).catch((err) =>
console.error(`[Workflow] Delete error:`, err)
);
}
break;
}
case "workflow_sync": {
handleWorkflowSync(ws).catch((err) =>
console.error(`[Workflow] Sync error:`, err)
);
break;
}
case "workflow_trigger": {
const { workflowId, notificationApp, notificationTitle, notificationText } =
msg as unknown as {
workflowId: string;
notificationApp?: string;
notificationTitle?: string;
notificationText?: string;
};
if (workflowId) {
handleWorkflowTrigger(ws, workflowId, notificationApp, notificationTitle, notificationText).catch(
(err) => console.error(`[Workflow] Trigger error:`, err)
);
}
break;
}
default: {
console.warn(
`Unknown message type from device ${ws.data.deviceId}:`,

View File

@@ -1,229 +0,0 @@
/**
* Server-side handlers for workflow CRUD and trigger messages
* from the Android device WebSocket.
*/
import type { ServerWebSocket } from "bun";
import { eq, and } from "drizzle-orm";
import { db } from "../db.js";
import { workflow, llmConfig } from "../schema.js";
import { parseWorkflowDescription } from "../agent/workflow-parser.js";
import type { LLMConfig } from "../agent/llm.js";
import type { WebSocketData } from "./sessions.js";
function sendToDevice(ws: ServerWebSocket<WebSocketData>, msg: Record<string, unknown>) {
try {
ws.send(JSON.stringify(msg));
} catch {
// device disconnected
}
}
async function getUserLlmConfig(userId: string): Promise<LLMConfig | null> {
const configs = await db
.select()
.from(llmConfig)
.where(eq(llmConfig.userId, userId))
.limit(1);
if (configs.length === 0) return null;
const cfg = configs[0];
return {
provider: cfg.provider,
apiKey: cfg.apiKey,
model: cfg.model ?? undefined,
};
}
function workflowToJson(wf: typeof workflow.$inferSelect): string {
return JSON.stringify({
id: wf.id,
name: wf.name,
description: wf.description,
triggerType: wf.triggerType,
conditions: wf.conditions,
goalTemplate: wf.goalTemplate,
enabled: wf.enabled,
createdAt: new Date(wf.createdAt).getTime(),
});
}
export async function handleWorkflowCreate(
ws: ServerWebSocket<WebSocketData>,
description: string
): Promise<void> {
const userId = ws.data.userId!;
const userLlm = await getUserLlmConfig(userId);
if (!userLlm) {
sendToDevice(ws, {
type: "error",
message: "No LLM provider configured. Set it up in the web dashboard.",
});
return;
}
try {
const parsed = await parseWorkflowDescription(description, userLlm);
// Validate regexes before persisting
for (const cond of parsed.conditions) {
if (cond.matchMode === "regex") {
try {
new RegExp(cond.value, "i");
} catch {
throw new Error(`Invalid regex in condition: ${cond.value}`);
}
}
}
const id = crypto.randomUUID();
const now = new Date();
await db.insert(workflow).values({
id,
userId,
name: parsed.name,
description,
triggerType: parsed.triggerType,
conditions: parsed.conditions,
goalTemplate: parsed.goalTemplate,
enabled: true,
createdAt: now,
updatedAt: now,
});
const inserted = await db
.select()
.from(workflow)
.where(eq(workflow.id, id))
.limit(1);
if (inserted.length > 0) {
sendToDevice(ws, {
type: "workflow_created",
workflowId: id,
workflowJson: workflowToJson(inserted[0]),
});
}
console.log(`[Workflow] Created '${parsed.name}' for user ${userId}`);
} catch (err) {
console.error(`[Workflow] Failed to create workflow:`, err);
sendToDevice(ws, {
type: "error",
message: `Failed to parse workflow: ${err}`,
});
}
}
export async function handleWorkflowUpdate(
ws: ServerWebSocket<WebSocketData>,
workflowId: string,
enabled?: boolean
): Promise<void> {
const userId = ws.data.userId!;
const updates: Record<string, unknown> = {};
if (enabled !== undefined) updates.enabled = enabled;
await db
.update(workflow)
.set(updates)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, userId)));
console.log(`[Workflow] Updated ${workflowId}: enabled=${enabled}`);
}
export async function handleWorkflowDelete(
ws: ServerWebSocket<WebSocketData>,
workflowId: string
): Promise<void> {
const userId = ws.data.userId!;
await db
.delete(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, userId)));
sendToDevice(ws, {
type: "workflow_deleted",
workflowId,
});
console.log(`[Workflow] Deleted ${workflowId}`);
}
export async function handleWorkflowSync(
ws: ServerWebSocket<WebSocketData>
): Promise<void> {
const userId = ws.data.userId!;
const workflows = await db
.select()
.from(workflow)
.where(eq(workflow.userId, userId));
const workflowsJson = JSON.stringify(
workflows.map((wf) => ({
id: wf.id,
name: wf.name,
description: wf.description,
triggerType: wf.triggerType,
conditions: wf.conditions,
goalTemplate: wf.goalTemplate,
enabled: wf.enabled,
createdAt: new Date(wf.createdAt).getTime(),
}))
);
sendToDevice(ws, {
type: "workflow_synced",
workflowsJson,
});
console.log(`[Workflow] Synced ${workflows.length} workflows for user ${userId}`);
}
export async function handleWorkflowTrigger(
ws: ServerWebSocket<WebSocketData>,
workflowId: string,
notificationApp?: string,
notificationTitle?: string,
notificationText?: string
): Promise<void> {
const userId = ws.data.userId!;
const workflows = await db
.select()
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, userId)))
.limit(1);
if (workflows.length === 0) {
console.warn(`[Workflow] Trigger for unknown workflow ${workflowId}`);
return;
}
const wf = workflows[0];
if (!wf.enabled) return;
// Expand goal template placeholders
let goal = wf.goalTemplate;
goal = goal.replace(/\{\{app\}\}/g, notificationApp ?? "unknown app");
goal = goal.replace(/\{\{title\}\}/g, notificationTitle ?? "");
goal = goal.replace(/\{\{text\}\}/g, notificationText ?? "");
console.log(`[Workflow] Triggering '${wf.name}' with goal: ${goal}`);
// Send as a goal — reuse existing goal handling by injecting a goal message
sendToDevice(ws, { type: "ping" }); // keep-alive before goal injection
// The device will receive this as a workflow-triggered goal
// We send the goal text back to the device to be submitted as a regular goal
sendToDevice(ws, {
type: "workflow_goal",
workflowId: wf.id,
goal,
});
}