Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
599 changes: 599 additions & 0 deletions apps/app/src/react-app/domains/settings/pages/workflows-panel.tsx

Large diffs are not rendered by default.

46 changes: 30 additions & 16 deletions apps/app/src/react-app/shell/settings-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { GeneralSettingsView } from "../domains/settings/pages/general-view";
import { AdvancedView } from "../domains/settings/pages/advanced-view";
import { AppearanceView } from "../domains/settings/pages/appearance-view";
import { AutomationsView } from "../domains/settings/pages/automations-view";
import { WorkflowsPanel } from "../domains/settings/pages/workflows-panel";
import { DebugView } from "../domains/settings/pages/debug-view";
import { DenView } from "../domains/settings/pages/den-view";
import { ExtensionsView } from "../domains/settings/pages/extensions-view";
Expand Down Expand Up @@ -1021,22 +1022,35 @@ export function SettingsRoute() {
);
case "automations":
return (
<AutomationsView
automations={automationsStore}
busy={busy}
selectedWorkspaceRoot={selectedWorkspaceRoot}
createSessionAndOpen={async () => undefined}
newTaskDisabled={!opencodeClient}
schedulerInstalled={false}
canEditPlugins={!isRemoteWorkspace}
addPlugin={async () => {
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
}}
reloadWorkspaceEngine={reloadCoordinator.reloadWorkspaceEngine}
reloadBusy={false}
canReloadWorkspace={reloadCoordinator.canReloadWorkspaceEngine}
openLink={(url) => platform.openLink(url)}
/>
<>
<WorkflowsPanel
serverBaseUrl={baseUrl || null}
workspaceId={selectedWorkspace?.id ?? null}
authToken={token || null}
showToast={(input) => {
if (input.tone === "error") {
setRouteError(input.title + (input.description ? `: ${input.description}` : ""));
}
}}
/>
<div className="my-8 border-t border-dls-border" />
<AutomationsView
automations={automationsStore}
busy={busy}
selectedWorkspaceRoot={selectedWorkspaceRoot}
createSessionAndOpen={async () => undefined}
newTaskDisabled={!opencodeClient}
schedulerInstalled={false}
canEditPlugins={!isRemoteWorkspace}
addPlugin={async () => {
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
}}
reloadWorkspaceEngine={reloadCoordinator.reloadWorkspaceEngine}
reloadBusy={false}
canReloadWorkspace={reloadCoordinator.canReloadWorkspaceEngine}
openLink={(url) => platform.openLink(url)}
/>
</>
);
case "skills":
return (
Expand Down
4 changes: 0 additions & 4 deletions apps/server-v2/src/routes/route-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,6 @@ export const routePaths = {
unrevert: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/unrevert`,
},
scheduler: {
base: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/scheduler/jobs`,
byName: (name: string = ":name", workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/scheduler/jobs/${name}`,
},
skills: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/skills`,
hubSkills: "/hub/skills",
simpleContent: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/files/content`,
Expand Down
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@
"access": "public"
},
"dependencies": {
"inngest": "^4.2.5",
"jsonc-parser": "^3.2.1",
"minimatch": "^10.0.1",
"yaml": "^2.6.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/minimatch": "^5.1.2",
"@types/node": "^22.10.2",
"bun-types": "^1.3.6",
"typescript": "^5.6.3"
},
Expand Down
300 changes: 300 additions & 0 deletions apps/server/src/automations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
/**
* Inngest-powered automations for OpenWork server (v1).
*
* Provides an in-memory automation store, CRUD, trigger, update, and a
* recurring scheduler that fires automations on their configured interval.
*/

import { Inngest } from "inngest";

// ── Inngest client ──────────────────────────────────────────────────────

export const inngest = new Inngest({ id: "openwork" });

// ── Types ───────────────────────────────────────────────────────────────

export type Automation = {
id: string;
name: string;
description: string;
prompt: string;
/** "manual" | intervalSeconds (e.g. "60") | cron expression */
schedule: string;
enabled: boolean;
workspaceId: string;
createdAt: string;
updatedAt: string;
lastRunAt: string | null;
lastRunStatus: "pending" | "running" | "success" | "failed" | null;
lastRunError: string | null;
lastSessionId: string | null;
runCount: number;
};

export type CreateAutomationInput = {
name?: string;
description?: string;
prompt: string;
schedule?: string;
};

export type UpdateAutomationInput = {
name?: string;
description?: string;
prompt?: string;
schedule?: string;
enabled?: boolean;
};

// ── In-memory store ─────────────────────────────────────────────────────

const automations = new Map<string, Automation>();

function generateId() {
return `auto_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

export function listAutomations(workspaceId: string): Automation[] {
const items: Automation[] = [];
for (const auto of automations.values()) {
if (auto.workspaceId === workspaceId) {
items.push({ ...auto });
}
}
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return items;
}

export function getAutomation(id: string): Automation | null {
const auto = automations.get(id);
return auto ? { ...auto } : null;
}

export function createAutomation(workspaceId: string, input: CreateAutomationInput): Automation {
const prompt = (input.prompt ?? "").trim();
if (!prompt) {
throw Object.assign(new Error("prompt is required"), { status: 400 });
}

const now = new Date().toISOString();
const auto: Automation = {
id: generateId(),
name: (input.name ?? "").trim() || "Untitled Automation",
description: (input.description ?? "").trim(),
prompt,
schedule: (input.schedule ?? "").trim() || "manual",
enabled: true,
workspaceId,
createdAt: now,
updatedAt: now,
lastRunAt: null,
lastRunStatus: null,
lastRunError: null,
lastSessionId: null,
runCount: 0,
};

automations.set(auto.id, auto);
return { ...auto };
}

export function updateAutomation(id: string, input: UpdateAutomationInput): Automation {
const auto = automations.get(id);
if (!auto) {
throw Object.assign(new Error(`Automation not found: ${id}`), { status: 404 });
}

if (input.name !== undefined) auto.name = input.name.trim() || auto.name;
if (input.description !== undefined) auto.description = input.description.trim();
if (input.prompt !== undefined) {
const p = input.prompt.trim();
if (p) auto.prompt = p;
}
if (input.schedule !== undefined) auto.schedule = input.schedule.trim() || "manual";
if (input.enabled !== undefined) auto.enabled = input.enabled;
auto.updatedAt = new Date().toISOString();

return { ...auto };
}

export function deleteAutomation(id: string): Automation {
const auto = automations.get(id);
if (!auto) {
throw Object.assign(new Error(`Automation not found: ${id}`), { status: 404 });
}
automations.delete(id);
return { ...auto };
}

// ── Trigger ─────────────────────────────────────────────────────────────

type FetchOpencodeJsonFn = (
path: string,
init: { method: string; body?: unknown },
) => Promise<unknown>;

export async function triggerAutomation(
id: string,
fetchOpencode: FetchOpencodeJsonFn,
): Promise<{ eventId: string; sessionId?: string }> {
const auto = automations.get(id);
if (!auto) {
throw Object.assign(new Error(`Automation not found: ${id}`), { status: 404 });
}

auto.lastRunAt = new Date().toISOString();
auto.lastRunStatus = "running";
auto.lastRunError = null;
auto.updatedAt = auto.lastRunAt;

return triggerDirect(auto, fetchOpencode);
}

async function triggerDirect(
auto: Automation,
fetchOpencode: FetchOpencodeJsonFn,
): Promise<{ eventId: string; sessionId?: string }> {
try {
const sessionResult = await fetchOpencode("/session", {
method: "POST",
body: {},
}) as { id?: string };

const sessionId = sessionResult?.id;
if (!sessionId) {
throw new Error("Session creation did not return an ID");
}

try {
await fetchOpencode(`/session/${sessionId}/prompt_async`, {
method: "POST",
body: { parts: [{ type: "text", text: auto.prompt }] },
});
} catch (err: unknown) {
const status = (err as any)?.status ?? (err as any)?.details?.status;
if (status !== 204) {
throw err;
}
}

auto.lastRunStatus = "success";
auto.lastSessionId = sessionId;
auto.lastRunError = null;
auto.runCount += 1;
auto.updatedAt = new Date().toISOString();

return { eventId: "direct", sessionId };
} catch (error: unknown) {
auto.lastRunStatus = "failed";
auto.lastRunError = error instanceof Error ? error.message : String(error);
auto.updatedAt = new Date().toISOString();
throw error;
}
}

// ── Recurring scheduler ─────────────────────────────────────────────────

let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerFetchOpencode: FetchOpencodeJsonFn | null = null;

/**
* Parse the schedule field. Returns interval in seconds, or 0 for manual.
*/
function parseScheduleSeconds(schedule: string): number {
if (!schedule || schedule === "manual") return 0;
const num = Number(schedule);
if (Number.isFinite(num) && num > 0) return num;
return 0;
}

/**
* Start the recurring scheduler. Checks every 10 seconds for automations
* whose interval has elapsed and triggers them.
*/
export function startScheduler(fetchOpencode: FetchOpencodeJsonFn) {
schedulerFetchOpencode = fetchOpencode;
if (schedulerTimer) return;

schedulerTimer = setInterval(() => {
if (!schedulerFetchOpencode) return;
const now = Date.now();

for (const auto of automations.values()) {
if (!auto.enabled) continue;
if (auto.lastRunStatus === "running") continue;

const intervalSec = parseScheduleSeconds(auto.schedule);
if (intervalSec <= 0) continue;

const lastRun = auto.lastRunAt ? new Date(auto.lastRunAt).getTime() : 0;
const elapsed = (now - lastRun) / 1000;

if (elapsed >= intervalSec) {
console.log(`[scheduler] Firing recurring automation: ${auto.name} (${auto.id})`);
triggerAutomation(auto.id, schedulerFetchOpencode).catch((err) => {
console.error(`[scheduler] Automation ${auto.id} failed:`, err instanceof Error ? err.message : err);
});
}
}
}, 10_000);
}

export function stopScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
schedulerFetchOpencode = null;
}

// ── Inngest functions (for Inngest serve endpoint) ──────────────────────

export const runAutomationFn = inngest.createFunction(
{
id: "run-automation",
retries: 2,
triggers: [{ event: "automation/run" }],
},
async ({ event, step }: any) => {
const { prompt, workspaceId, serverBaseUrl, authToken } = event.data as {
prompt: string;
workspaceId: string;
serverBaseUrl: string;
authToken?: string;
};

const session = await step.run("create-session", async () => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const res = await fetch(`${serverBaseUrl}/workspace/${workspaceId}/opencode/session`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
return (await res.json()) as { id: string };
});

const sessionId = session?.id;
if (!sessionId) throw new Error("No session ID returned");

await step.run("send-prompt", async () => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const res = await fetch(
`${serverBaseUrl}/workspace/${workspaceId}/opencode/session/${sessionId}/prompt_async`,
{
method: "POST",
headers,
body: JSON.stringify({ parts: [{ type: "text", text: prompt }] }),
},
);
if (!res.ok && res.status !== 204) throw new Error(`Failed: ${res.status}`);
return { sent: true };
});

return { sessionId, prompt, workspaceId, completedAt: new Date().toISOString() };
},
);

export const inngestFunctions = [runAutomationFn];
Loading