Skip to content
Open
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
53 changes: 53 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,59 @@ export async function fetchGoalConfiguration(goalId: string) {
);
}

const automationCadenceSourceSchema = z.object({
agent_id: z.string().nullable(),
automation_id: z.string().nullable(),
min_interval_minutes: z.number().int().nonnegative(),
});

export const automationCadenceSchema = z.object({
ok: z.literal(true),
schema_version: z.literal("chat_automation_cadence_v0"),
goal_id: z.string(),
agent_id: z.string().nullable(),
automation_id: z.string().nullable(),
configuration_revision: z.number().int().nonnegative(),
min_interval_minutes: z.number().int().nonnegative(),
enabled: z.boolean(),
enforcement: z.string(),
pre_model_admission: z.string(),
sources: z.array(automationCadenceSourceSchema),
preview_revision: z.string().optional(),
written: z.boolean().optional(),
readback_verified: z.boolean().optional(),
});

export type AutomationCadence = z.infer<typeof automationCadenceSchema>;
export type AutomationCadenceChange = {
goal_id: string;
agent_id: string | null;
automation_id: string | null;
min_interval_minutes: number;
expected_revision: number;
owner_reference: string;
approve_reduction: boolean;
};

export async function fetchAutomationCadence(goalId: string, agentId: string | null, automationId: string | null) {
const query = new URLSearchParams({ goal_id: goalId });
if (agentId) query.set("agent_id", agentId);
if (automationId) query.set("automation_id", automationId);
return automationCadenceSchema.parse(await requestJson<unknown>(`/api/chat/automation-cadence?${query}`));
}

export async function previewAutomationCadence(change: AutomationCadenceChange) {
return automationCadenceSchema.parse(await requestJson<unknown>("/api/chat/automation-cadence/preview", {
method: "POST", body: JSON.stringify(change),
}));
}

export async function applyAutomationCadence(change: AutomationCadenceChange, previewRevision: string) {
return automationCadenceSchema.parse(await requestJson<unknown>("/api/chat/automation-cadence/apply", {
method: "POST", body: JSON.stringify({ ...change, preview_revision: previewRevision }),
}));
}

export async function previewGoalConfiguration(
goalId: string,
capabilityId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";

import {
applyAutomationCadence, fetchAutomationCadence, previewAutomationCadence,
type AutomationCadence, type AutomationCadenceChange,
} from "../../data/chat";
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceGoal } from "./personal-workspace-model";

type Scope = "goal" | "agent" | "automation";

export function AutomationCadenceSettings({ goal }: Readonly<{ goal: WorkspaceGoal }>) {
const { t } = useWorkspaceI18n();
const agentLanes = useMemo(() => goal.agentLanes?.length
? goal.agentLanes : goal.agentId ? [{ agentId: goal.agentId, label: goal.agentLabel ?? goal.agentId }] : [], [goal]);
const [scope, setScope] = useState<Scope>("goal");
const [agentId, setAgentId] = useState(agentLanes[0]?.agentId ?? "");
const [automationId, setAutomationId] = useState("");
const [inspection, setInspection] = useState<AutomationCadence | null>(null);
const [minutes, setMinutes] = useState("0");
const [ownerReference, setOwnerReference] = useState("");
const [approveReduction, setApproveReduction] = useState(false);
const [preview, setPreview] = useState<AutomationCadence | null>(null);
const [busy, setBusy] = useState<"load" | "preview" | "apply" | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [reloadSequence, setReloadSequence] = useState(0);
const scopedAgent = scope === "goal" ? null : agentId.trim() || null;
const scopedAutomation = scope === "automation" ? automationId.trim() || null : null;
const scopeReady = scope === "goal" || Boolean(scopedAgent && (scope !== "automation" || scopedAutomation));

useEffect(() => {
setAgentId(agentLanes[0]?.agentId ?? "");
}, [goal.goalId]);

useEffect(() => {
if (!scopeReady) { setInspection(null); setPreview(null); return; }
let active = true;
setBusy("load");
setError(null);
setPreview(null);
setInspection(null);
fetchAutomationCadence(goal.goalId, scopedAgent, scopedAutomation)
.then((result) => {
if (!active) return;
setInspection(result);
const direct = result.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation);
setMinutes(String(direct?.min_interval_minutes ?? result.min_interval_minutes));
setOwnerReference("");
setApproveReduction(false);
})
.catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : t("cadence.loadFailed")); })
.finally(() => { if (active) setBusy(null); });
return () => { active = false; };
}, [goal.goalId, scope, scopedAgent, scopedAutomation, reloadSequence, t]);

