mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(budgets): per-task and per-project cost budgets (flag-gated) (#654)
* fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation The sweep re-escalated every expired unacked ack-required notification on every ~60s tick, forever — the live incident: 3 fresh blocker escalations + Telegram DMs per minute from a static stale pile. Now each notification carries reescalation_count / last_reescalated_at / reescalation_delivered_count (migration 079): first fire at expiry, then doubling intervals from 1h capped at 24h, hard stop after ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent log carrying attempts-vs-delivered so 'seen and ignored' is distinguishable from 'route never worked'. The due/wait/capped decision is a pure function in foundation/policy/communications.py. Per adversarial review, the attempt slot is claimed by compare-and-set (UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the previous draft leaned on the 60s dedup window, which never engages for BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent sweeps would have double-delivered. A lost claim skips delivery outright. Legacy rows read as count=0 and keep today's first-fire semantics. 61 tests incl. a two-session CAS race and a real alembic upgrade/downgrade round trip. * feat(budgets): per-task and per-project cost budgets (flag-gated) tasks.budget_usd + projects.monthly_budget_usd (migration 080, chained on 079; adds ix_agent_spawn_sessions_task_id since both enforcement seams filter on bare task_id). Behind ROBOCO_TASK_BUDGETS_ENABLED (default off, feature-flags card) — verifiably inert when off. Claim-time: a project-month-spend guard applies to WORK-STARTING claims only (i_will_work_on / i_will_plan) — per adversarial review, review/ doc/gate/inbound-PR claims are exempt so in-flight work can always finish reviewing and merging at cap. Spend counts closed sessions' estimated_cost_usd PLUS open sessions priced live from token snapshots (the original closed-only sum read parallel long sessions as $0). Sweep-side: the existing budget sweep also prices the active task's spend vs budget_usd (TaskType defaults when null); on breach the task is BLOCKED (HUMAN resolver, budget marker) BEFORE the graceful stop so the unclaim no-ops and the dispatcher never respawns onto it, and the CEO notification names both recovery steps. unblock on a budget-blocked task re-checks live spend and refuses while still over — no silent re-breach loop. Panel: budget inputs in both dialogs (0 rejected — a zero budget silently blocks everything), spend logic consolidated in TaskService.task_spend_usd. 42 new tests incl. a real-DB spend-query suite and a two-tick non-refire sweep test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,9 @@ import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { Team } from "@/types";
|
||||
import type { Project } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
// jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active"
|
||||
// toggle) measures its thumb via one on mount — mirrors
|
||||
@@ -117,6 +120,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
video_engine_enabled: false,
|
||||
dep_update_command: null,
|
||||
dep_update_paths: null,
|
||||
monthly_budget_usd: null,
|
||||
sandbox_services: null,
|
||||
sandbox_extensions: null,
|
||||
workspace_path: null,
|
||||
@@ -385,3 +389,104 @@ describe("EditProjectDialog — Protected Branches", () => {
|
||||
expect(call.updates.protected_branches).toEqual(["master", "slave"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditProjectDialog — Monthly Budget (USD)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
|
||||
mutateAsync.mockResolvedValue(makeProject());
|
||||
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
|
||||
});
|
||||
|
||||
function openAutonomySection() {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /Show Autonomous Maintenance/i }),
|
||||
);
|
||||
}
|
||||
|
||||
// fireEvent.submit(form) rather than clicking the Save button — this
|
||||
// dialog's Tabs-wrapped form doesn't reliably translate a button click
|
||||
// into a submit event under jsdom; submitting the form directly is the
|
||||
// same idiom create-task-dialog.test.tsx already uses.
|
||||
function submit() {
|
||||
fireEvent.submit(document.querySelector("form")!);
|
||||
}
|
||||
|
||||
it("pre-fills the stored monthly_budget_usd", async () => {
|
||||
renderDialog(makeProject({ monthly_budget_usd: 42 }));
|
||||
await screen.findByRole("button", { name: /Save Changes/i });
|
||||
openAutonomySection();
|
||||
|
||||
expect(screen.getByLabelText(/Monthly Budget/i)).toHaveValue(42);
|
||||
});
|
||||
|
||||
it("rejects 0 with an inline error and does not submit", async () => {
|
||||
renderDialog(makeProject({ monthly_budget_usd: null }));
|
||||
await screen.findByRole("button", { name: /Save Changes/i });
|
||||
openAutonomySection();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
|
||||
target: { value: "0" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/greater than 0/i),
|
||||
);
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a negative budget the same way", async () => {
|
||||
renderDialog(makeProject({ monthly_budget_usd: null }));
|
||||
await screen.findByRole("button", { name: /Save Changes/i });
|
||||
openAutonomySection();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
|
||||
target: { value: "-5" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/greater than 0/i),
|
||||
);
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits null when cleared (no cap)", async () => {
|
||||
renderDialog(makeProject({ monthly_budget_usd: 42 }));
|
||||
await screen.findByRole("button", { name: /Save Changes/i });
|
||||
openAutonomySection();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
|
||||
target: { value: "" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
const call = mutateAsync.mock.calls[0][0] as {
|
||||
updates: { monthly_budget_usd?: number | null };
|
||||
};
|
||||
expect(call.updates.monthly_budget_usd).toBeNull();
|
||||
});
|
||||
|
||||
it("submits a positive cap as a number", async () => {
|
||||
renderDialog(makeProject({ monthly_budget_usd: null }));
|
||||
await screen.findByRole("button", { name: /Save Changes/i });
|
||||
openAutonomySection();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
|
||||
target: { value: "100" },
|
||||
});
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
const call = mutateAsync.mock.calls[0][0] as {
|
||||
updates: { monthly_budget_usd?: number | null };
|
||||
};
|
||||
expect(call.updates.monthly_budget_usd).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,6 +237,9 @@ function EditProjectForm({
|
||||
const [depUpdatePaths, setDepUpdatePaths] = useState(
|
||||
(project.dep_update_paths || []).join(", "),
|
||||
);
|
||||
const [monthlyBudgetUsd, setMonthlyBudgetUsd] = useState(
|
||||
project.monthly_budget_usd != null ? String(project.monthly_budget_usd) : "",
|
||||
);
|
||||
const sandboxServices = project.sandbox_services || [];
|
||||
const [sandboxSet, setSandboxSet] = useState<Set<string>>(
|
||||
new Set(sandboxServices),
|
||||
@@ -312,6 +315,15 @@ function EditProjectForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedBudget = monthlyBudgetUsd.trim();
|
||||
const parsedBudget = trimmedBudget ? Number(trimmedBudget) : null;
|
||||
if (trimmedBudget && (Number.isNaN(parsedBudget) || parsedBudget! <= 0)) {
|
||||
toast.error(
|
||||
"Monthly budget must be greater than 0 — leave it empty for no cap",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
const updates: ProjectUpdate = {
|
||||
name,
|
||||
@@ -342,6 +354,9 @@ function EditProjectForm({
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
// Sent explicitly (never coerced to undefined) so clearing the input
|
||||
// actually clears the stored cap instead of being dropped.
|
||||
monthly_budget_usd: parsedBudget,
|
||||
sandbox_services: [...sandboxSet],
|
||||
sandbox_extensions: (() => {
|
||||
const extObj: Record<string, string[]> = {};
|
||||
@@ -800,6 +815,26 @@ function EditProjectForm({
|
||||
|
||||
{showAutonomy && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Calendar-month cap on this project's summed agent-spawn spend; a claim is refused once reached. Requires the task-budgets flag armed fleet-wide (ROBOCO_TASK_BUDGETS_ENABLED). Leave blank for no cap.">
|
||||
<Label htmlFor="monthly_budget_usd">Monthly Budget (USD)</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="monthly_budget_usd"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={monthlyBudgetUsd}
|
||||
onChange={(e) => setMonthlyBudgetUsd(e.target.value)}
|
||||
placeholder="No cap"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Claims are refused once this month's spend reaches the cap.
|
||||
Must be greater than 0 — a 0 budget would block every claim
|
||||
immediately. Leave blank for no cap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<HelpTip label="Opens a fix task automatically when this repo's default-branch CI goes red. Also requires the CI-watch engine armed fleet-wide (ROBOCO_CI_WATCH_ENABLED) to actually run.">
|
||||
<Label htmlFor="ci_watch_enabled">
|
||||
|
||||
@@ -176,4 +176,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
|
||||
expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// task_budgets_enabled joined FLAG_DESCRIPTIONS + FLAG_TOOLTIPS together —
|
||||
// guard both stay in sync the same way the vault/docs-sync case above does.
|
||||
it("renders the description and tooltip for task_budgets_enabled", async () => {
|
||||
getFeatureFlags.mockResolvedValueOnce({
|
||||
flags: [
|
||||
{
|
||||
key: "task_budgets_enabled",
|
||||
label: "Task/project cost budgets",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
note: "Changes take effect on the next backend restart.",
|
||||
});
|
||||
render(withQueryClient(<FeatureFlagsCard />));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/per-project monthly and per-task cost caps/i),
|
||||
).toBeInTheDocument();
|
||||
const label = screen.getByText("Task/project cost budgets");
|
||||
expect(label.getAttribute("data-state")).toBe("closed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
||||
"Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.",
|
||||
possibilities_matrix_enabled:
|
||||
"When a task's work is already done (commits + open PR + all acceptance criteria addressed + no open findings), submit it for QA in one i_am_done call instead of 3-6 turns — skips the retroactive plan, journal tracing, and local quality (CI-green proxy) gates. Off by default: the standard path is unchanged until you arm this.",
|
||||
task_budgets_enabled:
|
||||
"Enforce per-project monthly and per-task cost caps (USD). A claim is refused once a project's monthly budget is reached; an active task whose own budget (or its task-type default) is breached is stopped and blocked, and you're notified. Set the caps on the project edit dialog and a task's detail page — a project/task with no cap set is unaffected either way.",
|
||||
rag_auto_update_enabled:
|
||||
"Keep the knowledge base index refreshed automatically.",
|
||||
transcript_prune_enabled:
|
||||
@@ -126,6 +128,8 @@ const FLAG_TOOLTIPS: Record<string, string> = {
|
||||
conventions_enabled: "Enforces each project's architectural placement rules.",
|
||||
possibilities_matrix_enabled:
|
||||
"Fast-paths work that's already been done elsewhere.",
|
||||
task_budgets_enabled:
|
||||
"Caps agent spend per project (monthly) and per task.",
|
||||
rag_auto_update_enabled:
|
||||
"Keeps the RAG knowledge index automatically refreshed.",
|
||||
transcript_prune_enabled:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mutateAsync } = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useUpdateTask: () => ({ mutateAsync, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
vi.mock("@/components/agents/agent-selector", () => ({
|
||||
AgentSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/projects/project-selector", () => ({
|
||||
ProjectSelector: () => null,
|
||||
}));
|
||||
vi.mock("../markdown-editor", () => ({ MarkdownEditor: () => null }));
|
||||
vi.mock("../acceptance-criteria-editor", () => ({
|
||||
AcceptanceCriteriaEditor: () => null,
|
||||
}));
|
||||
|
||||
// Collapsible: always render children open, so the Budget field (inside
|
||||
// Advanced Options) is reachable without simulating the toggle click.
|
||||
vi.mock("@/components/ui/collapsible", () => ({
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
CollapsibleTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
CollapsibleContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
// Select: a native <select> stub, matching create-task-dialog's convention.
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
import { EditTaskDialog } from "../edit-task-dialog";
|
||||
import { mockTasks } from "@/lib/mock-data";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const task = mockTasks[0];
|
||||
|
||||
function submit() {
|
||||
fireEvent.submit(document.querySelector("form")!);
|
||||
}
|
||||
|
||||
function budgetInput(): HTMLInputElement {
|
||||
return screen.getByPlaceholderText("Task-type default") as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("EditTaskDialog — Budget (USD) input", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty when the task has no budget_usd", () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: null }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(budgetInput().value).toBe("");
|
||||
});
|
||||
|
||||
it("pre-fills the stored budget_usd", () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: 3.5 }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(budgetInput().value).toBe("3.5");
|
||||
});
|
||||
|
||||
it("rejects 0 with an inline error and does not submit", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: null }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(budgetInput(), { target: { value: "0" } });
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/greater than 0/i),
|
||||
);
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a negative budget the same way", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: null }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(budgetInput(), { target: { value: "-1" } });
|
||||
submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/greater than 0/i),
|
||||
);
|
||||
});
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits null when left empty (use the task-type default)", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: 3.5 }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(budgetInput(), { target: { value: "" } });
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const { updates } = mutateAsync.mock.calls[0][0] as {
|
||||
updates: Record<string, unknown>;
|
||||
};
|
||||
expect(updates.budget_usd).toBeNull();
|
||||
});
|
||||
|
||||
it("submits a positive budget as a number", async () => {
|
||||
render(
|
||||
<EditTaskDialog
|
||||
task={{ ...task, budget_usd: null }}
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(budgetInput(), { target: { value: "2.5" } });
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const { updates } = mutateAsync.mock.calls[0][0] as {
|
||||
updates: Record<string, unknown>;
|
||||
};
|
||||
expect(updates.budget_usd).toBe(2.5);
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,9 @@ function EditTaskDialogInner({
|
||||
? new Date(task.target_date).toISOString().slice(0, 16)
|
||||
: "",
|
||||
);
|
||||
const [budgetUsd, setBudgetUsd] = useState<string>(
|
||||
task.budget_usd != null ? String(task.budget_usd) : "",
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const updateTask = useUpdateTask();
|
||||
@@ -143,6 +146,15 @@ function EditTaskDialogInner({
|
||||
}
|
||||
setAcError(undefined);
|
||||
|
||||
const trimmedBudget = budgetUsd.trim();
|
||||
const parsedBudget = trimmedBudget ? Number(trimmedBudget) : null;
|
||||
if (trimmedBudget && (Number.isNaN(parsedBudget) || parsedBudget! <= 0)) {
|
||||
toast.error(
|
||||
"Budget must be greater than 0 — leave it empty for the task-type default",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedCriteria = acceptanceCriteria
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
@@ -164,6 +176,7 @@ function EditTaskDialogInner({
|
||||
project_id: projectId,
|
||||
assigned_to: assignedTo,
|
||||
target_date: targetDate ? new Date(targetDate).toISOString() : null,
|
||||
budget_usd: parsedBudget,
|
||||
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
|
||||
},
|
||||
});
|
||||
@@ -338,6 +351,26 @@ function EditTaskDialogInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Budget (USD) */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Caps this task's own agent-spawn spend; only enforced when the task-budgets feature flag is on. Empty = use the task-type default.">
|
||||
<Label>Budget (USD)</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="Task-type default"
|
||||
value={budgetUsd}
|
||||
onChange={(e) => setBudgetUsd(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be greater than 0 — a 0 budget would block the task
|
||||
before it spends a cent. Leave blank for the task-type
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Git Configuration Section */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -174,6 +174,7 @@ export const projectsApi = {
|
||||
video_engine_enabled: false,
|
||||
dep_update_command: null,
|
||||
dep_update_paths: null,
|
||||
monthly_budget_usd: null,
|
||||
sandbox_services: null,
|
||||
sandbox_extensions: null,
|
||||
workspace_path: null,
|
||||
|
||||
@@ -224,6 +224,8 @@ export interface Task {
|
||||
acceptance_criteria: string[];
|
||||
status: TaskStatus;
|
||||
priority: number; // 0=P0(highest), 1=P1, 2=P2, 3=P3(lowest)
|
||||
// Cost cap (ROBOCO_TASK_BUDGETS_ENABLED). null = use the task-type default.
|
||||
budget_usd?: number | null;
|
||||
sequence: number; // Order number within siblings
|
||||
team: Team;
|
||||
created_by: string;
|
||||
@@ -1059,6 +1061,9 @@ export interface Project {
|
||||
video_engine_enabled: boolean;
|
||||
dep_update_command: string | null;
|
||||
dep_update_paths: string[] | null;
|
||||
// Calendar-month cap on summed agent-spawn spend across this project's
|
||||
// tasks; null = no cap. Only enforced when ROBOCO_TASK_BUDGETS_ENABLED is on.
|
||||
monthly_budget_usd: number | null;
|
||||
sandbox_services: string[] | null;
|
||||
sandbox_extensions: Record<string, string[]> | null;
|
||||
// Runtime state
|
||||
@@ -1123,6 +1128,8 @@ export interface ProjectUpdate {
|
||||
video_engine_enabled?: boolean;
|
||||
dep_update_command?: string;
|
||||
dep_update_paths?: string[];
|
||||
// null clears the cap (no cap); omitted leaves unchanged.
|
||||
monthly_budget_usd?: number | null;
|
||||
sandbox_services?: string[];
|
||||
sandbox_extensions?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user