Skip to content
Merged
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
55 changes: 53 additions & 2 deletions apps/desktop/src/components/agents/routine-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { toast } from "sonner";
import { ChevronRight, CircleCheck } from "lucide-react";
import type { ComposioConnection, Routine, RoutineInput, RoutineTrigger, RoutineTriggerType, RoutineNotify } from "@godmode/shared";
import type { ComposioConnection, GroupDevice, Routine, RoutineInput, RoutineTrigger, RoutineTriggerType, RoutineNotify } from "@godmode/shared";
import { findModel } from "@godmode/shared";
import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { useAllAgents, useModelCatalog } from "@/lib/hooks";
import { useAllAgents, useDeviceGroup, useModelCatalog } from "@/lib/hooks";
import { modKey } from "@/lib/desktop";
import { useEasyMode } from "@/lib/mode";
import { clearDraft, useDraft } from "@/lib/drafts";
Expand Down Expand Up @@ -78,6 +78,8 @@ export interface RoutineDraft {
enabled: boolean;
reuseConversation: boolean;
notify: RoutineNotify;
/** Device groups: "primary" or a device id; null = any. */
runOn: string | null;
}

/** Like the core: events get a conversation each (named after the event); schedules and conditions keep one. */
Expand Down Expand Up @@ -108,6 +110,7 @@ function initialDraft(routine: Routine | null | undefined, agentId: string | und
enabled: routine?.enabled ?? initial?.enabled ?? true,
reuseConversation: routine?.reuseConversation ?? initial?.reuseConversation ?? defaultReuse(triggerType),
notify: routine?.notify ?? "failures",
runOn: routine?.runOn ?? initial?.runOn ?? null,
};
}