const directRule = inspection?.sources.find((source) => source.agent_id === scopedAgent && source.automation_id === scopedAutomation);
const inheritedFloor = Math.max(0, ...(inspection?.sources.filter((source) => source !== directRule).map((source) => source.min_interval_minutes) ?? []));
const proposedMinutes = Number(minutes);
const validMinutes = /^\d+$/.test(minutes) && Number.isSafeInteger(proposedMinutes) && proposedMinutes <= 525600;
const reduction = validMinutes && directRule !== undefined && proposedMinutes < directRule.min_interval_minutes;
const canPreview = Boolean(inspection && scopeReady && validMinutes && ownerReference.trim() && (!reduction || approveReduction) && !busy);

function invalidate() { setPreview(null); setNotice(null); setError(null); }

function change(): AutomationCadenceChange | null {
if (!inspection || !canPreview) return null;
return {
goal_id: goal.goalId, agent_id: scopedAgent, automation_id: scopedAutomation,
min_interval_minutes: proposedMinutes, expected_revision: inspection.configuration_revision,
owner_reference: ownerReference.trim(), approve_reduction: reduction && approveReduction,
};
}

async function createPreview() {
const payload = change();
if (!payload) return;
setBusy("preview"); setError(null);
try { setPreview(await previewAutomationCadence(payload)); }
catch (reason) { setError(reason instanceof Error ? reason.message : t("cadence.previewFailed")); }
finally { setBusy(null); }
}

async function apply() {
const payload = change();
if (!payload || !preview?.preview_revision) return;
setBusy("apply"); setError(null);
try {
const result = await applyAutomationCadence(payload, preview.preview_revision);
setInspection(result);
setPreview(null);
setOwnerReference("");
setApproveReduction(false);
setNotice(t(result.readback_verified ? "cadence.applied" : "cadence.readbackChanged"));
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : t("cadence.applyFailed"));
} finally { setBusy(null); }
}

