Files
kandev/apps/web/lib/api/domains/office-api.ts
T

644 lines
20 KiB
TypeScript

import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client";
import { normalizeActivityEntry, type RawActivityEntry } from "./office-activity-normalize";
import type {
AgentProfile,
Project,
CostSummary,
Approval,
InboxItem,
OfficeMeta,
ProviderUsage,
LiveRun,
} from "@/lib/state/slices/office/types";
import type { CLIFlag, AgentRole, AgentStatus } from "@/lib/types/agent-profile";
import { agentProfileId, workspaceId } from "@/lib/types/ids";
import { normalizeProject } from "./office-project-normalize";
// Re-export extended API so existing imports continue to work.
export {
listChannels,
setupChannel,
deleteChannel,
exportConfig,
exportConfigManifest,
exportSelectedConfigZip,
exportConfigZipUrl,
previewImport,
applyImport,
getIncomingDiff,
getOutgoingDiff,
applyIncomingSync,
applyOutgoingSync,
listTasks,
getTask,
listComments,
createComment,
searchTasks,
updateTask,
addTaskBlocker,
removeTaskBlocker,
listTaskReviewers,
addTaskReviewer,
removeTaskReviewer,
listTaskApprovers,
addTaskApprover,
removeTaskApprover,
approveTask,
requestTaskChanges,
listTaskDecisions,
listInstructions,
getInstruction,
upsertInstruction,
deleteInstruction,
getOnboardingState,
completeOnboarding,
importFromFS,
getDashboard,
listRuns,
updateWorkspaceSettings,
getWorkspaceSettings,
getGitStatus,
gitClone,
gitPull,
gitPush,
} from "./office-extended-api";
export { listBudgets, createBudget, updateBudget, deleteBudget } from "./office-budget-api";
export {
listRoutines,
createRoutine,
getRoutine,
updateRoutine,
deleteRoutine,
runRoutine,
listRoutineTriggers,
createRoutineTrigger,
deleteRoutineTrigger,
listRoutineRuns,
listAllRoutineRuns,
} from "./office-routine-api";
export type {
ImportDiff,
ImportPreview,
ParseError,
SyncDiff,
TaskCommentResponse,
TimelineEvent,
UpdateTaskPayload,
TaskParticipant,
OnboardingStateData,
OnboardingCompletePayload,
OnboardingCompleteResult,
OnboardingFSWorkspace,
ImportFromFSResult,
GitStatusData,
WorkspaceSettingsData,
TaskDecisionDTO,
} from "./office-extended-api";
const BASE = "/api/v1/office";
export const getMeta = (options?: ApiRequestOptions) =>
fetchJsonWithRetry<OfficeMeta>(`${BASE}/meta`, options);
type AgentResponse = { agent: unknown };
type AgentListResponse = { agents: unknown[] };
export type WorkspaceDeletionSummary = {
workspace_name: string;
tasks: number;
agents: number;
skills: number;
config_path: string;
};
function parseJSONField<T>(value: unknown, fallback: T): T {
if (typeof value !== "string") return (value as T) ?? fallback;
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function rawField(agent: Record<string, unknown>, camelKey: string, snakeKey: string) {
return agent[camelKey] ?? agent[snakeKey];
}
function stringField(agent: Record<string, unknown>, camelKey: string, snakeKey: string) {
const value = rawField(agent, camelKey, snakeKey);
return typeof value === "string" ? value : "";
}
function numberField(
agent: Record<string, unknown>,
camelKey: string,
snakeKey: string,
fallback: number,
) {
const value = rawField(agent, camelKey, snakeKey);
return typeof value === "number" ? value : fallback;
}
function boolField(agent: Record<string, unknown>, camelKey: string, snakeKey: string): boolean {
const value = rawField(agent, camelKey, snakeKey);
return typeof value === "boolean" ? value : false;
}
function normalizeAgent(raw: unknown): AgentProfile {
const agent = raw as Record<string, unknown>;
const id = agentProfileId(agent.id as string);
const rawAgentProfileId = stringField(agent, "agentProfileId", "agent_profile_id");
return {
id,
workspaceId: workspaceId(stringField(agent, "workspaceId", "workspace_id")),
name: agent.name as string,
agentProfileId: rawAgentProfileId ? agentProfileId(rawAgentProfileId) : id,
role: agent.role as AgentRole,
icon: agent.icon as string | undefined,
status: agent.status as AgentStatus,
reportsTo: stringField(agent, "reportsTo", "reports_to"),
permissions: parseJSONField(agent.permissions, {}),
budgetMonthlyCents: numberField(agent, "budgetMonthlyCents", "budget_monthly_cents", 0),
maxConcurrentSessions: numberField(
agent,
"maxConcurrentSessions",
"max_concurrent_sessions",
1,
),
desiredSkills: parseJSONField<string[]>(rawField(agent, "desiredSkills", "desired_skills"), []),
executorPreference: parseJSONField<Record<string, unknown>>(
rawField(agent, "executorPreference", "executor_preference"),
{},
),
pauseReason: stringField(agent, "pauseReason", "pause_reason"),
billingType: rawField(agent, "billingType", "billing_type") as AgentProfile["billingType"],
utilization: (agent.utilization ?? null) as AgentProfile["utilization"],
skillIds: parseJSONField<string[]>(rawField(agent, "skillIds", "skill_ids"), []),
// CLI subprocess fields. Office-served rows may omit these when the
// office agent is not yet wired to a CLI client; default to safe
// empty values so the canonical type stays satisfied.
agentId: stringField(agent, "agentId", "agent_id"),
agentDisplayName: stringField(agent, "agentDisplayName", "agent_display_name"),
model: stringField(agent, "model", "model"),
mode: (rawField(agent, "mode", "mode") as string | undefined) ?? undefined,
allowIndexing: boolField(agent, "allowIndexing", "allow_indexing"),
autoApprove: boolField(agent, "autoApprove", "auto_approve"),
cliFlags: parseJSONField<CLIFlag[]>(rawField(agent, "cliFlags", "cli_flags"), []),
cliPassthrough: boolField(agent, "cliPassthrough", "cli_passthrough"),
userModified: rawField(agent, "userModified", "user_modified") as boolean | undefined,
createdAt: stringField(agent, "createdAt", "created_at"),
updatedAt: stringField(agent, "updatedAt", "updated_at"),
};
}
function stringifyJSONField(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function agentPayload(data: Partial<AgentProfile>): Record<string, unknown> {
const payload: Record<string, unknown> = {
name: data.name,
agent_profile_id: data.agentProfileId,
role: data.role,
icon: data.icon,
reports_to: data.reportsTo,
permissions: stringifyJSONField(data.permissions),
budget_monthly_cents: data.budgetMonthlyCents,
max_concurrent_sessions: data.maxConcurrentSessions,
desired_skills: stringifyJSONField(data.desiredSkills),
executor_preference: stringifyJSONField(data.executorPreference),
skill_ids: stringifyJSONField(data.skillIds),
};
if (data.autoApprove !== undefined) {
payload.auto_approve = data.autoApprove;
}
if (data.allowIndexing !== undefined) {
payload.allow_indexing = data.allowIndexing;
}
if (data.cliPassthrough !== undefined) {
payload.cli_passthrough = data.cliPassthrough;
}
return payload;
}
// --- Agent Instances ---
export function listAgentProfiles(workspaceId: string, options?: ApiRequestOptions) {
return fetchJsonWithRetry<AgentListResponse>(
`${BASE}/workspaces/${workspaceId}/agents`,
options,
).then((res) => ({ agents: (res.agents ?? []).map((agent) => normalizeAgent(agent)) }));
}
export function createAgentProfile(
workspaceId: string,
data: Partial<AgentProfile>,
options?: ApiRequestOptions,
) {
return fetchJson<AgentResponse>(`${BASE}/workspaces/${workspaceId}/agents`, {
...options,
init: { method: "POST", body: JSON.stringify(agentPayload(data)), ...options?.init },
}).then((res) => normalizeAgent(res.agent));
}
export const getAgentProfile = (id: string, options?: ApiRequestOptions) =>
fetchJson<AgentResponse>(`${BASE}/agents/${id}`, options).then((res) =>
normalizeAgent(res.agent),
);
export const getAgentUtilization = (agentId: string, options?: ApiRequestOptions) =>
fetchJson<{ utilization: ProviderUsage | null }>(
`${BASE}/agents/${agentId}/utilization`,
options,
);
export function updateAgentProfile(
id: string,
data: Partial<AgentProfile>,
options?: ApiRequestOptions,
) {
return fetchJson<AgentResponse>(`${BASE}/agents/${id}`, {
...options,
init: { method: "PATCH", body: JSON.stringify(agentPayload(data)), ...options?.init },
}).then((res) => normalizeAgent(res.agent));
}
// Dedicated status transition endpoint: validates the transition table and
// clears pause_reason as a side effect. The general agent update endpoint
// above accepts a status field too but writes it unchecked, so callers that
// need transition validation (e.g. agent recovery) must use this instead.
// `expectedStatus` selects the guarded recovery form, which refuses to
// overwrite a status that is no longer paused or stopped.
export function updateAgentStatus(
id: string,
status: AgentStatus,
options?: ApiRequestOptions & { expectedStatus?: AgentStatus },
) {
const { expectedStatus, ...requestOptions } = options ?? {};
const body: { status: AgentStatus; expected_status?: AgentStatus } = { status };
if (expectedStatus) body.expected_status = expectedStatus;
return fetchJson<AgentResponse>(`${BASE}/agents/${id}/status`, {
...requestOptions,
init: { method: "PATCH", body: JSON.stringify(body), ...requestOptions.init },
}).then((res) => normalizeAgent(res.agent));
}
export function deleteAgentProfile(id: string, options?: ApiRequestOptions) {
return fetchJson<void>(`${BASE}/agents/${id}`, {
...options,
init: { method: "DELETE", ...options?.init },
});
}
export function getWorkspaceDeletionSummary(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<WorkspaceDeletionSummary>(
`${BASE}/workspaces/${workspaceId}/deletion-summary`,
options,
);
}
export function deleteWorkspace(
workspaceId: string,
confirmName: string,
options?: ApiRequestOptions,
) {
return fetchJson<void>(`${BASE}/workspaces/${workspaceId}`, {
...options,
init: {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm_name: confirmName }),
...options?.init,
},
});
}
// --- Skills ---
// Moved to office-skills-api.ts so this module stays under the
// 600-line cap. The re-exports below keep every existing
// `from "@/lib/api/domains/office-api"` import working.
export {
listSkills,
createSkill,
getSkill,
updateSkill,
deleteSkill,
importSkill,
getSkillFile,
} from "./office-skills-api";
// --- Projects ---
export function listProjects(workspaceId: string, options?: ApiRequestOptions) {
return fetchJsonWithRetry<{ projects: unknown[] }>(
`${BASE}/workspaces/${workspaceId}/projects`,
options,
).then((res) => ({ projects: (res.projects ?? []).map((project) => normalizeProject(project)) }));
}
// Backend single-project endpoints wrap the row as `{ project: ... }`.
// The list endpoint wraps as `{ projects: [...] }`. These helpers
// unwrap once at the API boundary so callers always see a `Project`.
export async function createProject(
workspaceId: string,
data: Partial<Project>,
options?: ApiRequestOptions,
) {
const res = await fetchJson<{ project: unknown }>(`${BASE}/workspaces/${workspaceId}/projects`, {
...options,
init: { method: "POST", body: JSON.stringify(data), ...options?.init },
});
return normalizeProject(res.project);
}
export async function getProject(id: string, options?: ApiRequestOptions) {
const res = await fetchJson<{ project: unknown }>(`${BASE}/projects/${id}`, options);
return normalizeProject(res.project);
}
export async function updateProject(
id: string,
data: Partial<Project>,
options?: ApiRequestOptions,
) {
const res = await fetchJson<{ project: unknown }>(`${BASE}/projects/${id}`, {
...options,
init: { method: "PATCH", body: JSON.stringify(data), ...options?.init },
});
return normalizeProject(res.project);
}
export function deleteProject(id: string, options?: ApiRequestOptions) {
return fetchJson<void>(`${BASE}/projects/${id}`, {
...options,
init: { method: "DELETE", ...options?.init },
});
}
// --- Costs ---
export const getCosts = (workspaceId: string, options?: ApiRequestOptions) =>
fetchJson<CostSummary>(`${BASE}/workspaces/${workspaceId}/costs`, options);
export function getCostSummary(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ total_subcents: number }>(
`${BASE}/workspaces/${workspaceId}/costs/summary`,
options,
);
}
export function getCostsByAgent(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<CostSummary["byAgent"]>(
`${BASE}/workspaces/${workspaceId}/costs/by-agent`,
options,
);
}
export function getCostsByProject(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<CostSummary["byProject"]>(
`${BASE}/workspaces/${workspaceId}/costs/by-project`,
options,
);
}
export function getCostsByModel(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<CostSummary["byModel"]>(
`${BASE}/workspaces/${workspaceId}/costs/by-model`,
options,
);
}
/**
* Composed costs view: total + by-agent / by-project / by-model / by-provider
* in a single round-trip (Stream D of office optimization). The five
* aggregations run concurrently on the backend with a snapshot-consistent
* read, so the page reflects one moment in time.
*/
export function getCostsBreakdown(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{
total_subcents: number;
by_agent: CostBreakdownItemRaw[];
by_project: CostBreakdownItemRaw[];
by_model: CostBreakdownItemRaw[];
by_provider: CostBreakdownItemRaw[];
}>(`${BASE}/workspaces/${workspaceId}/costs/breakdown`, options);
}
type CostBreakdownItemRaw = {
group_key: string;
group_label?: string;
count: number;
total_subcents: number;
};
// --- Built-in default spend ceiling (AC-OFFICE-BUDGET-003.5) ---
// DefaultCeilingRaw is the wire shape of GET/PUT .../budgets/default
// (internal/office/costs/dto.go's DefaultCeilingResponse /
// SetDefaultCeilingRequest, both `{"limit_subcents": ...}`). Named "Raw"
// and read as snake_case directly by the caller, matching this file's
// existing CostBreakdownItemRaw convention, rather than assuming a
// camelCase field the backend does not send.
type DefaultCeilingRaw = { limit_subcents: number };
export function getDefaultCeiling(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<DefaultCeilingRaw>(`${BASE}/workspaces/${workspaceId}/budgets/default`, options);
}
export function setDefaultCeiling(
workspaceId: string,
limitSubcents: number,
options?: ApiRequestOptions,
) {
return fetchJson<DefaultCeilingRaw>(`${BASE}/workspaces/${workspaceId}/budgets/default`, {
...options,
init: {
method: "PUT",
body: JSON.stringify({ limit_subcents: limitSubcents }),
...options?.init,
},
});
}
// --- Approvals ---
export function listApprovals(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ approvals: Approval[] }>(
`${BASE}/workspaces/${workspaceId}/approvals`,
options,
);
}
export function decideApproval(
id: string,
decision: { status: "approved" | "rejected"; note?: string },
options?: ApiRequestOptions,
) {
return fetchJson<Approval>(`${BASE}/approvals/${id}/decide`, {
...options,
init: { method: "POST", body: JSON.stringify(decision), ...options?.init },
});
}
// --- Activity ---
export function listActivity(
workspaceId: string,
filterType?: string,
options?: ApiRequestOptions,
) {
const query = filterType && filterType !== "all" ? `?type=${filterType}` : "";
return fetchJson<{ activity: RawActivityEntry[] }>(
`${BASE}/workspaces/${workspaceId}/activity${query}`,
options,
).then((res) => ({ activity: (res.activity ?? []).map(normalizeActivityEntry) }));
}
export function listActivityForTarget(
workspaceId: string,
targetId: string,
options?: ApiRequestOptions,
) {
const params = new URLSearchParams({ target_id: targetId });
return fetchJson<{ activity: RawActivityEntry[] }>(
`${BASE}/workspaces/${workspaceId}/activity?${params.toString()}`,
options,
).then((res) => ({ activity: (res.activity ?? []).map(normalizeActivityEntry) }));
}
// --- Inbox ---
export function getInbox(workspaceId: string, options?: ApiRequestOptions) {
// Returns items + total_count in a single call (Stream F of office
// optimization). The sidebar count subscribes to the store value the
// inbox page sets; no separate count fetch is required.
return fetchJsonWithRetry<{ items: InboxItem[]; total_count: number }>(
`${BASE}/workspaces/${workspaceId}/inbox`,
options,
);
}
/**
* @deprecated Use getInbox instead — it now returns total_count alongside
* items, removing the need for a second round-trip.
*/
export function getInboxCount(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ count: number }>(
`${BASE}/workspaces/${workspaceId}/inbox?count=true`,
options,
);
}
// --- Agent Memory ---
export function getMemory(agentId: string, options?: ApiRequestOptions) {
return fetchJson<{
memory: Array<{
id: string;
layer: string;
key: string;
content: string;
metadata: string;
updated_at: string;
}>;
}>(`${BASE}/agents/${agentId}/memory`, options);
}
export function putMemory(
agentId: string,
data: { layer: string; key: string; content: string },
options?: ApiRequestOptions,
) {
return fetchJson<void>(`${BASE}/agents/${agentId}/memory`, {
...options,
init: { method: "PUT", body: JSON.stringify({ entries: [data] }), ...options?.init },
});
}
export function deleteMemory(agentId: string, entryId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`${BASE}/agents/${agentId}/memory/${entryId}`, {
...options,
init: { method: "DELETE", ...options?.init },
});
}
export function deleteAllMemory(agentId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`${BASE}/agents/${agentId}/memory/all`, {
...options,
init: { method: "DELETE", ...options?.init },
});
}
export function exportMemory(agentId: string, options?: ApiRequestOptions) {
return fetchJson<{
memory: Array<{
id: string;
layer: string;
key: string;
content: string;
metadata: string;
updated_at: string;
}>;
}>(`${BASE}/agents/${agentId}/memory/export`, options);
}
export function getMemorySummary(agentId: string, options?: ApiRequestOptions) {
return fetchJson<{ count: number }>(`${BASE}/agents/${agentId}/memory/summary`, options);
}
// --- Live Runs ---
export function getLiveRuns(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ runs: LiveRun[] }>(`${BASE}/workspaces/${workspaceId}/live-runs`, options);
}
// --- Agent summaries ---
//
// Per-agent dashboard cards. One entry per workspace agent, with up to five
// most-recent sessions enriched with task identifier/title and command count.
// Backend: GET /api/v1/office/workspaces/:wsId/agent-summaries.
export type SessionSummary = {
session_id: string;
task_id: string;
task_identifier: string;
task_title: string;
state: string;
started_at: string;
completed_at?: string | null;
/**
* Backend-computed elapsed seconds. Stable across refetches: for
* non-RUNNING sessions it uses completed_at (or updated_at for office
* IDLE rows that never complete) so the value doesn't drift.
*/
duration_seconds: number;
command_count: number;
};
export type AgentSummary = {
agent_id: string;
agent_name: string;
agent_role: string;
status: "live" | "finished" | "never_run";
live_session?: SessionSummary | null;
last_session?: SessionSummary | null;
recent_sessions: SessionSummary[];
/** Most recent run status — "ok" | "failed". */
last_run_status?: "ok" | "failed";
/** Set when the agent is currently auto-paused after threshold. */
pause_reason?: string;
/** Counter for the auto-pause UX. */
consecutive_failures?: number;
};
export function getAgentSummaries(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ agents: AgentSummary[] }>(
`${BASE}/workspaces/${workspaceId}/agent-summaries`,
options,
);
}