Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e75740a44 | |||
| e6e9731bf3 | |||
| 221b93faec | |||
| 9344d014fb | |||
| 5cc570a18f | |||
| 2be87e66a9 | |||
| a44f98e177 | |||
| ee2d62f679 | |||
| cb22373549 | |||
| 1df028f05b | |||
| b6373e7026 | |||
| bb576c30d2 | |||
| 2357aec4bf | |||
| cace2eb7d3 | |||
| 231fb5ddab | |||
| 01087ddbe7 |
@@ -4,7 +4,7 @@
|
||||
# use this Makefile; CI calls docker compose / go test directly so the
|
||||
# Makefile can evolve without breaking the build.
|
||||
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
|
||||
|
||||
help: ## Show this help.
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -36,3 +36,23 @@ test: ## Run Go unit tests in workspace-server/.
|
||||
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
|
||||
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
|
||||
bash tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
|
||||
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
|
||||
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
|
||||
# swaggo annotations on the gin handlers. Commit the output. CI runs
|
||||
# `make openapi-spec-check` to assert no drift between annotations and
|
||||
# the committed file — if a PR changes a handler but forgets to
|
||||
# regenerate, CI fails with a diff.
|
||||
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
|
||||
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd workspace-server && swag init \
|
||||
--generalInfo cmd/server/main.go \
|
||||
--output docs/openapi \
|
||||
--outputTypes yaml,json \
|
||||
--dir . \
|
||||
--parseDependency=false \
|
||||
--parseInternal=true
|
||||
|
||||
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
|
||||
@git diff --exit-code -- workspace-server/docs/openapi/ \
|
||||
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
|
||||
|
||||
@@ -9,6 +9,8 @@ import { DetailsTab } from "./tabs/DetailsTab";
|
||||
import { SkillsTab } from "./tabs/SkillsTab";
|
||||
import { ChatTab } from "./tabs/ChatTab";
|
||||
import { ConfigTab } from "./tabs/ConfigTab";
|
||||
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
|
||||
import { DisplayTab } from "./tabs/DisplayTab";
|
||||
import { TerminalTab } from "./tabs/TerminalTab";
|
||||
import { FilesTab } from "./tabs/FilesTab";
|
||||
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
|
||||
@@ -31,6 +33,8 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "details", label: "Details", icon: "◉" },
|
||||
{ id: "skills", label: "Plugins", icon: "✦" },
|
||||
{ id: "terminal", label: "Terminal", icon: "▸" },
|
||||
{ id: "display", label: "Display", icon: "▣" },
|
||||
{ id: "container-config", label: "Container", icon: "▤" },
|
||||
{ id: "config", label: "Config", icon: "⚙" },
|
||||
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
||||
{ id: "channels", label: "Channels", icon: "⇌" },
|
||||
@@ -300,6 +304,8 @@ export function SidePanel() {
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
|
||||
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
|
||||
@@ -74,7 +76,7 @@ import { SidePanel } from "../SidePanel";
|
||||
|
||||
const TABS = [
|
||||
"chat", "activity", "details", "skills", "terminal",
|
||||
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
];
|
||||
|
||||
describe("SidePanel — ARIA tablist pattern", () => {
|
||||
@@ -85,10 +87,20 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
|
||||
});
|
||||
|
||||
it("renders exactly 13 tab buttons", () => {
|
||||
it("renders exactly 15 tab buttons", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBe(13);
|
||||
expect(tabs.length).toBe(15);
|
||||
});
|
||||
|
||||
it("renders the Display tab", () => {
|
||||
render(<SidePanel />);
|
||||
expect(document.getElementById("tab-display")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Container Config tab", () => {
|
||||
render(<SidePanel />);
|
||||
expect(document.getElementById("tab-container-config")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("active tab (chat) has aria-selected='true'", () => {
|
||||
@@ -99,11 +111,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("all other 12 tabs have aria-selected='false'", () => {
|
||||
it("all other 14 tabs have aria-selected='false'", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const inactive = tabs.filter((t) => t.id !== "tab-chat");
|
||||
expect(inactive.length).toBe(12);
|
||||
expect(inactive.length).toBe(14);
|
||||
for (const tab of inactive) {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("false");
|
||||
}
|
||||
@@ -116,7 +128,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
|
||||
expect(zeros.length).toBe(1);
|
||||
expect(zeros[0].id).toBe("tab-chat");
|
||||
expect(minusOnes.length).toBe(12);
|
||||
expect(minusOnes.length).toBe(14);
|
||||
});
|
||||
|
||||
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
type Props = {
|
||||
data: Pick<
|
||||
WorkspaceNodeData,
|
||||
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
|
||||
| "workspaceAccess" | "maxConcurrentTasks"
|
||||
>;
|
||||
};
|
||||
|
||||
export function ContainerConfigTab({ data }: Props) {
|
||||
const runtime = data.runtime || "unknown";
|
||||
const workspaceAccess = formatAccess(data.workspaceAccess);
|
||||
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
|
||||
const mountedPath = "/workspace";
|
||||
const privilegeStatus = "standard";
|
||||
const deliveryMode = data.deliveryMode || "push";
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
|
||||
<ConfigRow label="Workspace access" value={workspaceAccess} />
|
||||
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
|
||||
<ConfigRow label="Mounted workspace path" value={mountedPath} />
|
||||
<ConfigRow label="Container privileges" value={privilegeStatus} />
|
||||
<ConfigRow label="Delivery mode" value={deliveryMode} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
|
||||
<ReadOnlyAction label="Reset session" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Container status" value={data.status} />
|
||||
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
|
||||
<ConfigRow label="Mounted path access" value="available" />
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAccess(value: string | null | undefined): string {
|
||||
if (!value) return "none";
|
||||
return value.replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
|
||||
<dt className="text-ink-mid">{label}</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
<div className="font-mono text-ink break-words">{value}</div>
|
||||
{detail && detail !== value && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyAction({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface DisplayStatus {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function DisplayTab({ workspaceId }: Props) {
|
||||
const [status, setStatus] = useState<DisplayStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<DisplayStatus>(`/workspaces/${workspaceId}/display`)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStatus(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
|
||||
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status.available) {
|
||||
const isNotEnabled = status.reason === "display_not_enabled";
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mb-4 text-ink-mid"
|
||||
>
|
||||
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
|
||||
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">
|
||||
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
|
||||
</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
{isNotEnabled
|
||||
? "Recreate this workspace with display enabled to view and take over its desktop."
|
||||
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
|
||||
</p>
|
||||
{!isNotEnabled && (
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
|
||||
<dt className="text-ink-mid">Mode</dt>
|
||||
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
|
||||
<dt className="text-ink-mid">Status</dt>
|
||||
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/runtime-names", () => ({
|
||||
runtimeDisplayName: (runtime: string) => runtime,
|
||||
}));
|
||||
|
||||
import { ContainerConfigTab } from "../ContainerConfigTab";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ContainerConfigTab", () => {
|
||||
it("renders read-only runtime and container settings separate from compute shape", () => {
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 2,
|
||||
maxConcurrentTasks: 3,
|
||||
workspaceAccess: "read_write",
|
||||
deliveryMode: "poll",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Runtime image")).toBeTruthy();
|
||||
expect(screen.getByText("claude-code")).toBeTruthy();
|
||||
expect(screen.getByText("Workspace access")).toBeTruthy();
|
||||
expect(screen.getByText("read-write")).toBeTruthy();
|
||||
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(screen.getByText("/workspace")).toBeTruthy();
|
||||
expect(screen.getByText("Container privileges")).toBeTruthy();
|
||||
expect(screen.queryByText("Instance type")).toBeNull();
|
||||
expect(screen.queryByText("Root volume")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockGet,
|
||||
},
|
||||
}));
|
||||
|
||||
import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_not_enabled",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-no-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
|
||||
});
|
||||
});
|
||||
@@ -513,6 +513,8 @@ export function buildNodesAndEdges(
|
||||
parentId: ws.parent_id,
|
||||
currentTask: ws.current_task || "",
|
||||
runtime: ws.runtime || "",
|
||||
workspaceAccess: ws.workspace_access,
|
||||
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
|
||||
needsRestart: false,
|
||||
budgetLimit: ws.budget_limit ?? null,
|
||||
budgetUsed: ws.budget_used ?? null,
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
parentId: string | null;
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
workspaceAccess?: string | null;
|
||||
maxConcurrentTasks?: number | null;
|
||||
needsRestart: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budgetLimit: number | null;
|
||||
@@ -130,7 +132,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
deliveryMode?: string;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
|
||||
@@ -320,11 +320,13 @@ export interface WorkspaceData {
|
||||
url: string;
|
||||
parent_id: string | null;
|
||||
active_tasks: number;
|
||||
max_concurrent_tasks?: number | null;
|
||||
last_error_rate: number;
|
||||
last_sample_error: string;
|
||||
uptime_seconds: number;
|
||||
current_task: string;
|
||||
runtime: string;
|
||||
workspace_access?: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
# Optional env:
|
||||
# E2E_RUNTIME hermes (default) | claude-code | langgraph
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
|
||||
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
|
||||
# cold-boot worst-case + slack). Raised from
|
||||
# 1800 (#1646) because flaky tenant-provisioning
|
||||
# latency (not a code regression) causes
|
||||
# alternating pass/fail on identical SHAs.
|
||||
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
|
||||
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
|
||||
# E2E_MODE full (default) | smoke
|
||||
@@ -56,6 +61,7 @@ CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
RUNTIME="${E2E_RUNTIME:-hermes}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
|
||||
WORKSPACE_ONLINE_TIMEOUT_SECS="${E2E_WORKSPACE_ONLINE_TIMEOUT_SECS:-3600}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
MODE="${E2E_MODE:-full}"
|
||||
# `canary` is a legacy alias for `smoke` retained for back-compat with
|
||||
@@ -363,7 +369,7 @@ print(s[:4000])
|
||||
|
||||
wait_workspaces_online_routable() {
|
||||
local label="$1"; shift
|
||||
local deadline=$(( $(date +%s) + 1800 ))
|
||||
local deadline=$(( $(date +%s) + WORKSPACE_ONLINE_TIMEOUT_SECS ))
|
||||
local wid ws_last_status ws_last_url ws_url_missing_logged ws_failed_logged
|
||||
local ws_json ws_status ws_url ws_last_err
|
||||
|
||||
@@ -377,7 +383,7 @@ wait_workspaces_online_routable() {
|
||||
if [ "$(date +%s)" -gt "$deadline" ]; then
|
||||
ws_last_err=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
|
||||
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid never reached online with a routable URL within 30 min (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
|
||||
fail "Workspace $wid never reached online with a routable URL within ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min) (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
|
||||
fi
|
||||
ws_json=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
|
||||
ws_status=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
|
||||
@@ -526,14 +532,16 @@ fi
|
||||
# deadline fires at 5 min and sets status=failed prematurely; heartbeat
|
||||
# then transitions failed → online after install.sh finishes. So:
|
||||
#
|
||||
# - 30 min deadline (hermes worst-case + slack)
|
||||
# - ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min)
|
||||
# deadline (hermes worst-case + slack). Configurable via
|
||||
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS (#1646).
|
||||
# - 'failed' is a TRANSIENT state we must tolerate — log and keep
|
||||
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
|
||||
# (controlplane#245) this was a flake generator: workspace went
|
||||
# failed→online inside our window but we bailed at the failed read.
|
||||
WS_TO_CHECK=("$PARENT_ID")
|
||||
[ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID")
|
||||
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
|
||||
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
|
||||
|
||||
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
|
||||
# This step exists because the canvas-terminal failure of 2026-05-03
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
// Package main runs the per-tenant workspace-server.
|
||||
//
|
||||
// @title Molecule AI Workspace Server API
|
||||
// @version 1.0
|
||||
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
|
||||
// @host api.moleculesai.app
|
||||
// @BasePath /
|
||||
// @schemes https
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
|
||||
//
|
||||
// @securityDefinitions.apikey OrgSlugAuth
|
||||
// @in header
|
||||
// @name X-Molecule-Org-Slug
|
||||
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
|
||||
//
|
||||
// @securityDefinitions.apikey OrgIdAuth
|
||||
// @in header
|
||||
// @name X-Molecule-Org-Id
|
||||
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"schemes": [
|
||||
"https"
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
|
||||
"title": "Molecule AI Workspace Server API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "api.moleculesai.app",
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/workspaces/{id}/schedules": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "List schedules for a workspace",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.ScheduleResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Create a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Schedule fields",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateScheduleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateScheduleResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Delete a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.StatusResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Update a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Partial schedule fields (only provided keys are updated)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ScheduleResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Get past runs of a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.HistoryEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}/run": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Fire a schedule manually",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.RunNowResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"handlers.CreateScheduleRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cron_expr",
|
||||
"prompt"
|
||||
],
|
||||
"properties": {
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateScheduleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"next_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.HistoryEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"error_detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"request": {
|
||||
"type": "object"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.RunNowResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ScheduleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"next_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"run_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.StatusResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UpdateScheduleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
"OrgIdAuth": {
|
||||
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
|
||||
"type": "apiKey",
|
||||
"name": "X-Molecule-Org-Id",
|
||||
"in": "header"
|
||||
},
|
||||
"OrgSlugAuth": {
|
||||
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
|
||||
"type": "apiKey",
|
||||
"name": "X-Molecule-Org-Slug",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
handlers.CreateScheduleRequest:
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
required:
|
||||
- cron_expr
|
||||
- prompt
|
||||
type: object
|
||||
handlers.CreateScheduleResponse:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
next_run_at:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
handlers.HistoryEntry:
|
||||
properties:
|
||||
duration_ms:
|
||||
type: integer
|
||||
error_detail:
|
||||
type: string
|
||||
request:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
type: object
|
||||
handlers.RunNowResponse:
|
||||
properties:
|
||||
prompt:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ScheduleResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
last_error:
|
||||
type: string
|
||||
last_run_at:
|
||||
type: string
|
||||
last_status:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
next_run_at:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
run_count:
|
||||
type: integer
|
||||
source:
|
||||
description: '''template'' (seeded by org/import) | ''runtime'' (created via
|
||||
Canvas/API). Issue #24.'
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.StatusResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.UpdateScheduleRequest:
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
type: object
|
||||
host: api.moleculesai.app
|
||||
info:
|
||||
contact: {}
|
||||
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
|
||||
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
|
||||
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
|
||||
clients generated from this spec — see RFC #1706.'
|
||||
title: Molecule AI Workspace Server API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/workspaces/{id}/schedules:
|
||||
get:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.ScheduleResponse'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: List schedules for a workspace
|
||||
tags:
|
||||
- schedules
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule fields
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateScheduleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateScheduleResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Create a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.StatusResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Delete a schedule
|
||||
tags:
|
||||
- schedules
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
- description: Partial schedule fields (only provided keys are updated)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UpdateScheduleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ScheduleResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Update a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}/history:
|
||||
get:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.HistoryEntry'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Get past runs of a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}/run:
|
||||
post:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.RunNowResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Fire a schedule manually
|
||||
tags:
|
||||
- schedules
|
||||
schemes:
|
||||
- https
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
|
||||
platform's signup/SSO flow.
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
OrgIdAuth:
|
||||
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
|
||||
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
|
||||
in: header
|
||||
name: X-Molecule-Org-Id
|
||||
type: apiKey
|
||||
OrgSlugAuth:
|
||||
description: Tenant routing header — required on every /workspaces/{id}/* request
|
||||
so the platform edge can route to the correct per-tenant workspace-server. Either
|
||||
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
|
||||
(UUID) must be sent; slug is preferred for client code.
|
||||
in: header
|
||||
name: X-Molecule-Org-Slug
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
@@ -71,35 +71,30 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// with 202 status here was the original cycle 53 bug — callers saw
|
||||
// proxyErr != nil and logged "delegation failed: proxy a2a error".
|
||||
if isUpstreamBusyError(err) {
|
||||
// Capability primitive #5 — see project memory
|
||||
// `project_runtime_native_pluggable.md`. When the target workspace's
|
||||
// adapter has declared provides_native_session=True, the SDK
|
||||
// owns its own queue/session state (claude-agent-sdk's streaming
|
||||
// session, hermes-agent's in-container event log, etc.). Adding
|
||||
// the platform's a2a_queue layer on top would double-buffer the
|
||||
// same in-flight state — and worse, the platform queue's drain
|
||||
// timing has no relationship to the SDK's actual readiness, so
|
||||
// the queued request might dispatch while the SDK is STILL busy.
|
||||
// #1684 / Reno Stars: native_session adapters previously took a
|
||||
// 503-no-enqueue path here, on the assumption that the SDK owned
|
||||
// an inbound queue and the platform a2a_queue would double-buffer.
|
||||
// In practice, the common native_session SDKs (claude-agent-sdk,
|
||||
// codex app-server, hermes-agent) do NOT have an inbound queue —
|
||||
// new turns can only be pushed via the same HTTP POST that just
|
||||
// returned busy. So cron fires (and any A2A retry) bounce 503
|
||||
// every tick until the SDK voluntarily yields. Reno Stars #1684
|
||||
// observed 12 consecutive `*/30` cron fires lost over 6h while a
|
||||
// single native_session held the slot.
|
||||
//
|
||||
// For native_session targets, return 503 + Retry-After directly.
|
||||
// The caller's adapter handles retry on its own schedule, and
|
||||
// the SDK's own queue absorbs the in-flight request when it does.
|
||||
// Observability is preserved: logA2AFailure already ran above;
|
||||
// activity_logs records the busy event; the broadcaster fires.
|
||||
if runtimeOverrides.HasCapability(workspaceID, "session") {
|
||||
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
Response: gin.H{
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The original concern — "drain timing has no relationship to SDK
|
||||
// readiness" — turns out to be unfounded: heartbeat→drain is
|
||||
// gated by `payload.ActiveTasks < maxConcurrent` in
|
||||
// registry.go:Heartbeat, so drain only fires when the workspace
|
||||
// itself reports spare capacity. That IS the session-ended
|
||||
// signal. The native_session SDK reports ActiveTasks=1 while in a
|
||||
// turn, ActiveTasks=0 when idle, and the next heartbeat after
|
||||
// idle triggers DrainQueueForWorkspace.
|
||||
//
|
||||
// So we collapse the two branches: both native_session and
|
||||
// non-native callers enqueue here. The native_session SDK's own
|
||||
// in-flight POST stays unaffected; the queued item drains on the
|
||||
// next post-idle heartbeat.
|
||||
idempotencyKey := extractIdempotencyKey(body)
|
||||
// Honor params.expires_in_seconds when the caller specifies one. Zero
|
||||
// (the unset default) → expiresAt = nil → infinite TTL preserved by
|
||||
|
||||
@@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
|
||||
// 24 cols — compute added after talk_to_user_enabled.
|
||||
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
|
||||
columns := []string{
|
||||
"id", "name", "role", "tier", "status", "agent_card", "url",
|
||||
@@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
||||
"last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
rows := sqlmock.NewRows(columns).
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
|
||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
|
||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
|
||||
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WillReturnRows(rows)
|
||||
|
||||
@@ -456,7 +456,7 @@ func TestWorkspaceList(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
|
||||
// 24 cols: compute added after talk_to_user_enabled.
|
||||
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
|
||||
columns := []string{
|
||||
"id", "name", "role", "tier", "status", "agent_card", "url",
|
||||
@@ -464,13 +464,13 @@ func TestWorkspaceList(t *testing.T) {
|
||||
"last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
rows := sqlmock.NewRows(columns).
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
|
||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
|
||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
|
||||
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WillReturnRows(rows)
|
||||
@@ -1184,14 +1184,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("dddddddd-0004-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
|
||||
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
|
||||
nil, int64(0), false, true,
|
||||
nil, int64(0), false, true, []byte(`{}`),
|
||||
))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -6,18 +6,26 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandleA2ADispatchError_NativeSession_SkipsEnqueue validates capability
|
||||
// primitive #5: when the target workspace has declared
|
||||
// provides_native_session=True, a busy-shaped dispatch error MUST short-
|
||||
// circuit straight to 503 + Retry-After. The platform's a2a_queue is
|
||||
// skipped because the SDK owns its own queue/session state — double-
|
||||
// buffering would cause spurious dispatches when the SDK is still busy.
|
||||
// TestHandleA2ADispatchError_NativeSession_NowEnqueues validates the #1684
|
||||
// fix: native_session adapters used to short-circuit to 503-no-queue here,
|
||||
// on the assumption that the SDK owned an inbound queue. In practice the
|
||||
// common native_session SDKs (claude-agent-sdk, codex app-server, hermes)
|
||||
// don't — new turns arrive only via the same HTTP POST that returns busy.
|
||||
// So cron fires bounced 503 every tick until the SDK voluntarily yielded;
|
||||
// Reno Stars #1684 observed 12 consecutive `*/30` cron fires lost over 6h.
|
||||
//
|
||||
// Pin via sqlmock: we deliberately do NOT expect any INSERT INTO a2a_queue.
|
||||
// If a future refactor re-introduces enqueueing under native_session,
|
||||
// sqlmock fails the test on the unexpected query.
|
||||
func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
// Post-fix: native_session and non-native both enqueue. Drain timing is
|
||||
// gated by registry.go:Heartbeat (`payload.ActiveTasks < maxConcurrent`)
|
||||
// so the queued item only dispatches when the SDK reports spare capacity
|
||||
// — i.e. the next heartbeat after the in-flight turn returns.
|
||||
//
|
||||
// This test pins the new behavior: native_session capability DOES NOT
|
||||
// bypass EnqueueA2A. We expect the INSERT INTO a2a_queue query to fire,
|
||||
// here arranged to fail so we can observe the legacy 503 fallback (and
|
||||
// thereby confirm the INSERT was attempted; sqlmock fails the test if
|
||||
// the expected query never runs).
|
||||
func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
@@ -25,10 +33,15 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
|
||||
runtimeOverrides.SetCapabilities("ws-native", map[string]bool{"session": true})
|
||||
defer runtimeOverrides.Reset()
|
||||
|
||||
// DeadlineExceeded triggers isUpstreamBusyError. Without the native
|
||||
// gate, this would fire EnqueueA2A → INSERT INTO a2a_queue. With
|
||||
// the gate, it short-circuits to 503. We expect ZERO queue queries;
|
||||
// sqlmock's ExpectationsWereMet implicitly enforces that on teardown.
|
||||
// We now EXPECT the INSERT to fire even with native_session=true. Make
|
||||
// it fail so the handler falls through to the legacy 503 path — that
|
||||
// lets us assert (1) enqueue was attempted, (2) the response on
|
||||
// queue-failure does NOT carry native_session=true marker (that field
|
||||
// was removed alongside the gate).
|
||||
mock.ExpectQuery(`INSERT INTO a2a_queue`).
|
||||
WithArgs("ws-native", nil, PriorityTask, "{}", "message/send", nil).
|
||||
WillReturnError(errTestQueueUnavailable)
|
||||
|
||||
_, _, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-native", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 1, false,
|
||||
@@ -37,28 +50,27 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
|
||||
t.Fatal("expected proxy error, got nil")
|
||||
}
|
||||
if perr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("got status %d, want 503 (native_session bypasses queue but still 503s)", perr.Status)
|
||||
t.Errorf("got status %d, want 503 (enqueue failed → legacy 503 fallback)", perr.Status)
|
||||
}
|
||||
if perr.Headers["Retry-After"] == "" {
|
||||
t.Error("expected Retry-After header on native-session 503")
|
||||
t.Error("expected Retry-After header on busy-503")
|
||||
}
|
||||
// Pin the marker so callers' adapters can distinguish this from a
|
||||
// queue-failure 503: the body has native_session=true.
|
||||
if got, _ := perr.Response["native_session"].(bool); !got {
|
||||
t.Errorf("expected native_session=true in response body; got %+v", perr.Response)
|
||||
// The native_session marker was removed from the response body — the
|
||||
// platform queues both kinds now, callers no longer distinguish. Pin
|
||||
// its absence so a future revert is caught.
|
||||
if got, ok := perr.Response["native_session"].(bool); ok && got {
|
||||
t.Errorf("native_session marker should be gone after #1684 fix; got %+v", perr.Response)
|
||||
}
|
||||
// And busy=true stays so existing busy-handling code paths still trigger.
|
||||
if got, _ := perr.Response["busy"].(bool); !got {
|
||||
t.Errorf("expected busy=true in response body; got %+v", perr.Response)
|
||||
t.Errorf("expected busy=true; got %+v", perr.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues is the negative
|
||||
// pin: a workspace WITHOUT the capability flag falls through to the
|
||||
// existing EnqueueA2A path (and 503 if that fails). Same shape as
|
||||
// TestHandleA2ADispatchError_ContextDeadline; we duplicate it here so
|
||||
// the native_session gate change is bracketed by both positive and
|
||||
// negative tests in the same file.
|
||||
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues — non-native
|
||||
// behavior is unchanged: enqueue is attempted, fail-fallback to 503. This
|
||||
// negative pin guards against accidentally reverting the unification by
|
||||
// re-introducing a `if HasCapability(...)` gate that would short-circuit
|
||||
// the enqueue path.
|
||||
func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -79,13 +91,11 @@ func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
|
||||
if perr == nil {
|
||||
t.Fatal("expected proxy error, got nil")
|
||||
}
|
||||
// Queue insert failed → falls through to legacy 503 (without
|
||||
// native_session marker).
|
||||
if perr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("got status %d, want 503", perr.Status)
|
||||
}
|
||||
if got, _ := perr.Response["native_session"].(bool); got {
|
||||
t.Errorf("non-native workspace should NOT carry native_session=true in response; got %+v", perr.Response)
|
||||
t.Errorf("non-native workspace should NOT carry native_session=true; got %+v", perr.Response)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,46 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
|
||||
)
|
||||
|
||||
// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.)
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// StatusResponse is returned by mutating endpoints that only echo a status verb.
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules.
|
||||
type CreateScheduleResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
NextRunAt time.Time `json:"next_run_at"`
|
||||
}
|
||||
|
||||
// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run.
|
||||
type RunNowResponse struct {
|
||||
Status string `json:"status"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history.
|
||||
type HistoryEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
Status *string `json:"status"`
|
||||
ErrorDetail string `json:"error_detail"`
|
||||
Request json.RawMessage `json:"request" swaggertype:"object"`
|
||||
}
|
||||
|
||||
type ScheduleHandler struct{}
|
||||
|
||||
func NewScheduleHandler() *ScheduleHandler {
|
||||
return &ScheduleHandler{}
|
||||
}
|
||||
|
||||
type scheduleResponse struct {
|
||||
type ScheduleResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -40,6 +73,15 @@ type scheduleResponse struct {
|
||||
}
|
||||
|
||||
// List returns all schedules for a workspace.
|
||||
//
|
||||
// @Summary List schedules for a workspace
|
||||
// @Tags schedules
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Success 200 {array} ScheduleResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules [get]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
@@ -58,9 +100,9 @@ func (h *ScheduleHandler) List(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
schedules := make([]scheduleResponse, 0)
|
||||
schedules := make([]ScheduleResponse, 0)
|
||||
for rows.Next() {
|
||||
var s scheduleResponse
|
||||
var s ScheduleResponse
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone,
|
||||
&s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount,
|
||||
@@ -78,7 +120,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, schedules)
|
||||
}
|
||||
|
||||
type createScheduleRequest struct {
|
||||
type CreateScheduleRequest struct {
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr" binding:"required"`
|
||||
Timezone string `json:"timezone"`
|
||||
@@ -87,11 +129,23 @@ type createScheduleRequest struct {
|
||||
}
|
||||
|
||||
// Create adds a new schedule for a workspace.
|
||||
//
|
||||
// @Summary Create a schedule
|
||||
// @Tags schedules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param body body CreateScheduleRequest true "Schedule fields"
|
||||
// @Success 201 {object} CreateScheduleResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules [post]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body createScheduleRequest
|
||||
var body CreateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"})
|
||||
return
|
||||
@@ -145,7 +199,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type updateScheduleRequest struct {
|
||||
type UpdateScheduleRequest struct {
|
||||
Name *string `json:"name"`
|
||||
CronExpr *string `json:"cron_expr"`
|
||||
Timezone *string `json:"timezone"`
|
||||
@@ -155,12 +209,26 @@ type updateScheduleRequest struct {
|
||||
|
||||
// Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only
|
||||
// provided fields are changed — no dynamic SQL construction.
|
||||
//
|
||||
// @Summary Update a schedule
|
||||
// @Tags schedules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param scheduleId path string true "Schedule ID"
|
||||
// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)"
|
||||
// @Success 200 {object} ScheduleResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules/{scheduleId} [patch]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body updateScheduleRequest
|
||||
var body UpdateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
||||
return
|
||||
@@ -230,6 +298,17 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Delete removes a schedule.
|
||||
//
|
||||
// @Summary Delete a schedule
|
||||
// @Tags schedules
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param scheduleId path string true "Schedule ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules/{scheduleId} [delete]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) Delete(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
|
||||
@@ -252,6 +331,17 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RunNow manually fires a schedule immediately.
|
||||
//
|
||||
// @Summary Fire a schedule manually
|
||||
// @Tags schedules
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param scheduleId path string true "Schedule ID"
|
||||
// @Success 200 {object} RunNowResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules/{scheduleId}/run [post]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) RunNow(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id")
|
||||
@@ -282,6 +372,16 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
|
||||
}
|
||||
|
||||
// History returns recent runs for a schedule from activity_logs.
|
||||
//
|
||||
// @Summary Get past runs of a schedule
|
||||
// @Tags schedules
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param scheduleId path string true "Schedule ID"
|
||||
// @Success 200 {array} HistoryEntry
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/schedules/{scheduleId}/history [get]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *ScheduleHandler) History(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id")
|
||||
@@ -307,17 +407,9 @@ func (h *ScheduleHandler) History(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type historyEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
Status *string `json:"status"`
|
||||
ErrorDetail string `json:"error_detail"`
|
||||
Request json.RawMessage `json:"request"`
|
||||
}
|
||||
|
||||
entries := make([]historyEntry, 0)
|
||||
entries := make([]HistoryEntry, 0)
|
||||
for rows.Next() {
|
||||
var e historyEntry
|
||||
var e HistoryEntry
|
||||
var reqStr string
|
||||
if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil {
|
||||
continue
|
||||
@@ -329,11 +421,11 @@ func (h *ScheduleHandler) History(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// scheduleHealthResponse is the read-only health view of a schedule.
|
||||
// ScheduleHealthResponse is the read-only health view of a schedule.
|
||||
// It deliberately omits prompt and cron_expr so sensitive task content is
|
||||
// never exposed to peer workspaces — only execution-state fields needed to
|
||||
// detect silent cron failures are returned (issue #249).
|
||||
type scheduleHealthResponse struct {
|
||||
type ScheduleHealthResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -402,9 +494,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
schedules := make([]scheduleHealthResponse, 0)
|
||||
schedules := make([]ScheduleHealthResponse, 0)
|
||||
for rows.Next() {
|
||||
var s scheduleHealthResponse
|
||||
var s ScheduleHealthResponse
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt,
|
||||
&s.RunCount, &s.LastStatus, &s.LastError,
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
|
||||
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []scheduleHealthResponse
|
||||
var resp []ScheduleHealthResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
|
||||
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []scheduleHealthResponse
|
||||
var resp []ScheduleHealthResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
@@ -348,6 +348,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"})
|
||||
return
|
||||
}
|
||||
if err := validateWorkspaceCompute(payload.Compute); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Begin a transaction so the workspace row and any initial secrets are
|
||||
// committed atomically. A secret-encrypt or DB error rolls back the
|
||||
@@ -435,6 +439,24 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
payload.Name = persistedName
|
||||
}
|
||||
|
||||
if !workspaceComputeIsZero(payload.Compute) {
|
||||
computeJSON, encErr := workspaceComputeJSON(payload.Compute)
|
||||
if encErr != nil {
|
||||
tx.Rollback() //nolint:errcheck
|
||||
log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"})
|
||||
return
|
||||
}
|
||||
if _, dbErr := tx.ExecContext(ctx,
|
||||
`UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`,
|
||||
id, computeJSON); dbErr != nil {
|
||||
tx.Rollback() //nolint:errcheck
|
||||
log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist initial secrets from the create payload (inside same transaction).
|
||||
// nil/empty map is a no-op. Any failure rolls back the workspace insert
|
||||
// so we never have a workspace row without its intended secrets.
|
||||
@@ -679,6 +701,7 @@ func scanWorkspaceRow(rows interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (map[string]interface{}, error) {
|
||||
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
|
||||
var computeRaw []byte
|
||||
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
|
||||
var errorRate, x, y float64
|
||||
var collapsed, broadcastEnabled, talkToUserEnabled bool
|
||||
@@ -690,7 +713,7 @@ func scanWorkspaceRow(rows interface {
|
||||
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
|
||||
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
|
||||
¤tTask, &runtime, &workspaceDir, &x, &y, &collapsed,
|
||||
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
|
||||
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -717,6 +740,11 @@ func scanWorkspaceRow(rows interface {
|
||||
"broadcast_enabled": broadcastEnabled,
|
||||
"talk_to_user_enabled": talkToUserEnabled,
|
||||
}
|
||||
if len(computeRaw) > 0 && string(computeRaw) != "null" {
|
||||
ws["compute"] = json.RawMessage(computeRaw)
|
||||
} else {
|
||||
ws["compute"] = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
// budget_limit: nil when no limit set, int64 otherwise
|
||||
if budgetLimit.Valid {
|
||||
@@ -752,7 +780,8 @@ const workspaceListQuery = `
|
||||
COALESCE(w.workspace_dir, ''),
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
w.broadcast_enabled, w.talk_to_user_enabled
|
||||
w.broadcast_enabled, w.talk_to_user_enabled,
|
||||
COALESCE(w.compute, '{}'::jsonb)
|
||||
FROM workspaces w
|
||||
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
|
||||
WHERE w.status != 'removed'
|
||||
@@ -813,7 +842,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||
COALESCE(w.workspace_dir, ''),
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
w.broadcast_enabled, w.talk_to_user_enabled
|
||||
w.broadcast_enabled, w.talk_to_user_enabled,
|
||||
COALESCE(w.compute, '{}'::jsonb)
|
||||
FROM workspaces w
|
||||
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
|
||||
WHERE w.id = $1
|
||||
|
||||
@@ -33,7 +33,7 @@ var wsColumns = []string{
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
|
||||
// ==================== GET — financial fields stripped from open endpoint ====================
|
||||
@@ -56,7 +56,8 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
|
||||
nil, // budget_limit NULL
|
||||
0, // monthly_spend 0
|
||||
false, // broadcast_enabled
|
||||
true)) // talk_to_user_enabled
|
||||
true, // talk_to_user_enabled
|
||||
[]byte(`{}`)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -100,7 +101,8 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
|
||||
0.0, 0.0, false,
|
||||
int64(500), // budget_limit = $5.00 in DB
|
||||
int64(123), // monthly_spend = $1.23 in DB
|
||||
false, true)) // broadcast_enabled, talk_to_user_enabled
|
||||
false, true, // broadcast_enabled, talk_to_user_enabled
|
||||
[]byte(`{}`)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -145,18 +147,18 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // id
|
||||
"Budgeted Agent", // name
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"langgraph", // runtime
|
||||
sqlmock.AnyArg(), // awareness_namespace
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
&budgetVal, // budget_limit ($10)
|
||||
sqlmock.AnyArg(), // id
|
||||
"Budgeted Agent", // name
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"langgraph", // runtime
|
||||
sqlmock.AnyArg(), // awareness_namespace
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
&budgetVal, // budget_limit ($10)
|
||||
models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default
|
||||
"push", // delivery_mode default (#2339)
|
||||
"push", // delivery_mode default (#2339)
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
workspaceComputeDiskFloorGB = 30
|
||||
workspaceComputeDiskCeilingGB = 500
|
||||
)
|
||||
|
||||
type workspaceDisplayResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
var workspaceComputeInstanceAllowlist = map[string]struct{}{
|
||||
"t3.medium": {},
|
||||
"t3.large": {},
|
||||
"t3.xlarge": {},
|
||||
"t3.2xlarge": {},
|
||||
"m6i.large": {},
|
||||
"m6i.xlarge": {},
|
||||
"c6i.xlarge": {},
|
||||
}
|
||||
|
||||
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
|
||||
if compute.InstanceType != "" {
|
||||
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
|
||||
return fmt.Errorf("unsupported compute.instance_type")
|
||||
}
|
||||
}
|
||||
if compute.Volume.RootGB != 0 {
|
||||
if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB {
|
||||
return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB)
|
||||
}
|
||||
}
|
||||
switch compute.Display.Mode {
|
||||
case "", "none", "desktop-control", "gpu-desktop-control":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch compute.Display.Protocol {
|
||||
case "", "dcv":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if compute.Display.Width < 0 || compute.Display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) error {
|
||||
switch display.Mode {
|
||||
case "", "none", "desktop-control", "gpu-desktop-control":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch display.Protocol {
|
||||
case "", "dcv":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if display.Width < 0 || display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
|
||||
return compute.InstanceType == "" &&
|
||||
compute.Volume.RootGB == 0 &&
|
||||
compute.Display.Mode == "" &&
|
||||
compute.Display.Width == 0 &&
|
||||
compute.Display.Height == 0 &&
|
||||
compute.Display.Protocol == ""
|
||||
}
|
||||
|
||||
func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) {
|
||||
if workspaceComputeIsZero(compute) {
|
||||
return "{}", nil
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
if compute.InstanceType != "" {
|
||||
out["instance_type"] = compute.InstanceType
|
||||
}
|
||||
if compute.Volume.RootGB != 0 {
|
||||
out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB}
|
||||
}
|
||||
display := map[string]interface{}{}
|
||||
if compute.Display.Mode != "" {
|
||||
display["mode"] = compute.Display.Mode
|
||||
}
|
||||
if compute.Display.Width != 0 {
|
||||
display["width"] = compute.Display.Width
|
||||
}
|
||||
if compute.Display.Height != 0 {
|
||||
display["height"] = compute.Display.Height
|
||||
}
|
||||
if compute.Display.Protocol != "" {
|
||||
display["protocol"] = compute.Display.Protocol
|
||||
}
|
||||
if len(display) > 0 {
|
||||
out["display"] = display
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload {
|
||||
if !workspaceComputeIsZero(payload.Compute) || db.DB == nil {
|
||||
return payload
|
||||
}
|
||||
var raw string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
if raw == "" || raw == "{}" {
|
||||
return payload
|
||||
}
|
||||
var compute models.WorkspaceCompute
|
||||
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
|
||||
log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err)
|
||||
return payload
|
||||
}
|
||||
if err := validateWorkspaceCompute(compute); err != nil {
|
||||
log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err)
|
||||
return payload
|
||||
}
|
||||
payload.Compute = compute
|
||||
return payload
|
||||
}
|
||||
|
||||
// Display handles GET /workspaces/:id/display.
|
||||
//
|
||||
// Phase 1 only exposes the product contract and the non-display unavailable
|
||||
// state. Future desktop-control work will replace the display-enabled branch
|
||||
// with short-lived proxied DCV session details.
|
||||
func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
var raw string
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
|
||||
c.JSON(500, gin.H{"error": "failed to load display config"})
|
||||
return
|
||||
}
|
||||
var compute models.WorkspaceCompute
|
||||
if raw != "" && raw != "{}" {
|
||||
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
|
||||
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
|
||||
c.JSON(500, gin.H{"error": "invalid display config"})
|
||||
return
|
||||
}
|
||||
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
|
||||
log.Printf("Display: invalid stored compute for %s: %v", workspaceID, err)
|
||||
c.JSON(500, gin.H{"error": "invalid display config"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_not_enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_session_unavailable",
|
||||
Mode: compute.Display.Mode,
|
||||
Protocol: compute.Display.Protocol,
|
||||
Width: compute.Display.Width,
|
||||
Height: compute.Display.Height,
|
||||
Status: "not_configured",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) {
|
||||
compute := models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
Display: models.WorkspaceComputeDisplay{Mode: "none"},
|
||||
}
|
||||
|
||||
if err := validateWorkspaceCompute(compute); err != nil {
|
||||
t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
|
||||
compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"}
|
||||
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatal("validateWorkspaceCompute accepted unsupported instance type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
|
||||
for _, rootGB := range []int{29, 501} {
|
||||
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
|
||||
got, err := workspaceComputeJSON(models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("workspaceComputeJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(got, `"display"`) {
|
||||
t.Fatalf("workspaceComputeJSON included empty display section: %s", got)
|
||||
}
|
||||
if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` {
|
||||
t.Fatalf("workspaceComputeJSON = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{
|
||||
"name":"Sized Agent",
|
||||
"external":true,
|
||||
"runtime":"external",
|
||||
"compute":{
|
||||
"instance_type":"m6i.xlarge",
|
||||
"volume":{"root_gb":100},
|
||||
"display":{"mode":"none"}
|
||||
}
|
||||
}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{
|
||||
"name":"Oversized Agent",
|
||||
"compute":{"instance_type":"p4d.24xlarge"}
|
||||
}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
|
||||
WithArgs("ws-compute").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
|
||||
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
cfg := handler.buildProvisionerConfig(
|
||||
context.Background(),
|
||||
"ws-compute",
|
||||
"",
|
||||
nil,
|
||||
models.CreateWorkspacePayload{
|
||||
Tier: 4,
|
||||
Runtime: "claude-code",
|
||||
Compute: models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
t.TempDir(),
|
||||
"workspace:ws-compute",
|
||||
)
|
||||
|
||||
if cfg.InstanceType != "m6i.xlarge" {
|
||||
t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType)
|
||||
}
|
||||
if cfg.DiskGB != 100 {
|
||||
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-restart-compute").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`))
|
||||
|
||||
payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"}
|
||||
got := withStoredCompute(context.Background(), "ws-restart-compute", payload)
|
||||
|
||||
if got.Compute.InstanceType != "m6i.xlarge" {
|
||||
t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType)
|
||||
}
|
||||
if got.Compute.Volume.RootGB != 100 {
|
||||
t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-no-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["reason"] != "display_not_enabled" {
|
||||
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if resp["status"] != "not_configured" {
|
||||
t.Fatalf("status = %v, want not_configured", resp["status"])
|
||||
}
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
|
||||
}
|
||||
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
|
||||
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
|
||||
}
|
||||
if _, ok := resp["url"]; ok {
|
||||
t.Fatalf("display response exposed url before session infra exists: %v", resp["url"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display-sizing-drift").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display-sizing-drift"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display-sizing-drift/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-invalid-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-invalid-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-invalid-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["error"] != "invalid display config" {
|
||||
t.Fatalf("error = %v, want invalid display config", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
@@ -1009,4 +1011,3 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
|
||||
|
||||
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) {
|
||||
// isRestarting reports whether a restart cycle is currently in flight for
|
||||
// the workspace. Callers that have their own "container looks dead" probe
|
||||
// MUST consult this before triggering a restart, because during the
|
||||
// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false
|
||||
// 20-30s EC2-pending window the workspace's url=” and IsRunning()=false
|
||||
// looks identical to a dead container — and any restart-triggering probe
|
||||
// (maybeMarkContainerDead from canvas /delegations poll, or the trailing
|
||||
// restart-context probe at the end of runRestartCycle) will set
|
||||
@@ -337,7 +337,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
}
|
||||
|
||||
var configFiles map[string][]byte
|
||||
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime}
|
||||
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime})
|
||||
log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime)
|
||||
|
||||
// #12: ?reset=true (or body.Reset) discards the claude-sessions volume
|
||||
@@ -791,7 +791,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
})
|
||||
|
||||
// Runtime from DB — no more config file parsing
|
||||
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime}
|
||||
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
|
||||
|
||||
// Snapshot restart-context data before the new session overwrites
|
||||
// last_heartbeat_at. Issue #19 Layer 1.
|
||||
@@ -948,7 +948,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
|
||||
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
|
||||
})
|
||||
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
|
||||
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime})
|
||||
// Resume is provision-only (workspace is paused, no live container
|
||||
// to stop). provisionWorkspaceAuto handles backend routing and the
|
||||
// no-backend mark-failed fallback identically to Create. Pre-
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0001-0000-0000-000000000000").
|
||||
@@ -37,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
|
||||
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
|
||||
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph",
|
||||
"", 10.0, 20.0, false,
|
||||
nil, 0, false, true))
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -119,7 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -127,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
|
||||
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true))
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
|
||||
@@ -183,7 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -191,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true))
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
// Simulate the row vanishing between the two queries.
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -246,7 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -254,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
|
||||
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true))
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
// last_outbound_at follow-up query (existing path)
|
||||
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
|
||||
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1422,7 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
// Populate with non-zero financial values to confirm they are stripped.
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
@@ -1431,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
|
||||
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
|
||||
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD
|
||||
int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1479,7 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0955-0000-0000-000000000000").
|
||||
@@ -1492,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
|
||||
"langgraph",
|
||||
"/home/user/secret-projects/client-work",
|
||||
0.0, 0.0, false,
|
||||
nil, 0, false, true))
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -329,7 +329,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody interf
|
||||
|
||||
func decodeError(resp *http.Response) error {
|
||||
var e contract.Error
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return &contract.Error{
|
||||
Code: httpStatusToCode(resp.StatusCode),
|
||||
Message: fmt.Sprintf("status %d (read body failed: %v)", resp.StatusCode, readErr),
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &contract.Error{
|
||||
Code: httpStatusToCode(resp.StatusCode),
|
||||
|
||||
@@ -35,16 +35,16 @@ type Workspace struct {
|
||||
// DeliveryMode: "push" (synchronous to URL — default) or "poll" (logged
|
||||
// to activity_logs, agent reads via GET /activity?since_id=). See
|
||||
// migration 045 + RFC #2339.
|
||||
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
|
||||
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
|
||||
// BroadcastEnabled: when true the workspace may call POST /broadcast to
|
||||
// deliver a message to all non-removed agent workspaces in the org.
|
||||
// Default false — only privileged orchestrators should hold this ability.
|
||||
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
|
||||
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
|
||||
// TalkToUserEnabled: when false the workspace's send_message_to_user calls
|
||||
// and POST /notify requests are rejected with HTTP 403 so the agent is
|
||||
// forced to route updates through a parent workspace. Default true
|
||||
// (preserves existing behaviour for all workspaces).
|
||||
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
|
||||
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
|
||||
// Canvas layout fields (from JOIN)
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
@@ -71,12 +71,12 @@ type RegisterPayload struct {
|
||||
// enforces the conditional requirement based on the resolved
|
||||
// delivery mode (payload value, falling back to the row's existing
|
||||
// value, falling back to "push").
|
||||
URL string `json:"url"`
|
||||
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
|
||||
// DeliveryMode is optional. Empty string means "keep the existing
|
||||
// value on the workspace row, or default to push for new rows".
|
||||
// When set, must be one of DeliveryModePush / DeliveryModePoll.
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
}
|
||||
|
||||
type HeartbeatPayload struct {
|
||||
@@ -154,19 +154,36 @@ type MemorySeed struct {
|
||||
Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL
|
||||
}
|
||||
|
||||
type WorkspaceComputeVolume struct {
|
||||
RootGB int `json:"root_gb,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceComputeDisplay struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceCompute struct {
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
Volume WorkspaceComputeVolume `json:"volume,omitempty"`
|
||||
Display WorkspaceComputeDisplay `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
type CreateWorkspacePayload struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
Template string `json:"template"` // workspace-configs-templates folder name
|
||||
Tier int `json:"tier"`
|
||||
Model string `json:"model"`
|
||||
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
|
||||
External bool `json:"external"` // true = no Docker container, just a registered URL
|
||||
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
Template string `json:"template"` // workspace-configs-templates folder name
|
||||
Tier int `json:"tier"`
|
||||
Model string `json:"model"`
|
||||
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
|
||||
External bool `json:"external"` // true = no Docker container, just a registered URL
|
||||
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
|
||||
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
|
||||
// "poll" records inbound to activity_logs for the agent to consume via
|
||||
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
WorkspaceDir string `json:"workspace_dir"` // host path to mount as /workspace (empty = isolated volume)
|
||||
WorkspaceAccess string `json:"workspace_access"` // "none" (default), "read_only", or "read_write" — see #65
|
||||
ParentID *string `json:"parent_id"`
|
||||
@@ -180,7 +197,11 @@ type CreateWorkspacePayload struct {
|
||||
// MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use
|
||||
// DefaultMaxConcurrentTasks. Leaders typically set 3.
|
||||
MaxConcurrentTasks int `json:"max_concurrent_tasks"`
|
||||
Canvas struct {
|
||||
// Compute is the product-facing per-workspace EC2 shape/display
|
||||
// contract. Phase 1 uses instance_type + volume.root_gb and persists
|
||||
// display for future desktop-control workspaces.
|
||||
Compute WorkspaceCompute `json:"compute,omitempty"`
|
||||
Canvas struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
} `json:"canvas"`
|
||||
|
||||
@@ -152,12 +152,14 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
@@ -206,13 +208,15 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
}
|
||||
|
||||
req := cpProvisionRequest{
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
Runtime: cfg.Runtime,
|
||||
Tier: cfg.Tier,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
Runtime: cfg.Runtime,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
|
||||
@@ -191,6 +191,12 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
if body.WorkspaceID != "ws-1" || body.Runtime != "python" {
|
||||
t.Errorf("body mismatch: %+v", body)
|
||||
}
|
||||
if body.InstanceType != "m6i.xlarge" {
|
||||
t.Errorf("instance_type = %q, want m6i.xlarge", body.InstanceType)
|
||||
}
|
||||
if body.DiskGB != 100 {
|
||||
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
@@ -205,6 +211,7 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
|
||||
id, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
|
||||
InstanceType: "m6i.xlarge", DiskGB: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
@@ -362,7 +369,7 @@ func TestStart_CollectsConfigFiles(t *testing.T) {
|
||||
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
Runtime: "python",
|
||||
Tier: 1,
|
||||
PlatformURL: "http://tenant",
|
||||
TemplatePath: tmpl,
|
||||
@@ -424,7 +431,7 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
|
||||
p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
Runtime: "python",
|
||||
TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -98,6 +98,8 @@ type WorkspaceConfig struct {
|
||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||
Tier int
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
@@ -1605,4 +1607,3 @@ func parseOCIPlatform(s string) *ocispec.Platform {
|
||||
}
|
||||
return &ocispec.Platform{OS: parts[0], Architecture: parts[1]}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,10 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// the tenant AWS credentials. Admin-gated because console output
|
||||
// can include user-data snippets we treat as semi-sensitive.
|
||||
wsAdmin.GET("/workspaces/:id/console", wh.Console)
|
||||
// Display sessions will eventually return short-lived proxied DCV
|
||||
// URLs, so keep the endpoint admin-gated from the first unavailable
|
||||
// state rather than widening it later.
|
||||
wsAdmin.GET("/workspaces/:id/display", wh.Display)
|
||||
|
||||
// Admin memory backup/restore (#1051) — bulk export/import of agent
|
||||
// memories for safe Docker rebuilds. Matches workspaces by name on import.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
|
||||
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
|
||||
mock := setupRouterTestDB(t)
|
||||
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
r := buildWorkspaceDisplayEngine(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock unmet: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
|
||||
lastStatus := "ok"
|
||||
lastError := ""
|
||||
resultKind := ""
|
||||
if proxyErr != nil {
|
||||
lastStatus = "error"
|
||||
lastError = fmt.Sprintf("%v", proxyErr)
|
||||
@@ -433,8 +434,26 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
lastStatus = "error"
|
||||
lastError = fmt.Sprintf("HTTP %d", statusCode)
|
||||
log.Printf("Scheduler: '%s' non-2xx: %d", sched.Name, statusCode)
|
||||
} else if a2aErr := a2aErrorFromBody(respBody); a2aErr != "" {
|
||||
lastStatus = "error"
|
||||
lastError = fmt.Sprintf("A2A adapter error: %s", a2aErr)
|
||||
log.Printf("Scheduler: '%s' A2A adapter error (HTTP %d): %s", sched.Name, statusCode, a2aErr)
|
||||
} else {
|
||||
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
|
||||
// HTTP 200 — inspect response body for SDK-layer errors.
|
||||
// The claude-code-sdk adapter returns HTTP 200 even when the inner
|
||||
// LLM call throws (e.g. Max-plan rate-limit, quota exhaustion, SDK
|
||||
// internal errors). Without this check those failures surface as
|
||||
// "completed (HTTP 200)" in last_status while the agent chat shows
|
||||
// errors — a silent failure that hides schedule outages.
|
||||
// See: #1696.
|
||||
resultKind = detectResultKind(respBody)
|
||||
if resultKind != "" && resultKind != "ok" {
|
||||
lastStatus = resultKind
|
||||
lastError = fmt.Sprintf("SDK error: result_kind=%s", resultKind)
|
||||
log.Printf("Scheduler: '%s' SDK error detected — result_kind=%s", sched.Name, resultKind)
|
||||
} else {
|
||||
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// #795: detect phantom-producing schedules — cron fires successfully
|
||||
@@ -479,6 +498,54 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
resetCancel()
|
||||
}
|
||||
|
||||
// #1696: track consecutive SDK errors. When the adapter returns HTTP 200
|
||||
// but the response body signals a non-ok result_kind (rate_limited,
|
||||
// sdk_error, quota_exhausted), we increment a counter. After 3 consecutive
|
||||
// SDK errors we auto-disable the schedule and log it — the schedule is
|
||||
// suffering a persistent LLM-layer failure and firing it again will keep
|
||||
// producing the same errors while burning tokens.
|
||||
//
|
||||
// Only apply when the current lastStatus is a non-ok resultKind (not when
|
||||
// we already have 'error' from proxyErr or non-2xx HTTP status — those have
|
||||
// their own failure semantics). Also skip when lastStatus is 'stale' (the
|
||||
// empty-response escalation path takes priority).
|
||||
var consecSDK int
|
||||
if resultKind != "" && resultKind != "ok" {
|
||||
sdkCtx, sdkCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
|
||||
if err := db.DB.QueryRowContext(sdkCtx, `
|
||||
UPDATE workspace_schedules
|
||||
SET consecutive_sdk_errors = consecutive_sdk_errors + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING consecutive_sdk_errors`, sched.ID).Scan(&consecSDK); err != nil {
|
||||
log.Printf("Scheduler: '%s' SDK-error bump failed: %v", sched.Name, err)
|
||||
}
|
||||
sdkCancel()
|
||||
if consecSDK >= 3 {
|
||||
log.Printf("Scheduler: '%s' AUTO-DISABLING after %d consecutive SDK errors (workspace %s)",
|
||||
sched.Name, consecSDK, short(sched.WorkspaceID, 12))
|
||||
autoDisableCtx, autoDisableCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
|
||||
_, _ = db.DB.ExecContext(autoDisableCtx, `
|
||||
UPDATE workspace_schedules SET enabled = false, updated_at = now() WHERE id = $1 AND enabled = true`,
|
||||
sched.ID)
|
||||
autoDisableCancel()
|
||||
}
|
||||
} else {
|
||||
// Non-SDK-error run — reset the counter.
|
||||
// Guard: only reset when lastStatus is a clean ok (not 'stale', not
|
||||
// 'error', not resultKind). An 'ok' resultKind means the SDK is fine
|
||||
// and we should clear the streak.
|
||||
if lastStatus == "ok" {
|
||||
resetCtx, resetCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
|
||||
_, _ = db.DB.ExecContext(resetCtx, `
|
||||
UPDATE workspace_schedules
|
||||
SET consecutive_sdk_errors = 0,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, sched.ID)
|
||||
resetCancel()
|
||||
}
|
||||
}
|
||||
|
||||
nextRun, nextErr := ComputeNextRun(sched.CronExpr, sched.Timezone, time.Now())
|
||||
var nextRunPtr *time.Time
|
||||
if nextErr == nil {
|
||||
@@ -759,6 +826,73 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// detectResultKind inspects an A2A response body for SDK-layer error signals
|
||||
// that are invisible at the HTTP level. The claude-code-sdk adapter returns
|
||||
// HTTP 200 even when the inner LLM call throws (Max-plan rate-limit, quota
|
||||
// exhaustion, SDK internal errors) — the error surfaces only in the response
|
||||
// body under result.kind or result.result_kind.
|
||||
//
|
||||
// Returns an empty string when the response is clean (result_kind is "ok" or
|
||||
// absent). Returns the result_kind value when it is a non-ok signal, so callers
|
||||
// can propagate it as the schedule's last_status.
|
||||
//
|
||||
// Known non-ok kinds:
|
||||
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
|
||||
// - "quota_exhausted" — quota / budget exhausted
|
||||
// - "sdk_error" — SDK threw an internal error
|
||||
//
|
||||
// See #1696.
|
||||
func detectResultKind(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &top); err != nil {
|
||||
return ""
|
||||
}
|
||||
// Check result.kind first (canonical JSON-RPC shape).
|
||||
if rawResult, ok := top["result"]; ok {
|
||||
var result map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawResult, &result); err == nil {
|
||||
// result.kind (canonical JSON-RPC error envelope field).
|
||||
if rawKind, ok := result["kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
// result.result_kind (legacy / alternative field name).
|
||||
if rawKind, ok := result["result_kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Top-level error: non-ok HTTP 200 with a structured error in the body.
|
||||
if rawErr, ok := top["error"]; ok {
|
||||
var errMsg string
|
||||
if err := json.Unmarshal(rawErr, &errMsg); err == nil && errMsg != "" {
|
||||
// Distinguish SDK errors from other errors. SDK-layer errors from the
|
||||
// Claude Code runtime include specific markers.
|
||||
lower := strings.ToLower(errMsg)
|
||||
// Check more specific patterns first (max-plan quota > general rate).
|
||||
if strings.Contains(lower, "max-plan") || strings.Contains(lower, "quota") || strings.Contains(lower, "budget") {
|
||||
return "quota_exhausted"
|
||||
}
|
||||
if strings.Contains(lower, "rate limit") || strings.Contains(lower, "rate_limit") {
|
||||
return "rate_limited"
|
||||
}
|
||||
if strings.Contains(lower, "claude code returned an error") || strings.Contains(lower, "sdk error") ||
|
||||
strings.Contains(lower, "api key") || strings.Contains(lower, "authentication") {
|
||||
return "sdk_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isEmptyResponse checks if an A2A response body indicates the agent
|
||||
// produced no meaningful output. Catches "(no response generated)" from
|
||||
// the workspace runtime + genuinely empty/null responses. Used by the
|
||||
@@ -808,6 +942,32 @@ func isEmptyResponse(body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// a2aErrorFromBody extracts an A2A/JSON-RPC error message from a 2xx
|
||||
// response body. The adapter SDK may return HTTP 200 with an error
|
||||
// payload when it throws internally; this prevents the scheduler from
|
||||
// falsely recording last_status='ok'.
|
||||
// Issue #1696.
|
||||
func a2aErrorFromBody(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if json.Unmarshal(body, &resp) != nil {
|
||||
return ""
|
||||
}
|
||||
// JSON-RPC style: {"error":{"code":-32603,"message":"..."}}
|
||||
if errObj, ok := resp["error"].(map[string]interface{}); ok {
|
||||
if msg, ok := errObj["message"].(string); ok {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
// Plain style: {"error":"..."}
|
||||
if errStr, ok := resp["error"].(string); ok {
|
||||
return errStr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
|
||||
// The original #2026 fix lives in textutil's package docs as canonical
|
||||
// prior art. Ellipsis was previously "..." (3 ASCII bytes); the SSOT
|
||||
|
||||
@@ -3,6 +3,7 @@ package scheduler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -256,6 +257,58 @@ func (p *successProxy) ProxyA2ARequest(
|
||||
return 200, []byte(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
// ── adapterErrorProxy ─────────────────────────────────────────────────────────
|
||||
|
||||
// adapterErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200
|
||||
// with a JSON-RPC error body, simulating an adapter SDK that throws internally
|
||||
// but still completes the HTTP round-trip. Issue #1696.
|
||||
type adapterErrorProxy struct{}
|
||||
|
||||
func (p *adapterErrorProxy) ProxyA2ARequest(
|
||||
_ context.Context, _ string, _ []byte, _ string, _ bool,
|
||||
) (int, []byte, error) {
|
||||
return 200, []byte(`{"jsonrpc":"2.0","id":"cron-test-123","error":{"code":-32603,"message":"adapter SDK internal error"}}`), nil
|
||||
}
|
||||
|
||||
// ── TestFireSchedule_AdapterSDKError (#1696) ──────────────────────────────────
|
||||
//
|
||||
// When the adapter SDK throws internally and returns HTTP 200 with an error
|
||||
// payload, fireSchedule must record last_status='error', not 'ok'.
|
||||
|
||||
func TestFireSchedule_AdapterSDKError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
sched := scheduleRow{
|
||||
ID: "55555555-dead-beef-0000-000000000005",
|
||||
WorkspaceID: "66666666-dead-beef-0000-000000000006",
|
||||
Name: "adapter-err-job",
|
||||
CronExpr: "0 * * * *",
|
||||
Timezone: "UTC",
|
||||
Prompt: "do something",
|
||||
}
|
||||
|
||||
// active_tasks check → 0 (workspace is idle; proceed to fire)
|
||||
mock.ExpectQuery(`SELECT COALESCE`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
|
||||
|
||||
// Post-fire UPDATE must record last_status='error' with the adapter error message.
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID, sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// activity_logs INSERT must carry status='error' and the error detail.
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
s := New(&adapterErrorProxy{}, nil)
|
||||
s.fireSchedule(context.Background(), sched)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations — adapter error not recorded correctly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestFireSchedule_ComputeNextRunError (#722 Bug 1) ─────────────────────────
|
||||
//
|
||||
// When ComputeNextRun fails (bad cron expression), fireSchedule must NOT write
|
||||
@@ -285,6 +338,12 @@ func TestFireSchedule_ComputeNextRunError(t *testing.T) {
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// #1696 consecutive_sdk_errors reset — successProxy has no result_kind,
|
||||
// so detectResultKind returns "" and lastStatus="ok" → reset.
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// UPDATE must fire — COALESCE($2, next_run_at) keeps existing value when $2 is nil.
|
||||
// AnyArg for $2 because it will be nil (ComputeNextRun failed).
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
@@ -540,7 +599,14 @@ func TestFireSchedule_NormalSuccess_AdvancesNextRunAt(t *testing.T) {
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 3. Normal UPDATE after successful proxy call.
|
||||
// 3. #1696 consecutive_sdk_errors reset — successProxy response has no
|
||||
// result_kind in the body, so detectResultKind returns "" and lastStatus
|
||||
// is "ok" → we hit the SDK-error counter reset branch.
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 4. Normal UPDATE after successful proxy call.
|
||||
// Args: $1=sched.ID, $2=nextRunPtr (computed time), $3=lastStatus, $4=lastError
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
|
||||
@@ -603,6 +669,281 @@ func TestRecordSkipped_AdvancesNextRunAt(t *testing.T) {
|
||||
}
|
||||
// trigger CI
|
||||
|
||||
// ── TestDetectResultKind ───────────────────────────────────────────────────────
|
||||
|
||||
// TestDetectResultKind covers the SDK error detection path: HTTP 200 responses
|
||||
// with non-ok result_kind in the body must be recognised and returned as the
|
||||
// kind string, not silently treated as ok.
|
||||
func TestDetectResultKind(t *testing.T) {
|
||||
// The test exercises detectResultKind directly so we don't need a full
|
||||
// fireSchedule mock for this unit-test level.
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKind string
|
||||
}{
|
||||
{
|
||||
name: "clean ok response — empty body",
|
||||
body: `{}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok response — result.kind absent",
|
||||
body: `{"result":{"parts":[{"text":"hello"}]}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok response — result.kind=ok",
|
||||
body: `{"result":{"kind":"ok","parts":[{"text":"hello"}]}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok response — result.result_kind=ok",
|
||||
body: `{"result":{"result_kind":"ok","parts":[{"text":"hello"}]}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "SDK error — result.kind=rate_limited",
|
||||
body: `{"result":{"kind":"rate_limited","parts":[{"text":"error"}]}}`,
|
||||
wantKind: "rate_limited",
|
||||
},
|
||||
{
|
||||
name: "SDK error — result.kind=quota_exhausted",
|
||||
body: `{"result":{"kind":"quota_exhausted"}}`,
|
||||
wantKind: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
name: "SDK error — result.kind=sdk_error",
|
||||
body: `{"result":{"kind":"sdk_error"}}`,
|
||||
wantKind: "sdk_error",
|
||||
},
|
||||
{
|
||||
name: "SDK error — result.result_kind=rate_limited",
|
||||
body: `{"result":{"result_kind":"rate_limited"}}`,
|
||||
wantKind: "rate_limited",
|
||||
},
|
||||
{
|
||||
name: "SDK error — error string with rate limit",
|
||||
body: `{"result":{"parts":[]},"error":"An error occurred: rate limit exceeded"}`,
|
||||
wantKind: "rate_limited",
|
||||
},
|
||||
{
|
||||
name: "SDK error — error string with max-plan",
|
||||
body: `{"error":"Max-plan rate limit reached"}`,
|
||||
wantKind: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
name: "SDK error — error string with quota",
|
||||
body: `{"error":"quota exhausted for model"}`,
|
||||
wantKind: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
name: "SDK error — error string with sdk error",
|
||||
body: `{"error":"Claude Code returned an error result: success"}`,
|
||||
wantKind: "sdk_error",
|
||||
},
|
||||
{
|
||||
name: "SDK error — error string with api key",
|
||||
body: `{"error":"invalid API key"}`,
|
||||
wantKind: "sdk_error",
|
||||
},
|
||||
{
|
||||
name: "unknown error string — not an SDK error",
|
||||
body: `{"error":"something went wrong"}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "empty response body",
|
||||
body: ``,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
body: `not valid json`,
|
||||
wantKind: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := detectResultKind([]byte(tc.body))
|
||||
if got != tc.wantKind {
|
||||
t.Errorf("detectResultKind(%q) = %q, want %q", tc.body, got, tc.wantKind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestFireSchedule_SDKError_RateLimited (#1696) ───────────────────────────────
|
||||
//
|
||||
// When ProxyA2ARequest returns HTTP 200 but the response body contains a
|
||||
// non-ok result_kind, fireSchedule must:
|
||||
// 1. Set last_status to the result_kind (not 'ok').
|
||||
// 2. Set last_error to describe the SDK error.
|
||||
// 3. Increment consecutive_sdk_errors.
|
||||
// 4. NOT auto-disable on first occurrence (threshold is 3).
|
||||
//
|
||||
// This test uses an sdkErrorProxy that returns a rate-limited body and asserts
|
||||
// the first run is recorded as 'rate_limited' with consecutive_sdk_errors=1
|
||||
// and enabled=true.
|
||||
func TestFireSchedule_SDKError_RateLimited(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
sched := scheduleRow{
|
||||
ID: "sdk1-test-sched-0001",
|
||||
WorkspaceID: "sdk1-test-workspace1",
|
||||
Name: "rate-limited-job",
|
||||
CronExpr: "0 * * * *",
|
||||
Timezone: "UTC",
|
||||
Prompt: "do work",
|
||||
}
|
||||
|
||||
// 1. active_tasks check → workspace idle
|
||||
mock.ExpectQuery(`SELECT COALESCE`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
|
||||
|
||||
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives us count=1.
|
||||
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
|
||||
// produces a result set consumed via .Scan().
|
||||
mock.ExpectQuery(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(1))
|
||||
|
||||
// 3. Post-fire UPDATE — last_status='rate_limited', last_error='SDK error: result_kind=rate_limited'
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 4. activity_logs INSERT
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
|
||||
s.fireSchedule(context.Background(), sched)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations for SDK-error first run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive (#1696) ───────────
|
||||
//
|
||||
// On the 3rd consecutive SDK error, fireSchedule must auto-disable the
|
||||
// schedule (enabled=false) in addition to recording the error status.
|
||||
// Threshold is 3 per #1696 requirement.
|
||||
func TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
sched := scheduleRow{
|
||||
ID: "sdk2-test-sched-0002",
|
||||
WorkspaceID: "sdk2-test-workspace2",
|
||||
Name: "auto-disable-job",
|
||||
CronExpr: "0 * * * *",
|
||||
Timezone: "UTC",
|
||||
Prompt: "do work",
|
||||
}
|
||||
|
||||
// 1. active_tasks check → workspace idle
|
||||
mock.ExpectQuery(`SELECT COALESCE`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
|
||||
|
||||
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives count=3 (threshold met).
|
||||
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
|
||||
// produces a result set consumed via .Scan().
|
||||
mock.ExpectQuery(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(3))
|
||||
|
||||
// 3. Auto-disable UPDATE — sets enabled=false (schedule has hit 3rd SDK error)
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET enabled`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 4. Post-fire UPDATE
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 5. activity_logs INSERT
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
|
||||
s.fireSchedule(context.Background(), sched)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations for SDK-error auto-disable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TestFireSchedule_SDKError_CounterResetOnCleanRun (#1696) ──────────────────
|
||||
//
|
||||
// A clean HTTP-200 run (no SDK error) must reset consecutive_sdk_errors to 0.
|
||||
// This prevents false auto-disable after intermittent SDK blips.
|
||||
func TestFireSchedule_SDKError_CounterResetOnCleanRun(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
sched := scheduleRow{
|
||||
ID: "sdk3-test-sched-0003",
|
||||
WorkspaceID: "sdk3-test-workspace3",
|
||||
Name: "clean-reset-job",
|
||||
CronExpr: "30 * * * *",
|
||||
Timezone: "UTC",
|
||||
Prompt: "do work",
|
||||
}
|
||||
|
||||
// 1. active_tasks check → workspace idle
|
||||
mock.ExpectQuery(`SELECT COALESCE`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
|
||||
|
||||
// 2. No SDK error — #1696 counter is reset to 0
|
||||
// (lastStatus is 'ok', resultKind is empty, so we go to the reset branch)
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 3. Post-fire UPDATE
|
||||
mock.ExpectExec(`UPDATE workspace_schedules`).
|
||||
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 4. activity_logs INSERT
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "ok", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
s := New(&successProxy{}, nil)
|
||||
s.fireSchedule(context.Background(), sched)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations for SDK-error counter reset: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── sdkErrorProxy ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// sdkErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200 but
|
||||
// embeds a non-ok result_kind in the response body, simulating a Claude Code
|
||||
// SDK that returned 200 but the inner LLM call threw a rate-limit / quota error.
|
||||
// Used by TestFireSchedule_SDKError_* to cover #1696 SDK error detection.
|
||||
type sdkErrorProxy struct {
|
||||
kind string // result_kind value to embed in the response body
|
||||
}
|
||||
|
||||
func (p *sdkErrorProxy) ProxyA2ARequest(
|
||||
_ context.Context, _ string, _ []byte, _ string, _ bool,
|
||||
) (int, []byte, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"kind": p.kind,
|
||||
"parts": []map[string]interface{}{{"kind": "text", "text": "(no response generated)"}},
|
||||
},
|
||||
})
|
||||
return 200, body, nil
|
||||
}
|
||||
|
||||
// ── TestTruncate_utf8Safe_regression2026 ──────────────────────────────────────
|
||||
|
||||
// TestTruncate_utf8Safe_regression2026 locks in the #2026 fix: truncate must
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces DROP COLUMN IF EXISTS compute;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS compute JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- migration: 20260523000000_schedule_consecutive_sdk_errors.down.sql
|
||||
-- Reverts #1696 fix for #1696 (consecutive_sdk_errors column)
|
||||
|
||||
ALTER TABLE workspace_schedules DROP COLUMN IF EXISTS consecutive_sdk_errors;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- migration: 20260523000000_schedule_consecutive_sdk_errors.up.sql
|
||||
-- Fixes #1696: Add consecutive_sdk_errors counter to track SDK errors (HTTP 200
|
||||
-- responses where the Claude Code runtime returned a non-ok result_kind).
|
||||
-- When this counter reaches 3, the scheduler sets last_status='rate_limited'
|
||||
-- and auto-disables the schedule.
|
||||
--
|
||||
-- The core issue: the claude-code-sdk adapter returns HTTP 200 even when the
|
||||
-- inner LLM call throws (e.g. Max-plan rate-limit). All 3 observed runs logged
|
||||
-- "completed (HTTP 200)" yet surfaced agent errors in the workspace chat.
|
||||
-- This counter lets us detect that pattern and escalate appropriately.
|
||||
|
||||
ALTER TABLE workspace_schedules
|
||||
ADD COLUMN IF NOT EXISTS consecutive_sdk_errors INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN workspace_schedules.consecutive_sdk_errors IS
|
||||
'Count of consecutive scheduler fires where ProxyA2ARequest returned HTTP 200
|
||||
but the response body contained a non-ok result_kind (e.g. rate_limited,
|
||||
sdk_error, quota_exhausted). Reset to 0 on any non-SDK-error status.
|
||||
After 3 consecutive SDK errors the schedule is auto-disabled with
|
||||
status rate_limited. Fixes #1696.';
|
||||
Reference in New Issue
Block a user