Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/api/src/routes/admin/project-data-archive-breakers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Hono } from 'hono';

import type { Env } from '../../env';
import { errors } from '../../middleware/error';
import { getProjectDataArchiveRolloutListConfig } from '../../services/project-data-archive-rollout-controls';

/**
* Superadmin list of per-project archive-sharding circuit breakers.
*
* Mounted at `/api/admin/project-data/storage/archive-sharding/circuit-breakers`.
* D1-only and bounded: this backs the Admin → Storage tab, which is the
* phone-usable path for closing a tripped breaker (the POST lives in
* `project-data-storage.ts` as `/:projectId/archive-sharding/circuit-breaker`).
*/
export const adminProjectDataArchiveBreakerRoutes = new Hono<{ Bindings: Env }>();

const BREAKER_STATES = new Set(['closed', 'open', 'frozen']);

type BreakerRow = {
project_id: unknown;
project_name: unknown;
repository: unknown;
state: unknown;
reason: unknown;
opened_at: unknown;
updated_at: unknown;
};

export function parseArchiveRolloutLimit(rawLimit: string | undefined, env: Env): number {
const { defaultLimit, maxLimit } = getProjectDataArchiveRolloutListConfig(env);
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : defaultLimit;
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > maxLimit) {
throw errors.badRequest(`limit must be between 1 and ${maxLimit}`);
}
return parsedLimit;
}

function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}

function optionalNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