return <section className="personal-cadence-settings" aria-label={t("cadence.title")}>
<div className="personal-cadence-intro">
<p>{t("cadence.description")}</p>
</div>
<fieldset className="personal-cadence-scopes">
<legend>{t("cadence.scope")}</legend>
{(["goal", "agent", "automation"] as const).map((option) =>
<label key={option}><input checked={scope === option} disabled={Boolean(busy)} onChange={() => { setScope(option); invalidate(); }} type="radio" name="cadence-scope" value={option} />{t(`cadence.scope.${option}`)}</label>)}
</fieldset>
{scope !== "goal" ? <div className="personal-cadence-fields">
<label>{t("cadence.agentId")}<input disabled={Boolean(busy)} list="cadence-agent-lanes" onChange={(event) => { setAgentId(event.target.value); invalidate(); }} value={agentId} /></label>
<datalist id="cadence-agent-lanes">{agentLanes.map((lane) => <option key={lane.agentId} value={lane.agentId}>{lane.label}</option>)}</datalist>
{scope === "automation" ? <label>{t("cadence.automationId")}<input disabled={Boolean(busy)} onChange={(event) => { setAutomationId(event.target.value); invalidate(); }} value={automationId} /></label> : null}
</div> : null}
{busy === "load" ? <p aria-live="polite">{t("common.loading")}</p> : null}
{inspection ? <>
<div className="personal-cadence-readback">
<small>{t("cadence.effective")}</small><strong>{inspection.min_interval_minutes} <span>{t("cadence.minutes")}</span></strong>
<p>{directRule
? t("cadence.directSource", { minutes: directRule.min_interval_minutes, inherited: inheritedFloor })
: scope === "goal" ? t("cadence.unconfigured") : t("cadence.inheritedSource", { minutes: inheritedFloor })}</p>
</div>
<p className="personal-cadence-boundary"><AlertTriangle aria-hidden size={16} />{t("cadence.boundary")}</p>
<div className="personal-cadence-fields">
<label>{t("cadence.minimum")}<input disabled={Boolean(busy)} min={0} max={525600} onChange={(event) => { setMinutes(event.target.value); invalidate(); }} type="number" value={minutes} /><small>{t("cadence.zeroHint")}</small></label>
<label>{t("cadence.ownerReference")}<input disabled={Boolean(busy)} maxLength={256} onChange={(event) => { setOwnerReference(event.target.value); invalidate(); }} value={ownerReference} /><small>{t("cadence.referenceHint")}</small></label>
</div>
{reduction ? <label className="personal-cadence-reduction"><input checked={approveReduction} disabled={Boolean(busy)} onChange={(event) => { setApproveReduction(event.target.checked); invalidate(); }} type="checkbox" />{t("cadence.reduction")}</label> : null}
{preview ? <div className="personal-cadence-preview"><strong>{t("cadence.preview")}</strong><span>{t("cadence.previewValue", { minutes: preview.min_interval_minutes })}</span><small>{t("cadence.previewLocked")}</small></div> : null}
<div className="personal-cadence-actions"><button disabled={!canPreview} onClick={() => void createPreview()} type="button">{t("cadence.preview")}</button><button className="is-primary" disabled={!preview || Boolean(busy)} onClick={() => void apply()} type="button">{t("cadence.apply")}</button></div>
</> : null}
{error ? <p className="personal-machine-error" role="alert">{error} <button onClick={() => { invalidate(); setReloadSequence((value) => value + 1); }} type="button"><RefreshCw aria-hidden size={14} />{t("cadence.retry")}</button></p> : null}
{notice ? <p aria-live="polite" className="personal-cadence-notice">{notice}</p> : null}
</section>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,35 @@ const en = {
"settings.themeLoopx": "LoopX standard",
"settings.themeLoopxDescription": "Precise monochrome surfaces, Geist type, and quiet hairline structure.",
"settings.title": "Settings",
"cadence.title": "Automatic execution interval",
"cadence.description": "Choose the shortest interval LoopX may recommend for automatic runs of this Goal.",
"cadence.scope": "Applies to",
"cadence.scope.goal": "Whole Goal",
"cadence.scope.agent": "One Agent",
"cadence.scope.automation": "One automation",
"cadence.agentId": "Agent",
"cadence.automationId": "Automation ID",
"cadence.effective": "Effective minimum",
"cadence.unconfigured": "No minimum configured yet.",
"cadence.inheritedSource": "Inherited from parent scope: {minutes} min. No rule at this scope.",
"cadence.directSource": "This scope: {minutes} min · inherited minimum: {inherited} min.",
"cadence.minutes": "min",
"cadence.minimum": "Minimum interval (minutes)",
"cadence.zeroHint": "0 clears this scope's constraint; parent constraints still apply.",
"cadence.ownerReference": "Owner instruction or reason",
"cadence.referenceHint": "Stored locally with this policy. Do not include secrets.",
"cadence.reduction": "I intend to lower or remove this scope's existing minimum.",
"cadence.boundary": "This controls LoopX schedule recommendations. Existing Codex App timers are not changed automatically; App timer-to-hook admission has not been qualified.",
"cadence.preview": "Review change",
"cadence.previewValue": "After change: {minutes} min effective minimum",
"cadence.previewLocked": "Applying rechecks the exact policy revision. A changed policy requires a new review.",
"cadence.apply": "Apply reviewed change",
"cadence.applied": "Saved and read back from the quota policy.",
"cadence.readbackChanged": "Saved, but the policy changed before readback. Refresh to inspect the latest value.",
"cadence.loadFailed": "Could not load the interval policy.",
"cadence.previewFailed": "Could not preview the interval change.",
"cadence.applyFailed": "Could not apply the interval change.",
"cadence.retry": "Refresh policy",
"settings.workspaceDisplay": "Workspace display",
"settings.workspaceTheme": "Workspace theme",
"session.closeRecord": "Exit run record",
Expand Down Expand Up @@ -2087,6 +2116,35 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"settings.themeLoopx": "LoopX 标准",
"settings.themeLoopxDescription": "精确的黑白界面、Geist 字体与安静的细线结构。",
"settings.title": "设置",
"cadence.title": "自动执行间隔",
"cadence.description": "设置 LoopX 为该 Goal 的自动运行可推荐的最短间隔。",
"cadence.scope": "作用范围",
"cadence.scope.goal": "整个 Goal",
"cadence.scope.agent": "单个 Agent",
"cadence.scope.automation": "单个自动化",
"cadence.agentId": "Agent",
"cadence.automationId": "自动化 ID",
"cadence.effective": "生效下限",
"cadence.unconfigured": "尚未设置间隔下限。",
"cadence.inheritedSource": "继承上层 {minutes} 分钟;本层未设置。",
"cadence.directSource": "本层设置 {minutes} 分钟;上层下限 {inherited} 分钟。",
"cadence.minutes": "分钟",
"cadence.minimum": "最短间隔(分钟)",
"cadence.zeroHint": "设为 0 仅清除此层约束;上层约束仍然生效。",
"cadence.ownerReference": "所有者指令或原因",
"cadence.referenceHint": "随策略保存在本机,请勿填写密钥。",
"cadence.reduction": "我明确要降低或移除此层现有的间隔下限。",
"cadence.boundary": "此设置约束 LoopX 的排程建议,不会自动修改已有 Codex App 定时任务;App 定时触发到启动前钩子的拦截尚未验证。",
"cadence.preview": "预览变更",
"cadence.previewValue": "变更后生效下限:{minutes} 分钟",
"cadence.previewLocked": "应用时会复核策略修订号;若策略已变化,需重新预览。",
"cadence.apply": "应用已预览变更",
"cadence.applied": "已保存并从 quota 策略读回。",
"cadence.readbackChanged": "已写入,但读回前策略又发生变化;请刷新核对最新值。",
"cadence.loadFailed": "无法读取间隔策略。",
"cadence.previewFailed": "无法预览间隔变更。",
"cadence.applyFailed": "无法应用间隔变更。",
"cadence.retry": "刷新策略",
"settings.workspaceDisplay": "工作区显示",
"settings.workspaceTheme": "工作区主题",
"session.closeRecord": "退出运行记录",
Expand Down
Loading
Loading