Files
agentic-mobile-control/cloud-console/src/api.ts
T
q792602257andClaude Opus 4.6 ec261d57c2 feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 12:47:49 +08:00

331 lines
9.3 KiB
TypeScript

import type {
CloudUser,
DeviceRecord,
HostGovernancePolicy,
HostTokenUsageSummary,
LlmProviderProfile,
LlmProviderProfileListResponse,
LlmProviderSettings,
LlmProviderType,
HostRecord,
PlannerDecisionItem,
PlannerDecisionListResponse,
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskListResponse,
TaskSubmissionPayload,
TaskStatus,
TokenUsageEvent,
UserListResponse,
UserSubmissionPolicy,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
| string
| undefined;
export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace(
/\/$/,
"",
);
const CSRF_COOKIE_NAME = "amcp_csrf";
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
export class CloudApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "CloudApiError";
}
}
interface RequestInitLike {
method?: string;
body?: string | null;
headers?: Record<string, string>;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
const method = init.method || "GET";
const headers: Record<string, string> = {
Accept: "application/json",
...init.headers,
};
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrfToken = getCookie(CSRF_COOKIE_NAME);
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
}
if (init.body !== undefined && init.body !== null) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
body: init.body ?? null,
headers,
credentials: "include",
});
if (response.status === 401) {
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT));
throw new CloudApiError(401, await responseDetail(response, "authentication expired"));
}
if (!response.ok) {
throw new CloudApiError(
response.status,
await responseDetail(response, `${response.status} ${response.statusText}`),
);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
async function responseDetail(response: Response, fallback: string): Promise<string> {
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") return payload.detail;
if (payload.detail) return JSON.stringify(payload.detail);
} catch {
// Preserve the fallback for empty or non-JSON responses.
}
return fallback;
}
function getCookie(name: string): string | null {
const prefix = `${encodeURIComponent(name)}=`;
for (const part of document.cookie.split(";")) {
const value = part.trim();
if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length));
}
return null;
}
export function login(username: string, password: string): Promise<CloudUser> {
return request<CloudUser>("/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function getCurrentUser(): Promise<CloudUser> {
return request<CloudUser>("/v1/auth/me");
}
export function logout(): Promise<void> {
return request<void>("/v1/auth/logout", { method: "POST" });
}
export function changePassword(
currentPassword: string,
newPassword: string,
): Promise<void> {
return request<void>("/v1/auth/password", {
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
}
export function listUsers(): Promise<UserListResponse> {
return request<UserListResponse>("/v1/users?limit=100&offset=0");
}
export function createUser(payload: {
username: string;
display_name: string;
role: "viewer" | "operator" | "admin";
password: string;
}): Promise<CloudUser> {
return request<CloudUser>("/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(
userId: string,
payload: { display_name?: string; role?: "viewer" | "operator" | "admin"; enabled?: boolean },
): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function revokeUserSessions(userId: string): Promise<void> {
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
method: "DELETE",
});
}
export function getUserSubmissionPolicy(userId: string): Promise<UserSubmissionPolicy> {
return request<UserSubmissionPolicy>(
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
);
}
export function updateUserSubmissionPolicy(
userId: string,
payload: Omit<UserSubmissionPolicy, "user_id" | "revision" | "updated_at"> & {
expected_revision?: number;
},
): Promise<UserSubmissionPolicy> {
return request<UserSubmissionPolicy>(
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
{ method: "PUT", body: JSON.stringify(payload) },
);
}
export function getHostGovernancePolicy(hostId: string): Promise<HostGovernancePolicy> {
return request<HostGovernancePolicy>(
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
);
}
export function updateHostGovernancePolicy(
hostId: string,
payload: Omit<HostGovernancePolicy, "host_id" | "revision" | "updated_at"> & {
expected_revision?: number;
},
): Promise<HostGovernancePolicy> {
return request<HostGovernancePolicy>(
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
{ method: "PUT", body: JSON.stringify(payload) },
);
}
export function getHostTokenUsage(hostId: string): Promise<HostTokenUsageSummary> {
return request<HostTokenUsageSummary>(
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage`,
);
}
export function listHostTokenUsageEvents(hostId: string): Promise<TokenUsageEvent[]> {
return request<TokenUsageEvent[]>(
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage-events?limit=20&offset=0`,
);
}
export function listTasks(options?: {
status?: TaskStatus;
limit?: number;
offset?: number;
}): Promise<TaskListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
params.set("limit", String(options?.limit ?? 50));
params.set("offset", String(options?.offset ?? 0));
const query = params.toString();
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
}
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
}
export function getTaskPlannerDecisions(
taskId: string,
attempt: number,
): Promise<PlannerDecisionItem[]> {
return request<PlannerDecisionListResponse>(
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
).then((resp) => resp.items);
}
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
return request<{ task_id: string }>("/v1/tasks", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function listDevices(): Promise<DeviceRecord[]> {
return request<DeviceRecord[]>("/v1/devices");
}
export function listHosts(): Promise<HostRecord[]> {
return request<HostRecord[]>("/v1/hosts");
}
export function listPlugins(): Promise<PluginRecord[]> {
return request<PluginRecord[]>("/v1/plugins");
}
export function registerPlugin(payload: PluginRegistrationPayload): Promise<PluginRecord> {
return request<PluginRecord>("/v1/plugins", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function listLlmProviderProfiles(): Promise<LlmProviderProfileListResponse> {
return request<LlmProviderProfileListResponse>("/v1/planner/providers");
}
export function createLlmProviderProfile(payload: {
name: string;
provider_type: LlmProviderType;
model: string;
base_url?: string | null;
timeout_seconds: number;
api_key: string;
}): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>("/v1/planner/providers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateLlmProviderProfile(
profileId: string,
payload: {
name?: string;
provider_type?: LlmProviderType;
model?: string;
base_url?: string | null;
timeout_seconds?: number;
enabled?: boolean;
api_key?: string;
expected_revision?: number;
},
): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>(`/v1/planner/providers/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function activateLlmProviderProfile(
profileId: string,
expectedSettingsRevision: number,
): Promise<LlmProviderSettings> {
return request<LlmProviderSettings>(
`/v1/planner/providers/${encodeURIComponent(profileId)}/activate`,
{
method: "POST",
body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }),
},
);
}
export function deleteLlmProviderProfile(
profileId: string,
expectedRevision: number,
): Promise<void> {
return request<void>(
`/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`,
{ method: "DELETE" },
);
}