adminProjectDataArchiveBreakerRoutes.get('/', async (c) => {
const limit = parseArchiveRolloutLimit(c.req.query('limit'), c.env);
const result = await c.env.DATABASE.prepare(
`SELECT
b.project_id,
p.name AS project_name,
p.repository AS repository,
b.state,
b.reason,
b.opened_at,
b.updated_at
FROM project_data_archive_circuit_breakers b
LEFT JOIN projects p ON p.id = b.project_id
ORDER BY CASE WHEN b.state = 'closed' THEN 1 ELSE 0 END ASC, b.updated_at DESC
LIMIT ?`
)
.bind(limit)
.all<BreakerRow>();

const breakers = [];
let skippedRows = 0;
for (const row of result.results ?? []) {
const projectId = optionalString(row.project_id);
const updatedAt = optionalNumber(row.updated_at);
if (!projectId || updatedAt === null || !BREAKER_STATES.has(String(row.state))) {
skippedRows += 1;
continue;
}
breakers.push({
projectId,
projectName: optionalString(row.project_name),
repository: optionalString(row.repository),
state: row.state as 'closed' | 'open' | 'frozen',
reason: optionalString(row.reason),
openedAt: optionalNumber(row.opened_at),
updatedAt,
});
}

return c.json({ breakers, skippedRows, limit });
});
19 changes: 9 additions & 10 deletions apps/api/src/routes/admin/project-data-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ import {
import {
freezeProjectDataArchiveProject,
getProjectDataArchiveManualCanaryConfig,
getProjectDataArchiveRolloutListConfig,
getProjectDataArchiveRolloutState,
listProjectDataArchiveProblemMigrations,
setProjectDataArchiveCircuitBreaker,
} from '../../services/project-data-archive-rollout-controls';
import {
adminProjectDataArchiveBreakerRoutes,
parseArchiveRolloutLimit,
} from './project-data-archive-breakers';

const PROJECT_DATA_STORAGE_STATUSES = new Set(['ok', 'notice', 'warning', 'critical', 'degraded']);
const PROJECT_DATA_STORAGE_CLEANUP_HEALTH_STATES = new Set([
Expand All @@ -52,6 +55,11 @@ const DEFAULT_STORAGE_TELEMETRY_LIST_MAX = 200;

export const adminProjectDataStorageRoutes = new Hono<{ Bindings: Env }>();

adminProjectDataStorageRoutes.route(
'/archive-sharding/circuit-breakers',
adminProjectDataArchiveBreakerRoutes
);

function parsePositiveIntegerConfig(raw: string | undefined, fallback: number): number {
if (!raw?.trim()) return fallback;
const parsed = Number.parseInt(raw, 10);
Expand Down Expand Up @@ -83,15 +91,6 @@ function parseStorageTelemetryLimit(rawLimit: string | undefined, env: Env): num
return parsedLimit;
}

function parseArchiveRolloutLimit(rawLimit: string | undefined, env: Env): number {
const { defaultLimit, maxLimit } = getProjectDataArchiveRolloutListConfig(env);
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : defaultLimit;
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > maxLimit) {
throw errors.badRequest(`limit must be between 1 and ${maxLimit}`);
}
return parsedLimit;
}

function parseArchiveFrozenIntentLimit(rawLimit: string | undefined, env: Env): number {
const { defaultLimit, maxLimit } = getProjectDataArchiveFrozenIntentInspectionConfig(env);
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : defaultLimit;
Expand Down
167 changes: 167 additions & 0 deletions apps/api/tests/unit/routes/admin-project-data-archive-breakers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';

import * as schema from '../../../src/db/schema';
import type { Env } from '../../../src/env';
import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1';
import { createAdminProjectDataStorageApp } from './helpers/admin-project-data-storage-route';

const LIST_URL =
'http://localhost/api/admin/project-data/storage/archive-sharding/circuit-breakers';

function makeEnv(sqlite: Database.Database): Env {
return { DATABASE: createSqliteD1(sqlite) } as Env;
}

function createTables(sqlite: Database.Database): void {
createSchemaTables(sqlite, [schema.projects, schema.projectDataArchiveCircuitBreakers]);
}

function seedProject(sqlite: Database.Database, id: string, name: string): void {
sqlite
.prepare(
`INSERT INTO projects (id, user_id, name, normalized_name, repository, created_at, updated_at)
VALUES (?, 'owner', ?, ?, ?, 1, 1)`
)
.run(id, name, name.toLowerCase(), `org/${id}`);
}

function seedBreaker(
sqlite: Database.Database,
input: {
projectId: string;
state: string;
reason?: string | null;
openedAt?: number | null;
updatedAt: number;
}
): void {
sqlite
.prepare(
`INSERT INTO project_data_archive_circuit_breakers (project_id, state, reason, opened_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
)
.run(
input.projectId,
input.state,
input.reason ?? null,
input.openedAt ?? null,
input.updatedAt
);
}

describe('GET /api/admin/project-data/storage/archive-sharding/circuit-breakers', () => {
it('lists breakers with project names, open breakers first, and skips malformed rows', async () => {
const sqlite = new Database(':memory:');
createTables(sqlite);
seedProject(sqlite, 'project-open', 'SAM');
seedProject(sqlite, 'project-closed', 'Other');
seedBreaker(sqlite, {
projectId: 'project-closed',
state: 'closed',
reason: 'operator reset',
updatedAt: 5_000,
});
seedBreaker(sqlite, {
projectId: 'project-open',
state: 'open',
reason: 'attempts_exhausted:Error',
openedAt: 1_000,
updatedAt: 2_000,
});
// Breaker for a deleted project: still listed, name null.
seedBreaker(sqlite, { projectId: 'project-gone', state: 'frozen', updatedAt: 3_000 });
// Malformed state must be skipped, not fail the list (rule 50).
seedBreaker(sqlite, { projectId: 'project-bad', state: 'bogus', updatedAt: 4_000 });

const app = createAdminProjectDataStorageApp();
const response = await app.request(
LIST_URL,
{ headers: { 'x-test-role': 'superadmin' } },
makeEnv(sqlite)
);

expect(response.status).toBe(200);
const body = (await response.json()) as {
breakers: Array<Record<string, unknown>>;
skippedRows: number;
limit: number;
};
expect(body.skippedRows).toBe(1);
expect(body.breakers.map((b) => b.projectId)).toEqual([
'project-gone',
'project-open',
'project-closed',
]);
expect(body.breakers[1]).toEqual({
projectId: 'project-open',
projectName: 'SAM',
repository: 'org/project-open',
state: 'open',
reason: 'attempts_exhausted:Error',
openedAt: 1_000,
updatedAt: 2_000,
});
expect(body.breakers[0]).toMatchObject({
projectName: null,
repository: null,
state: 'frozen',
});
});

it('rejects non-superadmin callers', async () => {
const sqlite = new Database(':memory:');
createTables(sqlite);
const app = createAdminProjectDataStorageApp();
const response = await app.request(LIST_URL, {}, makeEnv(sqlite));
expect(response.status).toBe(403);
});

it('rejects an out-of-range limit', async () => {
const sqlite = new Database(':memory:');
createTables(sqlite);
const app = createAdminProjectDataStorageApp();
const response = await app.request(
`${LIST_URL}?limit=0`,
{ headers: { 'x-test-role': 'superadmin' } },
makeEnv(sqlite)
);
expect(response.status).toBe(400);
});

it('closes a breaker through the control route and the list reflects it', async () => {
const sqlite = new Database(':memory:');
createTables(sqlite);
seedProject(sqlite, 'project-open', 'SAM');
seedBreaker(sqlite, {
projectId: 'project-open',
state: 'open',
reason: 'attempts_exhausted:Error',
openedAt: 1_000,
updatedAt: 2_000,
});
const app = createAdminProjectDataStorageApp();
const env = makeEnv(sqlite);

const close = await app.request(
'http://localhost/api/admin/project-data/storage/project-open/archive-sharding/circuit-breaker',
{
method: 'POST',
headers: { 'x-test-role': 'superadmin', 'content-type': 'application/json' },
body: JSON.stringify({ state: 'closed', reason: 'Closed from admin UI' }),
},
env
);
expect(close.status).toBe(200);

const list = await app.request(LIST_URL, { headers: { 'x-test-role': 'superadmin' } }, env);
const body = (await list.json()) as { breakers: Array<Record<string, unknown>> };
expect(body.breakers).toHaveLength(1);
expect(body.breakers[0]).toMatchObject({
projectId: 'project-open',
state: 'closed',
reason: 'Closed from admin UI',
openedAt: null,
});
});
});
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const AdminProjectEvents = lazyNamed(
() => import('./pages/AdminProjectEvents'),
'AdminProjectEvents'
);
const AdminStorage = lazyNamed(() => import('./pages/AdminStorage'), 'AdminStorage');
const AdminStream = lazyNamed(() => import('./pages/AdminStream'), 'AdminStream');
const AdminTrials = lazyNamed(() => import('./pages/AdminTrials'), 'AdminTrials');
const AdminUsers = lazyNamed(() => import('./pages/AdminUsers'), 'AdminUsers');
Expand Down Expand Up @@ -379,6 +380,7 @@ export default function App() {
<Route path="integrations" element={page(<AdminPlatformConfig />)} />
<Route path="credentials" element={page(<AdminPlatformCredentials />)} />
<Route path="infrastructure" element={page(<AdminInfrastructure />)} />
<Route path="storage" element={page(<AdminStorage />)} />
<Route path="ai-proxy" element={page(<AdminAIProxy />)} />
<Route path="trials" element={page(<AdminTrials />)} />
<Route path="costs" element={page(<AdminCosts />)} />
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/lib/api/admin-project-data-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {
AdminProjectDataArchiveCircuitBreakerControlResponse,
AdminProjectDataArchiveCircuitBreakersResponse,
AdminProjectDataStorageTelemetryResponse,
} from '@simple-agent-manager/shared';

import { request } from './client';

// =============================================================================
// Admin ProjectData storage (Admin → Storage tab)
// =============================================================================

export async function fetchAdminProjectDataStorageTelemetry(
limit?: number
): Promise<AdminProjectDataStorageTelemetryResponse> {
const params = limit ? `?limit=${limit}` : '';
return request<AdminProjectDataStorageTelemetryResponse>(
`/api/admin/project-data/storage${params}`
);
}

export async function fetchAdminProjectDataArchiveCircuitBreakers(
limit?: number
): Promise<AdminProjectDataArchiveCircuitBreakersResponse> {
const params = limit ? `?limit=${limit}` : '';
return request<AdminProjectDataArchiveCircuitBreakersResponse>(
`/api/admin/project-data/storage/archive-sharding/circuit-breakers${params}`
);
}

export async function closeAdminProjectDataArchiveCircuitBreaker(
projectId: string,
reason: string
): Promise<AdminProjectDataArchiveCircuitBreakerControlResponse> {
return request<AdminProjectDataArchiveCircuitBreakerControlResponse>(
`/api/admin/project-data/storage/${encodeURIComponent(projectId)}/archive-sharding/circuit-breaker`,
{
method: 'POST',
body: JSON.stringify({ state: 'closed', reason }),
}
);
}
5 changes: 5 additions & 0 deletions apps/web/src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export {
updatePlatformCredential,
updateSignupApprovalConfig,
} from './admin';
export {
closeAdminProjectDataArchiveCircuitBreaker,
fetchAdminProjectDataArchiveCircuitBreakers,
fetchAdminProjectDataStorageTelemetry,
} from './admin-project-data-storage';
export {
createAgentProfile,
createSkill,
Expand Down
Loading
Loading