Expand Down Expand Up @@ -167,6 +170,8 @@ export function RoutineDialog({
const type = draft.triggerType;
const meta = TRIGGER_TYPES[type];
const eventTrigger = isEventTrigger(type);
const { data: group } = useDeviceGroup();
const pinnable = !!group?.role && !eventTrigger;
const appSchema = useAppTriggerSchema(draft.app);

const windowProblem = useMemo(
Expand Down Expand Up @@ -233,6 +238,7 @@ export function RoutineDialog({
enabled: draft.enabled,
reuseConversation: draft.reuseConversation,
notify: draft.notify,
runOn: pinnable ? draft.runOn : undefined,
};
if (!routine) return api.routines.create(input);
if (!routine.fillOnly || !reveal) return api.routines.update(routine.id, input);
Expand Down Expand Up @@ -618,6 +624,8 @@ export function RoutineDialog({
</p>
</div>

{pinnable && <RunOnField devices={group.devices} value={draft.runOn} onChange={(v) => set("runOn", v)} />}

<DialogFooter className="items-center sm:justify-between">
{kept.saved ? (
<DraftStatus
Expand Down Expand Up @@ -651,6 +659,49 @@ export function RoutineDialog({
}

/** The expression behind a schedule, for the rare one the friendly picker can't express. */
const ANY_DEVICE = "__any";

/** Device groups: the one computer that runs a schedule or check (one with its files and browser profiles). */
function RunOnField({ devices: list, value, onChange }: { devices: GroupDevice[]; value: string | null; onChange: (runOn: string | null) => void }) {
const devices = list.map((d) => ({
value: d.id ?? "primary",
name: d.name,
tag: [d.role === "primary" ? "primary" : null, d.self ? "this computer" : null].filter(Boolean).join(", "),
online: d.state === "online",
}));
const picked = value ? devices.find((d) => d.value === value) : null;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Label htmlFor="routine-run-on" className="shrink-0">
Run on
</Label>
<Select value={value ?? ANY_DEVICE} onValueChange={(v) => onChange(v === ANY_DEVICE ? null : v)}>
<SelectTrigger id="routine-run-on" className="w-56" aria-describedby="routine-run-on-hint">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={ANY_DEVICE}>Any device</SelectItem>
<SelectSeparator />
{devices.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.name}
{d.tag && <span className="ml-1.5 text-xs text-muted-foreground">{d.tag}</span>}
</SelectItem>
))}
{value && !picked && <SelectItem value={value}>Another computer</SelectItem>}
</SelectContent>
</Select>
<p id="routine-run-on-hint" className="basis-full text-xs text-muted-foreground">
{!value
? "Runs where your schedules run: the primary, or another computer while it stands in."
: picked
? `Only ${picked.name} runs it, also while another computer stands in. A time it misses while away runs when it's back${picked.online ? "" : " (it's offline now)"}.`
: "Pinned to a computer that isn't listed: one that left the group (the primary runs it then), or one this computer can't see while the primary is away."}
</p>
</div>
);
}

function RawCronField({ id, label, value, onChange }: { id: string; label: string; value: string; onChange: (cron: string) => void }) {
const problem = validateCron(value);
return (
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/automations/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ export function automationAgentReveals(routine: Pick<Routine, "agentId">): boole
* became fill-only (or stopped being it) starts a new chat, and nothing read in the old one comes along. For an
* agent that reads no raw secrets anyway, every chat of its own fits.
*/
export function chatFitsAutomation(routine: Pick<Routine, "agentId" | "fillOnly">, conversationId: string): boolean {
const existing = get<{ secret_access: string | null }>("SELECT secret_access FROM conversations WHERE id = ? AND agent_id = ?", conversationId, routine.agentId);
export function chatFitsAutomation(routine: Pick<Routine, "agentId" | "fillOnly"> & { runOn?: string | null }, conversationId: string): boolean {
const existing = get<{ secret_access: string | null; runner_id: string | null }>(
"SELECT secret_access, runner_id FROM conversations WHERE id = ? AND agent_id = ?",
conversationId,
routine.agentId,
);
if (!existing) return false;
// Pinned to one computer: a chat that lives on another one would run there.
if (routine.runOn && existing.runner_id) return false;
return (existing.secret_access === "fill") === routine.fillOnly || !automationAgentReveals(routine);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/cloud/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ const RULES: [methods: string, path: string, rule: Rule][] = [
["GET", "/api/link/conversations/:id/handover", refused(COMPUTER_ONLY)],
["GET", "/api/link/conversations/:id/handover/size", refused(COMPUTER_ONLY)],
["POST", "/api/link/exec", refused(COMPUTER_ONLY)],
["POST", "/api/link/routines/:id/run", refused(COMPUTER_ONLY)],
["POST", "/api/link/forget", refused(COMPUTER_ONLY)],
["PUT", "/api/link/update/chunk", refused(COMPUTER_ONLY)],
["POST", "/api/link/update/apply", refused(COMPUTER_ONLY)],
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,14 @@ CREATE TABLE artifact_versions (
created_at TEXT NOT NULL,
PRIMARY KEY (artifact_id, version)
);
`,
},
{
id: 103,
name: "routine_run_on",
sql: /* sql */ `
-- Device groups: the computer an automation's schedule runs on ("primary" or a device id; NULL = any).
ALTER TABLE routines ADD COLUMN run_on TEXT;
`,
},
];
10 changes: 6 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { ensureDefaultAgent } from "./agents/service";
import { recoverInterruptedRuns, shutdownRunner } from "./runner/runner";
import { interruptedWork, resumeInterruptedWork, startOutageRecovery, stopOutageRecovery } from "./services/resume";
import { clearRunMods, startModUpdates, stopModUpdates } from "./mods/service";
import { startScheduler, stopScheduler } from "./scheduler/scheduler";
import { reloadSchedules, startScheduler } from "./scheduler/scheduler";
import { startSignInRetry, stopSignInRetry } from "./automations/signInRetry";
import { startDreaming, stopDreaming } from "./memory/dreaming";
import { startFollowups, stopFollowups } from "./services/followups";
Expand Down Expand Up @@ -211,7 +211,6 @@ async function serve(values: Record<string, unknown>, role?: CoreConfig["role"])
if (sharedOn) return;
sharedOn = true;
// A runner's runs are told by the computer it works for (they arrive there as its own runs).
startScheduler();
startSignInRetry();
startRunNotices();
startDreaming();
Expand All @@ -225,7 +224,6 @@ async function serve(values: Record<string, unknown>, role?: CoreConfig["role"])
const stopShared = async () => {
if (!sharedOn) return;
sharedOn = false;
stopScheduler();
stopSignInRetry();
stopRunNotices();
stopDreaming();
Expand All @@ -237,11 +235,15 @@ async function serve(values: Record<string, unknown>, role?: CoreConfig["role"])
stopCalls();
};
if (!runner) {
if (runsShared()) void groupSettled().then(startShared);
// Every computer of a group fires the automations pinned to it; the rest only where the shared work runs.
const settled = groupSettled();
void settled.then(startScheduler);
if (runsShared()) void settled.then(startShared);
startModUpdates();
onGroupChange(() => {
if (runsShared()) startShared();
else void stopShared().catch((err) => log.warn("could not hand the shared work to the primary", err));
reloadSchedules();
});
}
// Adopt VMs that kept running while Godmode was closed.
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,14 @@ const routineFields = {
.boolean()
.optional()
.describe("Keep one conversation for all runs (continuity). Default: true for schedule and condition, false for app and webhook (one conversation per event)."),
runOn: z
.string()
.max(100)
.nullable()
.optional()
.describe(
'Device groups, schedule and condition triggers: the only computer that runs it — "primary", a device id or computer name from Settings → Devices; null = any (the primary, or a secondary standing in for it). Pin automations that need files, folders or browser profiles of one computer.',
),
};

function triggerSummary(r: Routine) {
Expand Down Expand Up @@ -858,6 +866,7 @@ function routineSummary(r: Routine, promptMax = 500) {
timezone: r.timezone,
enabled: r.enabled,
reuseConversation: r.reuseConversation,
...(r.runOn ? { runOn: r.runOn } : {}),
status: r.triggerStatus.state,
...(r.triggerStatus.message ? { statusMessage: r.triggerStatus.message } : {}),
lastRunAt: r.lastRunAt,
Expand Down Expand Up @@ -2451,7 +2460,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl
defineTool({
name: "routine_update",
description:
"Change an automation's trigger, schedule, prompt, filter, name or enabled state. Changing one of an agent that may read secrets in plain text while you can't makes it fill-only (pausing it doesn't).",
"Change an automation's trigger, schedule, prompt, filter, name, enabled state or the computer it runs on (runOn). Changing one of an agent that may read secrets in plain text while you can't makes it fill-only (pausing it or picking its computer doesn't).",
schema: z.object({
routineId: z.string(),
name: routineFields.name.optional(),
Expand All @@ -2462,6 +2471,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl
timezone: routineFields.timezone,
enabled: routineFields.enabled,
reuseConversation: routineFields.reuseConversation,
runOn: routineFields.runOn,
}),
when: managesSetup,
run: async ({ routineId, trigger, ...patch }, { agent, ctx }) => {
Expand All @@ -2476,8 +2486,8 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl
const owner = getAgent(latest.agentId);
const secrets = automationSecrets(agent, ctx, owner, "change its automations");
if (typeof secrets === "string") return fail(secrets);
// What this caller writes or starts runs without raw secrets from now on. Pausing does neither.
const pauseOnly = fields.length === 1 && patch.enabled === false;
// What this caller writes or starts runs without raw secrets from now on. Pausing does neither, nor picking the computer.
const pauseOnly = fields.length > 0 && fields.every((f) => f === "runOn" || (f === "enabled" && patch.enabled === false));
const fillOnly = secrets.fillOnly && !pauseOnly;
const r = updateRoutine(routineId, { ...patch, ...(resolved ? { trigger: resolved } : {}), ...(fillOnly ? { fillOnly } : {}) });
const became = fillOnly && !latest.fillOnly;
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/remote/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* `link.*` meta never goes into backups, like the pairings it describes.
*/
import type { DeviceRole } from "@godmode/shared";
import { deleteMeta, getMeta, setMeta } from "../db";
import { deleteMeta, get, getMeta, setMeta } from "../db";

const ROLE_META = "link.group.role";
const PRIMARY_META = "link.group.primary";
Expand Down Expand Up @@ -97,6 +97,26 @@ export function runsShared(): boolean {
return !isSecondary() || standingIn();
}

/**
* Whether this computer runs the schedule of an automation pinned to `runOn` ("primary", a device id, or null = any).
* Pinned ones run on their computer only, standing in or not; outside a group every pin is moot. On the primary, a pin
* to a computer that left the group runs here.
*/
export function runsRoutineHere(runOn: string | null | undefined): boolean {
const role = groupRole();
if (!role) return true;
if (!runOn) return runsShared();
if (role === "primary") {
if (runOn === "primary") return true;
try {
return !get("SELECT 1 FROM runners WHERE id = ? AND kind = 'device'", runOn);
} catch {
return false;
}
}
return runOn === selfDeviceId();
}

export function setStandingIn(since: string | null): void {
if (since) setMeta(STAND_IN_META, since);
else deleteMeta(STAND_IN_META);
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/remote/routinePins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Automations pinned to one computer of a device group (owner: remote): `routines.run_on` is "primary" or a device's id
* in the primary's runner list (`GroupDevice.id`), the same on every computer. The scheduler fires a pinned automation
* only there (group.ts `runsRoutineHere`), and Run now elsewhere goes there over the link.
*/
import type { Run } from "@godmode/shared";
import { all, get } from "../db";
import { computerName } from "../mobile/devices";
import { HttpError, badRequest } from "../util";
import { groupRole, isPrimary, primaryRunnerId, selfDeviceId } from "./group";

const DEVICE_ID = /^[A-Za-z0-9_-]{1,100}$/;

function rowName(id: string | null): string | null {
return id ? (get<{ name: string }>("SELECT name FROM runners WHERE id = ?", id)?.name ?? null) : null;
}

/**
* A pin as given (by the app, an agent: "primary", a device id, a computer's name, or what a secondary calls its
* primary's runner) as it is stored. undefined = unchanged, null = any computer.
*/
export function resolveRunOn(input: string | null | undefined): string | null | undefined {
if (input === undefined) return undefined;
const value = (input ?? "").trim();
if (!value || value.toLowerCase() === "any") return null;
const role = groupRole();
if (!role) throw badRequest("This computer isn't part of a device group: its automations run here.");
const same = (a: string | null | undefined) => !!a && a.toLowerCase() === value.toLowerCase();
const primaryId = role === "secondary" ? primaryRunnerId() : null;
if (same("primary") || same(primaryId) || same(role === "primary" ? computerName() : rowName(primaryId))) return "primary";
if (role === "secondary" && (same(selfDeviceId()) || same(computerName()))) return selfDeviceId();
const devices = all<{ id: string; name: string }>("SELECT id, name FROM runners WHERE kind = 'device'");
const device = devices.find((d) => same(d.id)) ?? devices.find((d) => same(d.name));
if (device) return device.id;
// A secondary knows the other secondaries only through its primary: their ids stand as given.
if (role === "secondary" && DEVICE_ID.test(value) && value.includes("_")) return value;
throw badRequest(`Unknown computer “${value}”: give "primary", a computer of the device group (Settings → Devices) or null for any.`);
}

/** The computer an automation is pinned to, as this one calls it. */
export function runOnName(runOn: string): string {
if (runOn === "primary") return isPrimary() ? computerName() : (rowName(primaryRunnerId()) ?? "the primary");
if (runOn === selfDeviceId()) return computerName();
return rowName(runOn) ?? "the computer it is pinned to";
}

/**
* Run now for an automation pinned to another computer: the request goes there (a secondary sends it to its primary,
* which passes it on unless it is the pinned one). Refused clearly while that computer can't be reached.
*/
export async function runPinnedElsewhere(routine: { id: string; name: string; runOn: string }, opts: { byHuman?: boolean }): Promise<Run> {
const target = isPrimary() ? routine.runOn : primaryRunnerId();
const where = runOnName(routine.runOn);
// A secondary reaches the other secondaries through its primary.
const via = !isPrimary() && routine.runOn !== "primary" ? `${runOnName("primary")}, which passes it on there,` : where;
const offline = () =>
new HttpError(409, `“${routine.name}” runs on ${where} only, and ${via} can't be reached right now. It runs there at its next time, or set Run on to Any device.`, "device_offline");
if (!target) throw offline();
const { link } = await import("./runners");
let l: ReturnType<typeof link>;
try {
l = link(target);
l.assertOnline();
} catch {
throw offline();
}
const here = isPrimary() || routine.runOn === "primary";
return l.json<Run>("POST", `/api/link/routines/${encodeURIComponent(routine.id)}/run`, { here, byHuman: opts.byHuman === true }, { timeoutMs: 120_000 });
}
Loading
Loading