From d1cad7fb5f5e334f0ab0a8a7001c66762c4ffd89 Mon Sep 17 00:00:00 2001 From: distributed-nerd Date: Sat, 29 Aug 2026 17:17:58 +0100 Subject: [PATCH] feat(ops): add incident controls and upgrade surface --- README.md | 3 + backend/src/api/app.ts | 274 ++++-------------- backend/src/api/middleware/readOnly.ts | 36 +++ backend/src/api/routes/admin.ts | 201 ++++++++++++- backend/src/services/adminControl.ts | 248 ++++++++++++++++ docs/README.md | 11 + docs/architecture/index.md | 188 ++++++++++++ docs/design-system.md | 60 ++++ .../agents/AgentDetailModal.module.css | 40 +-- .../agents/AgentFilterBar.module.css | 34 +-- .../components/agents/AgentOutputRenderer.tsx | 4 +- .../agents/AgentReputationRadar.tsx | 4 +- .../agents/AgentReputationTrend.tsx | 4 +- .../components/agents/AgentTable.module.css | 40 +-- .../src/components/agents/CodingRenderer.tsx | 14 +- .../components/agents/DAGPreview.module.css | 4 +- frontend/src/components/agents/DAGPreview.tsx | 16 +- .../src/components/agents/DesignRenderer.tsx | 66 ++--- .../agents/ResearchReportRenderer.tsx | 6 +- frontend/src/components/agents/RiskMatrix.tsx | 50 ++-- .../components/agents/TaskSubmissionForm.tsx | 26 +- .../common/CommandPalette.module.css | 78 ++--- .../src/components/common/ErrorBoundary.tsx | 20 +- frontend/src/components/common/FormField.tsx | 16 +- .../common/ImageLightbox.module.css | 72 ++--- .../src/components/common/Skeleton.module.css | 10 +- frontend/src/components/common/Toast.css | 117 ++------ .../components/dashboard/KpiCard.module.css | 6 +- frontend/src/components/dashboard/KpiCard.tsx | 6 +- .../dashboard/NetworkHealthBadge.module.css | 8 +- .../dashboard/RecentTasksTable.module.css | 8 +- frontend/src/components/landing/AgentCard.tsx | 4 +- frontend/src/components/landing/Footer.tsx | 2 +- .../src/components/landing/Hero.module.css | 2 +- frontend/src/components/landing/Hero.tsx | 6 +- frontend/src/components/landing/Navbar.tsx | 16 +- .../landing/SpecialistAgentsSection.tsx | 6 +- frontend/src/components/layout/Breadcrumb.css | 8 +- .../src/components/layout/MobileDrawer.css | 40 +-- frontend/src/components/layout/Sidebar.css | 16 +- frontend/src/components/layout/TopNav.css | 87 +++--- frontend/src/components/layout/TopNav.tsx | 2 +- .../notifications/NotificationCenter.css | 106 +++---- .../tasks/TaskComparison.module.css | 60 ++-- .../src/components/tasks/TaskComparison.tsx | 30 +- .../components/tasks/TaskFilterBar.module.css | 72 ++--- .../components/tasks/TaskTimeline.module.css | 118 ++++---- .../src/components/tasks/TaskTimeline.tsx | 32 +- .../components/wallet/ExportButton.module.css | 10 +- .../components/wallet/PaymentChart.module.css | 2 +- .../src/components/wallet/PaymentChart.tsx | 4 +- .../components/wallet/SendXLMForm.module.css | 58 ++-- .../wallet/TransactionTable.module.css | 32 +- .../components/wallet/WalletWizard.module.css | 40 +-- .../src/components/wallet/WalletWizard.tsx | 4 +- frontend/src/pages/AgentsPage.module.css | 22 +- frontend/src/pages/NotFoundPage.tsx | 8 +- frontend/src/pages/RendererDemoPage.tsx | 10 +- frontend/src/pages/TaskDetailPage.tsx | 80 ++--- frontend/src/pages/WalletPage.module.css | 88 +++--- frontend/src/pages/dashboard.module.css | 4 +- .../pages/tasks/TaskHistoryPage.module.css | 38 +-- frontend/src/styles/global.css | 96 ++---- frontend/src/styles/micro-interactions.css | 2 +- frontend/src/styles/tokens.css | 255 ++++++++++++++++ frontend/tailwind.config.js | 64 +++- .../contracts/agent_bidding/src/errors.rs | 8 +- .../contracts/agent_bidding/src/lib.rs | 53 ++++ .../contracts/agent_bidding/src/types.rs | 4 + .../contracts/agent_registry/src/lib.rs | 5 + .../contracts/agent_registry/src/upgrade.rs | 52 +++- .../contracts/error-registry/src/lib.rs | 67 ++++- .../error-resolver/src/agent_errors.rs | 39 ++- .../contracts/task_store/src/lib.rs | 53 +++- .../contracts/task_store/src/types.rs | 6 + .../deployments/futurenet.json.template | 56 +++- .../deployments/mainnet.json.template | 56 +++- .../deployments/testnet.json.template | 56 +++- .../scripts/verified-upgrade-sequence.sh | 183 ++++++++++++ 79 files changed, 2569 insertions(+), 1163 deletions(-) create mode 100644 backend/src/api/middleware/readOnly.ts create mode 100644 backend/src/services/adminControl.ts create mode 100644 docs/README.md create mode 100644 docs/architecture/index.md create mode 100644 docs/design-system.md create mode 100644 frontend/src/styles/tokens.css create mode 100755 smart-contracts/scripts/verified-upgrade-sequence.sh diff --git a/README.md b/README.md index 16e60318..1bb38eba 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,9 @@ npm run test:e2e ## Documentation +- [Documentation Index](docs/README.md): Navigation for architecture, design, API, operations, and testing docs. +- [Architecture](docs/architecture/index.md): Authoritative system context, component diagrams, task lifecycle, payment/escrow sequence, layer responsibilities, and test map. +- [Design System](docs/design-system.md): Semantic tokens, theme model, and frontend component conventions. - [REST API Reference](docs/API_REFERENCE.md): Comprehensive per-endpoint documentation, error codes taxonomy, authentication headers, and runnable curl examples. - [Node Operators Guide](docs/NODE_OPERATORS_GUIDE.md): Step-by-step instructions for provisioning, configuring secrets, deploying smart contracts, funding accounts, operating nodes, monitoring metrics, and troubleshooting common errors. - [Smart Contract Deployment Guide](smart-contracts/docs/DEPLOYMENT_GUIDE.md): Complete deployment and upgrade workflows on Soroban. diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 7baf55f0..3204f90e 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -1,200 +1,69 @@ -/** - * Express application factory. - * - * Called by tests (pass port=0 for random) and by the server entry-point. - * Wires up: - * - JSON body parsing - * - Pino HTTP request logging - * - Cache initialisation - * - Route mounting (health, stats, agents) - * - Global error handler - */ - -import express, { Request, Response, NextFunction } from 'express'; -import pinoHttp from 'pino-http'; -import { config } from '../config/index'; -import { initCache } from '../cache/index'; -import { logger } from './logger'; -import healthRouter from './routes/health'; -import statsRouter from './routes/stats'; -import agentsRouter from './routes/agents'; - -export function createApp() { - // Initialise cache once (idempotent — subsequent calls return the same client) - try { - initCache({ - driver: config.CACHE_DRIVER, - redisUrl: config.REDIS_URL, - lruMaxSize: config.CACHE_LRU_MAX_SIZE, - defaultTtlSeconds: Math.max( - config.CACHE_TTL_AGENTS, - config.CACHE_TTL_STATS, - config.CACHE_TTL_HEALTH, - ), - }); - } catch { - // Already initialised (e.g. during testing) — ignore - } - - const app = express(); - - // ── Middleware stack ────────────────────────────────────────────────────── - - app.use(express.json()); - - if (config.NODE_ENV !== 'test') { - app.use(pinoHttp({ logger })); - } - - // ── Routes ──────────────────────────────────────────────────────────────── - - app.use('/api/health', healthRouter); - app.use('/api/stats', statsRouter); - app.use('/api/agents', agentsRouter); - - // ── 404 catch-all ───────────────────────────────────────────────────────── - - app.use((_req: Request, res: Response) => { - res.status(404).json({ error: { message: 'Not found', code: 'NOT_FOUND' } }); - }); - - // ── Global error handler ────────────────────────────────────────────────── - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { - logger.error({ err }, 'Unhandled error'); - res.status(500).json({ - error: { message: err.message ?? 'Internal server error', code: 'INTERNAL_ERROR' }, - }); - }); - - return app; -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -if (require.main === module) { - const app = createApp(); - const server = app.listen(config.PORT, () => { - logger.info({ port: config.PORT, env: config.NODE_ENV }, 'ai-net backend started'); - }); - - const shutdown = () => { - logger.info('Received shutdown signal — draining connections…'); - server.close(() => { - logger.info('Server closed'); - process.exit(0); - }); - setTimeout(() => { - logger.error('Graceful shutdown timed out — forcing exit'); - process.exit(1); - }, 10_000); - }; - - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); -import express, { Request, Response, NextFunction } from "express"; -import { createServer, Server as HttpServer } from "http"; -import { randomUUID } from "crypto"; +import express, { type Request, type Response } from "express"; +import { createServer, type Server as HttpServer } from "http"; import swaggerUi from "swagger-ui-express"; import { + createTaskJobHandler, type DispatchFn, type PaymentReleaseFn, } from "../coordinator/coordinator"; import { httpDispatch } from "../coordinator/dispatch"; -import type { AgentRegistry } from "../types/agent"; -import { getTask } from "../coordinator/taskStore"; import { eventBus } from "../coordinator/eventBus"; +import { getTask } from "../coordinator/taskStore"; +import { getTaskDb } from "../db/tasks"; +import { createPaymentReleaseFn, type StellarReleasePaymentFn } from "../payment"; +import { getGlobalJobQueue, JobWorker, type JobQueue } from "../queue"; +import { createHeartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat"; +import { metricsMiddleware, metricsService } from "../services/metrics"; import type { EventStore } from "../events/eventStore"; -import { - attachTaskStream, - getStreamConnectionCount, - type TaskStreamOptions, -} from "./routes/stream"; +import type { AgentRegistry } from "../types/agent"; import type { DAGNode } from "../types/task"; -import { - createPaymentReleaseFn, - type StellarReleasePaymentFn, -} from "../payment"; -import { agentsRouter } from "./routes/agents"; -import { healthRouter } from "./routes/health"; -import { createStatsRouter } from "./routes/stats"; -import { createTasksRouter } from "./routes/tasks"; -import { createReconciliationRouter, type ReconciliationRouterOptions } from "./routes/reconciliation"; -import { rateLimitMiddleware, registerRateLimitMiddleware } from "./middleware/rateLimit"; -import { authMiddleware } from "./middleware/auth"; -import { createCorsMiddleware } from "./middleware/cors"; +import { adminAuthMiddleware } from "./middleware/auth"; import { compressionMiddleware } from "./middleware/compression"; +import { createCorsMiddleware } from "./middleware/cors"; +import { errorHandler } from "./middleware/errorHandler"; +import { readOnlyMiddleware } from "./middleware/readOnly"; +import { registerRateLimitMiddleware } from "./middleware/rateLimit"; import { requestId } from "./middleware/requestId"; import { requestLogger } from "./middleware/requestLogger"; -import { errorHandler } from "./middleware/errorHandler"; import { versioningMiddleware } from "./middleware/versioning"; +import { getOpenapiJson, getOpenapiYaml, openapiSpec, swaggerUiOptions } from "./docs"; +import { agentsRouter } from "./routes/agents"; +import { createAdminRouter } from "./routes/admin"; +import { healthRouter } from "./routes/health"; +import { createReconciliationRouter, type ReconciliationRouterOptions } from "./routes/reconciliation"; +import { createStatsRouter } from "./routes/stats"; +import { attachTaskStream, getStreamConnectionCount, type TaskStreamOptions } from "./routes/stream"; import { createV1TasksRouter } from "./routes/v1/tasks"; import { createV2TasksRouter } from "./routes/v2/tasks"; -import { createLogger } from "../utils/logger"; -import { createTaskDb, getTaskDb } from "../db/tasks"; -import { createHeartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat"; -import { createTaskJobHandler } from "../coordinator/coordinator"; -import { - openapiSpec, - swaggerUiOptions, - getOpenapiJson, - getOpenapiYaml, -} from "./docs"; -import { - getGlobalJobQueue, - createJobStore, - getJobDb, - closeJobDb, - JobWorker, - type JobQueue, -} from "../queue"; -import { createAdminQueueRouter } from "./routes/admin"; export interface AppOptions { - /** Called to execute a single DAG node; defaults to HTTP dispatch via agent registry */ + /** Called to execute a single DAG node; defaults to HTTP dispatch via agent registry. */ dispatch?: DispatchFn; - /** Called after each node completes; defaults to no-op (returns 'mock-hash') */ + /** Called after each node completes; defaults to no-op payment release. */ releasePayment?: PaymentReleaseFn; - /** - * Override the EventStore used for stream replay. When omitted, the store - * owned by `eventBus` is used — which is the canonical single store that the - * EventBus persists to. Only provide this in tests that need an isolated - * store; production code should leave it unset. - * - * @deprecated Pass a custom EventBus instance (with its own store) instead. - */ + /** Override the EventStore used for stream replay. */ eventStore?: EventStore; - /** Heartbeat / auth timing for the WebSocket stream */ + /** Heartbeat / auth timing for the WebSocket stream. */ stream?: TaskStreamOptions; - /** - * Agent registry used to resolve endpoint URLs for HTTP dispatch. - * Required when `dispatch` is not provided; ignored when `dispatch` is set. - */ + /** Agent registry used to resolve endpoint URLs for HTTP dispatch. */ agentRegistry?: AgentRegistry; - /** Enable background heartbeat cleanup service (defaults to true in non-test envs) */ + /** Enable background heartbeat cleanup service. */ enableHeartbeatCleanup?: boolean; - /** Custom options for heartbeat cleanup service */ + /** Custom options for heartbeat cleanup service. */ heartbeatOptions?: HeartbeatServiceOptions; - /** Options for the payment reconciliation router */ + /** Options for the payment reconciliation router. */ reconciliation?: ReconciliationRouterOptions; - /** Disable response compression (useful in tests). Default: false. */ + /** Disable response compression. Default: false. */ disableCompression?: boolean; - /** Custom job queue instance */ + /** Custom job queue instance. */ queue?: JobQueue; - /** Custom job worker instance */ + /** Custom job worker instance. */ jobWorker?: JobWorker; - /** Enable background queue worker (default: true) */ + /** Enable background queue worker. Default: true. */ enableQueueWorker?: boolean; } -/** - * Attempt to load smart-contracts releasePayment at runtime via dynamic require. - * Returns undefined when the module is unavailable (e.g. backend CI without - * smart-contracts compiled). Using require() instead of a static import keeps - * TypeScript's rootDir constraint intact. - */ function tryLoadStellarRelease(): StellarReleasePaymentFn | undefined { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -210,25 +79,28 @@ export function createApp(opts: AppOptions = {}): { close: (callback?: () => void) => void; } { const app = express(); + app.use(express.json()); - // ── Global middleware ──────────────────────────────────────────────────────── app.use((_req, res, next) => { if (process.env.NODE_ENV === "production") { - res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload"); + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ); } next(); }); app.use(createCorsMiddleware()); app.use(requestId); app.use(requestLogger); - // Samples every response into the health dashboard's rolling window. Mounted - // before the routers so latency covers the full handler chain. app.use(metricsMiddleware); app.use(versioningMiddleware); + app.use( + readOnlyMiddleware({ + exemptPaths: ["/api/admin", "/api/reconciliation"], + }), + ); - // ── Response compression ──────────────────────────────────────────────────── - // Applied early so that all downstream route handlers benefit automatically. - // Disabled in tests (disableCompression: true) to keep assertions simple. if (!opts.disableCompression && process.env.NODE_ENV !== "test") { app.use(...compressionMiddleware()); } @@ -237,7 +109,6 @@ export function createApp(opts: AppOptions = {}): { const releasePayment: PaymentReleaseFn = opts.releasePayment ?? createPaymentReleaseFn(tryLoadStellarRelease()); - // ── Background Job Queue & Worker ────────────────────────────────────────── const jobQueue = opts.queue ?? getGlobalJobQueue(); const jobWorker = opts.jobWorker ?? @@ -251,25 +122,17 @@ export function createApp(opts: AppOptions = {}): { jobWorker.start(); } - // ── Heartbeat Background Cleanup Service ──────────────────────────────────── const heartbeatService = createHeartbeatService(opts.heartbeatOptions); if (opts.enableHeartbeatCleanup || (opts.enableHeartbeatCleanup !== false && process.env.NODE_ENV !== "test")) { heartbeatService.start(); } - // ── Health routes ─────────────────────────────────────────────────────────── app.use("/health", healthRouter); - - // ── Stats routes ─────────────────────────────────────────────────────────── app.use("/api/stats", createStatsRouter(getTaskDb())); - // ── Agent routes ─────────────────────────────────────────────────────────── - // Apply a stricter rate limit specifically to the register endpoint to - // prevent registration floods (the full agentsRouter handles GET/DELETE etc.). app.post("/api/agents/register", registerRateLimitMiddleware); app.use("/api/agents", agentsRouter); - // ── API docs ───────────────────────────────────────────────────────────────── app.get("/openapi.json", (_req: Request, res: Response) => { res.json(getOpenapiJson()); }); @@ -286,43 +149,25 @@ export function createApp(opts: AppOptions = {}): { }); app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, swaggerUiOptions)); - // ── Task routes ──────────────────────────────────────────────────────────── - // Create version-specific routers const v1TasksRouter = createV1TasksRouter(dispatch, releasePayment, jobQueue); const v2TasksRouter = createV2TasksRouter(dispatch, releasePayment, jobQueue); - - // Version-specific task routing based on negotiated API version app.use("/api/tasks", (req, res, next) => { const apiVersion = res.locals.apiVersion || "1.0"; - - // Route to version-specific handler based on negotiated version if (apiVersion.startsWith("1.")) { return v1TasksRouter(req, res, next); - } else { - // Default to v2 for version 2.0 and above - return v2TasksRouter(req, res, next); } + return v2TasksRouter(req, res, next); }); - // ── Admin Queue routes ───────────────────────────────────────────────────── - app.use("/api/admin/queue", createAdminQueueRouter(jobQueue)); - app.use("/api/admin", createAdminQueueRouter(jobQueue)); - - // ── Payment reconciliation routes ────────────────────────────────────────── - app.use("/api/reconciliation", createReconciliationRouter(opts.reconciliation)); + app.use( + "/api/admin", + adminAuthMiddleware, + createAdminRouter({ queue: jobQueue, reconciliation: opts.reconciliation }), + ); + app.use("/api/reconciliation", adminAuthMiddleware, createReconciliationRouter(opts.reconciliation)); - // ── HTTP server ──────────────────────────────────────────────────── const httpServer = createServer(app); - - // ── Event persistence ────────────────────────────────────────────────────── - // The EventBus already persists every event to its own EventStore (wired in - // the EventBus constructor). We use that same store as the single canonical - // source for stream replay so there is exactly one DB and one writer. - // opts.eventStore is kept for backward compatibility with tests that inject - // a custom store; in production it will always be undefined here. const eventStore = opts.eventStore ?? eventBus.store; - - // ── WebSocket: /tasks/:id/stream ─────────────────────────────────────────── const detachStream = attachTaskStream({ httpServer, eventStore, @@ -331,17 +176,12 @@ export function createApp(opts: AppOptions = {}): { ...opts.stream, }); - // ── Metrics ──────────────────────────────────────────────────────────────── - // GC is process-global, so the observer is started once and left running for - // the lifetime of the process (it is unref'd and never holds the event loop - // open). The WebSocket probe is per-app and is cleared on close(). metricsService.startGcObserver(); metricsService.setWebSocketProbe(() => ({ listening: httpServer.listening, connections: getStreamConnectionCount(), })); - // ── Error handler (must be last) ─────────────────────────────────────────── app.use(errorHandler); function close(callback?: () => void): void { @@ -359,20 +199,11 @@ export function createApp(opts: AppOptions = {}): { return { httpServer, close }; } - -/** - * Build a DispatchFn that looks up the cheapest agent for a node's type in the - * provided registry and forwards the call to that agent via HTTP. - * - * If no registry is provided (e.g. during tests that supply their own dispatch) - * the returned function throws a clear error so misconfiguration is obvious at - * runtime rather than producing a silent no-op. - */ function makeHttpDispatch(registry?: AgentRegistry): DispatchFn { return async (taskId: string, node: DAGNode, context: string): Promise => { if (!registry) { throw new Error( - `No agent registry configured. Provide agentRegistry in AppOptions or supply a custom dispatch function.`, + "No agent registry configured. Provide agentRegistry in AppOptions or supply a custom dispatch function.", ); } @@ -381,7 +212,6 @@ function makeHttpDispatch(registry?: AgentRegistry): DispatchFn { throw new Error(`No agent registered for type: ${node.type}`); } - // Pick cheapest available agent. const agent = [...agents].sort((a, b) => a.cost - b.cost)[0]; return httpDispatch(agent, node.nodeId, node, context); }; diff --git a/backend/src/api/middleware/readOnly.ts b/backend/src/api/middleware/readOnly.ts new file mode 100644 index 00000000..d89c0368 --- /dev/null +++ b/backend/src/api/middleware/readOnly.ts @@ -0,0 +1,36 @@ +import type { NextFunction, Request, Response } from "express"; +import { getReadOnlyState, isReadOnly } from "../../services/adminControl"; + +const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export interface ReadOnlyMiddlewareOptions { + exemptPaths?: string[]; +} + +export function readOnlyMiddleware(options: ReadOnlyMiddlewareOptions = {}) { + const exemptPaths = options.exemptPaths ?? []; + + return (req: Request, res: Response, next: NextFunction): void => { + if (!MUTATION_METHODS.has(req.method)) { + next(); + return; + } + + if (exemptPaths.some((prefix) => req.path === prefix || req.path.startsWith(`${prefix}/`))) { + next(); + return; + } + + if (!isReadOnly()) { + next(); + return; + } + + const state = getReadOnlyState(); + res.status(503).json({ + error: "READ_ONLY", + message: "Mutations are temporarily disabled by an operator.", + readOnly: state, + }); + }; +} diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 2bd290e4..98023dfb 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -1,5 +1,204 @@ -import { Router, Request, Response } from "express"; +import { Router, type NextFunction, type Request, type Response } from "express"; +import { z } from "zod"; import { getGlobalJobQueue, type JobQueue, type JobStatus } from "../../queue"; +import { + actorFromRequest, + auditLogToCsv, + backupDatabases, + getReadOnlyState, + listAdminAuditLog, + listAgentsForAdmin, + recordAdminAudit, + setAgentEnabled, + setReadOnlyState, + vacuumDatabases, +} from "../../services/adminControl"; +import { + ReconciliationService, + createDefaultReconciliationService, +} from "../../services/reconciliation"; +import type { ReconciliationRouterOptions } from "./reconciliation"; +import type { ReconciliationTrigger } from "../../services/reconciliation.types"; +import { createLogger } from "../../utils/logger"; + +const logger = createLogger({ module: "admin" }); + +const readOnlySchema = z.object({ + enabled: z.boolean(), + reason: z.string().max(500).optional(), +}); + +const agentListSchema = z.object({ + status: z.enum(["online", "offline"]).optional(), +}); + +const auditLogQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(1000).default(200), + offset: z.coerce.number().int().min(0).default(0), + format: z.enum(["json", "csv"]).default("json"), +}); + +const reconciliationSchema = z.object({ + triggeredBy: z.enum(["manual", "scheduled", "release"]).default("manual"), +}); + +const backupSchema = z.object({ + directory: z.string().min(1).optional(), +}); + +export interface AdminRouterOptions { + queue?: JobQueue; + reconciliation?: ReconciliationRouterOptions; +} + +function asyncHandler( + handler: (req: Request, res: Response, next: NextFunction) => Promise, +) { + return (req: Request, res: Response, next: NextFunction): void => { + void handler(req, res, next).catch(next); + }; +} + +function auditAdminRequests(req: Request, res: Response, next: NextFunction): void { + res.on("finish", () => { + recordAdminAudit({ + at: new Date().toISOString(), + actor: actorFromRequest(req), + action: `${req.method} ${req.baseUrl}${req.path}`, + target: req.params.id, + statusCode: res.statusCode, + requestId: + (res.locals.requestId as string | undefined) ?? + (res.locals.correlationId as string | undefined), + details: { + params: req.params, + query: req.query, + body: req.method === "GET" ? undefined : req.body, + }, + }); + }); + next(); +} + +function getReconciliationService(options?: ReconciliationRouterOptions): ReconciliationService { + return options?.service ?? createDefaultReconciliationService(); +} + +export function createAdminRouter(options: AdminRouterOptions = {}): Router { + const router = Router(); + const jobQueue = options.queue ?? getGlobalJobQueue(); + const reconciliationService = getReconciliationService(options.reconciliation); + + router.use(auditAdminRequests); + + router.get("/read-only", (_req: Request, res: Response) => { + res.json(getReadOnlyState()); + }); + + router.put("/read-only", (req: Request, res: Response) => { + const parsed = readOnlySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "INVALID_BODY", details: parsed.error.flatten() }); + return; + } + + const state = setReadOnlyState( + parsed.data.enabled, + actorFromRequest(req), + parsed.data.reason, + ); + res.json(state); + }); + + router.get("/agents", (req: Request, res: Response) => { + const parsed = agentListSchema.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: "INVALID_QUERY", details: parsed.error.flatten() }); + return; + } + res.json({ agents: listAgentsForAdmin(parsed.data.status) }); + }); + + router.post("/agents/:id/enable", (req: Request, res: Response) => { + const agent = setAgentEnabled(req.params.id, true); + if (!agent) { + res.status(404).json({ error: "AGENT_NOT_FOUND" }); + return; + } + res.json({ enabled: true, agent }); + }); + + router.post("/agents/:id/disable", (req: Request, res: Response) => { + const agent = setAgentEnabled(req.params.id, false); + if (!agent) { + res.status(404).json({ error: "AGENT_NOT_FOUND" }); + return; + } + res.json({ enabled: false, agent }); + }); + + router.post( + "/reconciliation/run", + asyncHandler(async (req: Request, res: Response) => { + const parsed = reconciliationSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + res.status(400).json({ error: "INVALID_BODY", details: parsed.error.flatten() }); + return; + } + + const triggeredBy = parsed.data.triggeredBy as ReconciliationTrigger; + const report = await reconciliationService.run(triggeredBy); + res.status(200).json(report); + }), + ); + + router.post("/maintenance/vacuum", (_req: Request, res: Response) => { + res.json({ results: vacuumDatabases() }); + }); + + router.post( + "/maintenance/backup", + asyncHandler(async (req: Request, res: Response) => { + const parsed = backupSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + res.status(400).json({ error: "INVALID_BODY", details: parsed.error.flatten() }); + return; + } + + const results = await backupDatabases(parsed.data.directory); + res.json({ results }); + }), + ); + + router.get("/audit-log", (req: Request, res: Response) => { + const parsed = auditLogQuerySchema.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: "INVALID_QUERY", details: parsed.error.flatten() }); + return; + } + + const entries = listAdminAuditLog(parsed.data.limit, parsed.data.offset); + if (parsed.data.format === "csv") { + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.send(auditLogToCsv(entries)); + return; + } + res.json({ entries }); + }); + + router.use("/queue", createAdminQueueRouter(jobQueue)); + router.use("/", createAdminQueueRouter(jobQueue)); + + router.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error({ err }, "admin operation failed"); + res.status(500).json({ + error: "ADMIN_OPERATION_FAILED", + message: err.message, + }); + }); + + return router; +} export function createAdminQueueRouter(queue?: JobQueue): Router { const router = Router(); diff --git a/backend/src/services/adminControl.ts b/backend/src/services/adminControl.ts new file mode 100644 index 00000000..f0e82cfa --- /dev/null +++ b/backend/src/services/adminControl.ts @@ -0,0 +1,248 @@ +import fs from "fs"; +import path from "path"; +import type { Request } from "express"; +import Database from "better-sqlite3"; +import { createAgentDb, getAgentDb, type AgentRecord } from "../db/agents"; +import { getDb as getPaymentDb } from "../db"; +import { getTaskDb } from "../db/tasks"; +import { getJobDb } from "../queue"; +import { createLogger } from "../utils/logger"; + +const logger = createLogger({ component: "admin-control" }); + +const DEFAULT_AUDIT_DB = path.join(process.cwd(), "admin_audit.db"); +const DEFAULT_BACKUP_DIR = path.join(process.cwd(), "backups", "admin"); + +export interface ReadOnlyState { + enabled: boolean; + reason?: string; + changedAt?: string; + changedBy?: string; +} + +export interface AdminAuditEntry { + id?: number; + at: string; + actor: string; + action: string; + target?: string; + statusCode: number; + requestId?: string; + details?: unknown; +} + +let readOnlyState: ReadOnlyState = { + enabled: process.env.AI_NET_READ_ONLY === "true", + reason: process.env.AI_NET_READ_ONLY_REASON, + changedAt: new Date().toISOString(), + changedBy: "boot", +}; + +let auditDb: Database.Database | null = null; + +function getAuditDb(): Database.Database { + if (!auditDb) { + const dbPath = process.env.ADMIN_AUDIT_DB_PATH ?? DEFAULT_AUDIT_DB; + auditDb = new Database(dbPath); + auditDb.pragma("busy_timeout = 5000"); + auditDb.pragma("journal_mode = WAL"); + auditDb.exec(` + CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + statusCode INTEGER NOT NULL, + requestId TEXT, + details TEXT + ); + CREATE INDEX IF NOT EXISTS idx_admin_audit_log_at + ON admin_audit_log (at); + CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action + ON admin_audit_log (action); + `); + } + return auditDb; +} + +export function getReadOnlyState(): ReadOnlyState { + return { ...readOnlyState }; +} + +export function isReadOnly(): boolean { + return readOnlyState.enabled; +} + +export function setReadOnlyState(enabled: boolean, actor: string, reason?: string): ReadOnlyState { + readOnlyState = { + enabled, + reason: reason?.trim() || undefined, + changedAt: new Date().toISOString(), + changedBy: actor, + }; + return getReadOnlyState(); +} + +export function actorFromRequest(req: Request): string { + const actorHeader = req.headers["x-admin-actor"]; + const actor = Array.isArray(actorHeader) ? actorHeader[0] : actorHeader; + if (actor?.trim()) return actor.trim(); + return req.ip || "admin"; +} + +function redact(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(redact); + + const redacted: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (/key|secret|token|authorization|password/i.test(key)) { + redacted[key] = "[redacted]"; + } else { + redacted[key] = redact(item); + } + } + return redacted; +} + +export function recordAdminAudit(entry: AdminAuditEntry): void { + try { + getAuditDb() + .prepare( + ` + INSERT INTO admin_audit_log (at, actor, action, target, statusCode, requestId, details) + VALUES (@at, @actor, @action, @target, @statusCode, @requestId, @details) + `, + ) + .run({ + at: entry.at, + actor: entry.actor, + action: entry.action, + target: entry.target ?? null, + statusCode: entry.statusCode, + requestId: entry.requestId ?? null, + details: entry.details === undefined ? null : JSON.stringify(redact(entry.details)), + }); + } catch (err) { + logger.error({ err, action: entry.action }, "failed to write admin audit entry"); + } +} + +export function listAdminAuditLog(limit = 200, offset = 0): AdminAuditEntry[] { + const rows = getAuditDb() + .prepare( + ` + SELECT id, at, actor, action, target, statusCode, requestId, details + FROM admin_audit_log + ORDER BY id DESC + LIMIT ? OFFSET ? + `, + ) + .all(Math.min(Math.max(limit, 1), 1000), Math.max(offset, 0)) as Array< + AdminAuditEntry & { details: string | null } + >; + + return rows.map((row) => ({ + ...row, + details: row.details ? JSON.parse(row.details) : undefined, + })); +} + +export function auditLogToCsv(entries: AdminAuditEntry[]): string { + const escape = (value: unknown): string => { + const text = value === undefined || value === null ? "" : String(value); + return `"${text.replace(/"/g, '""')}"`; + }; + const header = ["id", "at", "actor", "action", "target", "statusCode", "requestId", "details"]; + const rows = entries.map((entry) => + [ + entry.id, + entry.at, + entry.actor, + entry.action, + entry.target, + entry.statusCode, + entry.requestId, + entry.details === undefined ? "" : JSON.stringify(entry.details), + ] + .map(escape) + .join(","), + ); + return [header.join(","), ...rows].join("\n"); +} + +export function listAgentsForAdmin(status?: "online" | "offline"): AgentRecord[] { + const db = createAgentDb(getAgentDb()); + return db.list(status ? { status } : undefined); +} + +export function setAgentEnabled(agentId: string, enabled: boolean): AgentRecord | undefined { + const db = createAgentDb(getAgentDb()); + const agent = db.findById(agentId); + if (!agent) return undefined; + + const updated: AgentRecord = { + ...agent, + status: enabled ? "online" : "offline", + lastSeenAt: new Date().toISOString(), + }; + db.upsert(updated); + return updated; +} + +export interface MaintenanceResult { + database: string; + ok: boolean; + file?: string; + error?: string; +} + +function databases(): Array<{ name: string; db: Database.Database }> { + return [ + { name: "tasks", db: getTaskDb() }, + { name: "agents", db: getAgentDb() }, + { name: "jobs", db: getJobDb() }, + { name: "payments", db: getPaymentDb() }, + { name: "admin_audit", db: getAuditDb() }, + ]; +} + +export function vacuumDatabases(): MaintenanceResult[] { + return databases().map(({ name, db }) => { + try { + db.pragma("wal_checkpoint(TRUNCATE)"); + db.exec("VACUUM"); + return { database: name, ok: true }; + } catch (err) { + return { + database: name, + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); +} + +export async function backupDatabases(directory?: string): Promise { + const targetDir = directory ?? process.env.ADMIN_BACKUP_DIR ?? DEFAULT_BACKUP_DIR; + fs.mkdirSync(targetDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + + const results: MaintenanceResult[] = []; + for (const { name, db } of databases()) { + const file = path.join(targetDir, `${name}-${stamp}.sqlite`); + try { + await db.backup(file); + results.push({ database: name, ok: true, file }); + } catch (err) { + results.push({ + database: name, + ok: false, + file, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return results; +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..fd2ca936 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# ai-net Documentation + +Use these docs as the navigation entry point for implementation and operations work. + +| Document | Purpose | +|---|---| +| [Architecture](architecture/index.md) | System context, Mermaid diagrams, task lifecycle, payment/escrow sequence, layer responsibilities, and test map | +| [Design System](design-system.md) | Frontend semantic tokens, theme model, and component conventions | +| [REST API Reference](API_REFERENCE.md) | Public API endpoints, schemas, errors, and examples | +| [Node Operators Guide](NODE_OPERATORS_GUIDE.md) | Production/testnet node setup, secrets, deployment, monitoring, backup, and troubleshooting | +| [End-to-End Testing](e2e-testing.md) | E2E setup and validation | diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 00000000..e45a5e8e --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,188 @@ +# ai-net Architecture + +This is the authoritative technical map for ai-net. It describes how the frontend, backend coordinator, and Stellar smart contracts fit together, how task state moves through the system, and where tests should cover each layer. + +## System Context + +```mermaid +flowchart LR + User[User wallet and browser] --> Frontend[Frontend dashboard] + Frontend --> Backend[Backend API and coordinator] + Backend --> Queue[Task queue and workers] + Backend --> AgentDb[(Agent and task state)] + Queue --> Agents[Specialized agents] + Agents --> Venice[Venice AI] + Backend --> Stellar[Stellar RPC] + Stellar --> Contracts[Soroban contracts] + Contracts --> Registry[Agent registry] + Contracts --> Bidding[Agent bidding] + Contracts --> TaskStore[Task store] + Contracts --> Errors[Error registry and resolver] +``` + +ai-net has three primary layers: + +| Layer | Responsibility | Primary source | +|---|---|---| +| Frontend | Task submission, task history, wallet flows, agent browsing, status/error surfaces | `frontend/src` | +| Backend | API auth, rate limits, read-only incident controls, task orchestration, queue workers, reconciliation, audit logging | `backend/src` | +| Smart contracts | Agent identity, bidding, task records, error metadata, upgrade-controlled on-chain state | `smart-contracts/contracts` | + +## Component Model + +```mermaid +flowchart TB + subgraph UI[Frontend] + Submit[Task submission form] + Timeline[Task history and timeline] + Wallet[Wallet and payment views] + AgentsPage[Agent directory] + end + + subgraph API[Backend API] + Auth[Auth and admin auth] + ReadOnly[Global read-only gate] + Tasks[Task routes] + Admin[Admin incident routes] + Recon[Payment reconciliation] + Metrics[Health and metrics] + end + + subgraph Runtime[Coordinator runtime] + Queue[Job queue] + Worker[Job worker] + Dispatch[Agent dispatch] + Payments[Payment release] + Audit[(Admin audit log)] + end + + subgraph Chain[Soroban contracts] + AR[agent-registry] + AB[agent-bidding] + TS[task-store] + ER[error-registry] + EX[error-resolver] + end + + Submit --> Tasks + Timeline --> Tasks + Wallet --> Recon + AgentsPage --> AR + Auth --> Tasks + ReadOnly --> Tasks + Admin --> ReadOnly + Admin --> Audit + Tasks --> Queue + Queue --> Worker + Worker --> Dispatch + Worker --> Payments + Payments --> Chain + Recon --> Chain + AR --> AB + TS --> ER + ER --> EX +``` + +## Task Lifecycle + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant A as Backend API + participant Q as Job Queue + participant C as Coordinator Worker + participant R as Agent Registry + participant G as Specialized Agent + participant S as Soroban Contracts + + U->>F: Submit prompt, budget, agent preferences + F->>A: POST /api/tasks + A->>A: Validate auth, quota, prompt bounds, read-only state + A->>Q: Enqueue idempotent task job + A-->>F: Task id and DAG preview + Q->>C: Claim queued job + C->>R: Discover eligible agents + C->>G: Dispatch DAG node with context + G-->>C: Node result or typed failure + C->>S: Persist task/payment/error state + C->>Q: Mark job completed or retryable failure + F->>A: Poll or stream task status + A-->>F: Current task state and audit-safe errors +``` + +Task state is authoritative in the backend queue/task database while execution is in flight. Contract records provide the durable on-chain settlement and registry truth. Reconciliation compares backend payment intent with observed Stellar state and repairs safe mismatches through explicit operator action. + +## Payment And Escrow Flow + +```mermaid +sequenceDiagram + participant U as User wallet + participant A as Backend coordinator + participant B as Agent bidding contract + participant T as Task store contract + participant E as Escrow or payment layer + participant G as Winning agent + + U->>A: Submit task with budget + A->>B: Create bidding auction + G->>B: Submit sealed bid + G->>B: Reveal bid after deadline + A->>B: Award winning bid + A->>E: Lock escrow for awarded price + A->>T: Record task and selected agent + G->>A: Complete assigned work + A->>T: Mark node/task completed + A->>E: Release escrow payment + E-->>G: Transfer funds + A->>A: Record reconciliation/audit state +``` + +Every contract mutation must call `require_auth()` for the signer that owns the action. Singleton configuration such as admin and contract version belongs in instance storage. Entity records belong in persistent or temporary storage with TTL handling and bounded collection access. + +## Incident Controls + +Operators use `/api/admin/*` endpoints guarded by `ADMIN_API_KEY`. + +| Control | Backend endpoint | Behavior | +|---|---|---| +| Global read-only | `PUT /api/admin/read-only` | Blocks `POST`, `PUT`, `PATCH`, and `DELETE` outside exempt admin/reconciliation paths | +| Agent enablement | `GET /api/admin/agents`, `POST /api/admin/agents/:id/enable`, `POST /api/admin/agents/:id/disable` | Lists or flips agent availability without manual database edits | +| Reconciliation | `POST /api/admin/reconciliation/run` | Runs payment reconciliation using the configured service | +| Maintenance | `POST /api/admin/maintenance/vacuum`, `POST /api/admin/maintenance/backup` | Runs SQLite vacuum or backup over operational databases | +| Audit export | `GET /api/admin/audit-log?format=json|csv` | Exports admin action history | + +Every admin route is audit-logged after response completion with actor, route, status code, request id, redacted request body, and timestamp. + +## Upgrade Model + +The registry, bidding, task store, error resolver, and error registry contracts expose: + +| Function | Purpose | +|---|---| +| `admin` | Returns the configured upgrade administrator | +| `contract_version` | Returns the active semantic contract version | +| `upgrade(new_wasm_hash, new_version)` | Requires admin auth, updates current contract WASM, stores the new version, and emits an upgrade event | + +Deployment manifests in `smart-contracts/deployments/*.json.template` record contract ids, wasm hashes, versions, admins, and the verified upgrade script path. The live verification entry point is `smart-contracts/scripts/verified-upgrade-sequence.sh`; mainnet manifests require a testnet verification run before production upgrade execution. + +## Layer Responsibilities + +| Area | Frontend | Backend | Contracts | +|---|---|---|---| +| Identity | Shows wallet and agent identity | Authenticates users/admins/agents | Authorizes on-chain actors with `require_auth()` | +| Task state | Displays DAG preview, status, errors | Owns queue state and idempotent execution | Stores durable task facts where required | +| Agent selection | Captures preferences and shows registry data | Filters/ranks agents and dispatches work | Stores agent capabilities, fees, bonds, status | +| Payments | Presents balances and settlement status | Creates release intents and reconciles | Enforces escrow and payment mutations | +| Incidents | Shows graceful errors | Read-only toggle, maintenance, audit export | Admin-guarded upgrade and pause-style controls | +| Observability | Loading, empty, and error states | Metrics, logs, health, admin audit | Events for indexers and settlement review | + +## Test Map + +| Test layer | What it should prove | +|---|---| +| Smart contract unit tests | Auth is required for mutations, upgrade methods expose admin/version, storage remains bounded, task/bidding/error flows preserve invariants | +| Smart contract integration/e2e | Verified upgrade sequence runs on testnet against registry, bidding, task store, error resolver, and error registry | +| Backend unit tests | Read-only blocks mutations, admin actions audit-log, reconciliation can be triggered safely, agent enable/disable is idempotent | +| Backend integration tests | Queue/task lifecycle survives retries and emits stable API responses | +| Frontend tests | Components consume semantic tokens, render loading/error states, and preserve task/wallet workflows across themes | diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 00000000..925b9278 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,60 @@ +# ai-net Design System + +The design system is driven by `frontend/src/styles/tokens.css` and exposed to Tailwind through `frontend/tailwind.config.js`. Components should consume semantic tokens instead of raw colors, radii, shadows, or spacing values. + +## Token Groups + +| Group | Examples | Usage | +|---|---|---| +| Surface | `--surface-canvas`, `--surface-primary`, `--surface-raised`, `--surface-overlay` | Page backgrounds, panels, popovers, scrims | +| Text | `--text-primary`, `--text-secondary`, `--text-muted`, `--text-inverse` | Body copy, labels, metadata, text on filled accents | +| Border | `--border-primary`, `--border-subtle`, `--border-muted`, `--border-strong` | Input, panel, table, and divider borders | +| Accent | `--accent`, `--accent-info`, `--accent-text`, `--accent-surface`, `--accent-border` | Primary actions, focus states, selected states | +| Status | `--status-success-*`, `--status-warning-*`, `--status-danger-*` | Success, pending, warning, failed, destructive states | +| Agent | `--agent-research`, `--agent-risk`, `--agent-coding`, `--agent-design`, `--agent-report` | Agent labels, badges, timeline markers | +| Layout | `--space-*`, `--radius-*`, `--shadow-*`, `--focus-ring` | Component spacing, shape, elevation, focus treatment | + +## Theme Model + +The dark theme is defined on `:root` and `.theme-dark`; `.theme-light` overrides the same semantic variables. Components should not branch manually for light and dark themes. Prefer: + +```css +.panel { + background: var(--surface-primary); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} +``` + +Tailwind names mirror the semantic layer: + +```tsx +
+ ... +
+``` + +## Component Conventions + +Buttons use semantic accent and status tokens. Primary actions use `--gradient-primary` or `--accent`; destructive actions use `--status-danger` and `--status-danger-surface`. Disabled controls use `--text-muted` or `--text-disabled`. + +Form fields use `--surface-primary`, `--border-primary`, `--focus-ring`, and status tokens for validation. Use `--radius-lg` or `--radius-xl`; avoid one-off pixel radii. + +Cards are for repeated items, modals, and framed tools only. Keep card radius at `--radius-lg` or `--radius-xl`; page sections should be full-width bands or unframed layouts. + +Tables, lists, timelines, and dashboards should use `--surface-*`, `--border-*`, and `--text-*` tokens for dense, scan-friendly operational UI. + +Agent visuals use the `--agent-*` token set. Do not hard-code agent hex colors in components. + +Shadows and glow effects use `--shadow-*`, `--glow-*`, or `--info-glow-*`. Do not add raw `rgba()` shadows in component CSS. + +## Review Checklist + +Before merging UI changes: + +1. Check new CSS/TSX for raw `#hex`, `rgb()`, `rgba()`, one-off shadow values, and arbitrary radii. +2. Confirm any remaining raw values are user content, canvas drawing internals, or a new value that belongs in `tokens.css`. +3. Verify light and dark themes inherit from the same semantic token names. +4. Prefer Tailwind semantic names over arbitrary values when the token exists. diff --git a/frontend/src/components/agents/AgentDetailModal.module.css b/frontend/src/components/agents/AgentDetailModal.module.css index b54e872b..26f9ae5d 100644 --- a/frontend/src/components/agents/AgentDetailModal.module.css +++ b/frontend/src/components/agents/AgentDetailModal.module.css @@ -1,7 +1,7 @@ .overlay { position: fixed; inset: 0; - background: rgba(2, 6, 23, 0.7); + background: var(--surface-backdrop); backdrop-filter: blur(4px); display: flex; align-items: center; @@ -15,10 +15,10 @@ max-width: 560px; max-height: 85vh; overflow-y: auto; - background: #0f172a; + background: var(--surface-canvas); border: 1px solid var(--panel-border); - border-radius: 16px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + border-radius: var(--radius-2xl); + box-shadow: 0 24px 60px var(--surface-black-muted); padding: 24px; } @@ -50,15 +50,15 @@ width: 34px; height: 34px; flex-shrink: 0; - background: rgba(255, 255, 255, 0.05); + background: var(--white-alpha-05); border: 1px solid var(--panel-border); color: var(--text-secondary); - border-radius: 8px; + border-radius: var(--radius-lg); box-shadow: none; } .closeButton:hover { - background: rgba(255, 255, 255, 0.1); + background: var(--white-alpha-10); color: var(--text-primary); transform: none; box-shadow: none; @@ -108,22 +108,22 @@ .status { display: inline-block; padding: 3px 10px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.75rem; font-weight: 600; text-transform: capitalize; } .statusActive { - background: rgba(16, 185, 129, 0.15); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #6ee7b7; + background: var(--status-success-surface); + border: 1px solid var(--status-success-border); + color: var(--status-success-text-strong); } .statusInactive { - background: rgba(239, 68, 68, 0.12); - border: 1px solid rgba(239, 68, 68, 0.3); - color: #fca5a5; + background: var(--status-danger-surface); + border: 1px solid var(--status-danger-surface-strong); + color: var(--status-danger-text); } .pills { @@ -133,11 +133,11 @@ } .pill { - background: rgba(99, 102, 241, 0.15); - border: 1px solid rgba(99, 102, 241, 0.3); - color: #a5b4fc; + background: var(--accent-surface); + border: 1px solid var(--accent-border); + color: var(--accent-text-soft); padding: 4px 12px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.75rem; font-weight: 600; text-transform: capitalize; @@ -147,12 +147,12 @@ display: inline-flex; align-items: center; gap: 8px; - color: #818cf8; + color: var(--accent-text-muted); text-decoration: none; } .txLink:hover { - color: #a5b4fc; + color: var(--accent-text-soft); text-decoration: underline; } diff --git a/frontend/src/components/agents/AgentFilterBar.module.css b/frontend/src/components/agents/AgentFilterBar.module.css index b30aaf5d..b28cbdfd 100644 --- a/frontend/src/components/agents/AgentFilterBar.module.css +++ b/frontend/src/components/agents/AgentFilterBar.module.css @@ -5,9 +5,9 @@ gap: 24px; padding: 16px; margin-bottom: 16px; - background: rgba(15, 23, 42, 0.4); + background: var(--surface-glass-subtle); border: 1px solid var(--panel-border); - border-radius: 12px; + border-radius: var(--radius-xl); } .group { @@ -44,11 +44,11 @@ } .capChip { - background: rgba(255, 255, 255, 0.04); + background: var(--white-alpha-04); border: 1px solid var(--panel-border); color: var(--text-secondary); padding: 5px 12px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.78rem; font-weight: 600; box-shadow: none; @@ -56,7 +56,7 @@ } .capChip:hover { - background: rgba(99, 102, 241, 0.12); + background: var(--accent-surface-muted); color: var(--text-primary); transform: none; box-shadow: none; @@ -64,9 +64,9 @@ .capChipActive, .capChipActive:hover { - background: rgba(99, 102, 241, 0.25); + background: var(--accent-surface-strong); border-color: var(--primary); - color: #c7d2fe; + color: var(--accent-text); } /* Price slider */ @@ -85,7 +85,7 @@ .toggle { display: inline-flex; border: 1px solid var(--panel-border); - border-radius: 8px; + border-radius: var(--radius-lg); overflow: hidden; } @@ -101,7 +101,7 @@ } .toggleButton:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--white-alpha-05); color: var(--text-primary); transform: none; box-shadow: none; @@ -110,7 +110,7 @@ .toggleButtonActive, .toggleButtonActive:hover { background: var(--primary); - color: #fff; + color: var(--text-inverse); } /* Actions */ @@ -127,15 +127,15 @@ justify-content: center; width: 34px; height: 34px; - background: rgba(255, 255, 255, 0.04); + background: var(--white-alpha-04); border: 1px solid var(--panel-border); color: var(--text-secondary); - border-radius: 8px; + border-radius: var(--radius-lg); box-shadow: none; } .iconAction:hover { - background: rgba(99, 102, 241, 0.15); + background: var(--accent-surface); color: var(--text-primary); transform: none; box-shadow: none; @@ -149,16 +149,16 @@ border: 1px solid var(--panel-border); color: var(--text-secondary); padding: 7px 12px; - border-radius: 8px; + border-radius: var(--radius-lg); font-size: 0.8rem; font-weight: 600; box-shadow: none; } .resetButton:hover { - background: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.3); - color: #fca5a5; + background: var(--status-danger-surface); + border-color: var(--status-danger-surface-strong); + color: var(--status-danger-text); transform: none; box-shadow: none; } diff --git a/frontend/src/components/agents/AgentOutputRenderer.tsx b/frontend/src/components/agents/AgentOutputRenderer.tsx index 485d93fe..28b22001 100644 --- a/frontend/src/components/agents/AgentOutputRenderer.tsx +++ b/frontend/src/components/agents/AgentOutputRenderer.tsx @@ -41,9 +41,9 @@ const AgentOutputRenderer: React.FC = ({ agentType, result }) => { padding: '24px', textAlign: 'center', color: 'var(--text-secondary)', - background: 'rgba(255, 255, 255, 0.02)', + background: 'var(--white-alpha-02)', borderRadius: '8px', - border: '1px dashed rgba(255, 255, 255, 0.1)', + border: '1px dashed var(--white-alpha-10)', }} > {t('agent.output.empty')} diff --git a/frontend/src/components/agents/AgentReputationRadar.tsx b/frontend/src/components/agents/AgentReputationRadar.tsx index 00cd7882..fe2ba379 100644 --- a/frontend/src/components/agents/AgentReputationRadar.tsx +++ b/frontend/src/components/agents/AgentReputationRadar.tsx @@ -20,9 +20,9 @@ export const AgentReputationRadar: React.FC = ({ dime - + - + diff --git a/frontend/src/components/agents/AgentReputationTrend.tsx b/frontend/src/components/agents/AgentReputationTrend.tsx index a44af598..5702b4d8 100644 --- a/frontend/src/components/agents/AgentReputationTrend.tsx +++ b/frontend/src/components/agents/AgentReputationTrend.tsx @@ -25,8 +25,8 @@ export const AgentReputationTrend: React.FC = ({ hist - - + + diff --git a/frontend/src/components/agents/AgentTable.module.css b/frontend/src/components/agents/AgentTable.module.css index e2cc842a..e066d1c8 100644 --- a/frontend/src/components/agents/AgentTable.module.css +++ b/frontend/src/components/agents/AgentTable.module.css @@ -51,7 +51,7 @@ } .row:hover td { - background: rgba(255, 255, 255, 0.03); + background: var(--surface-hover-subtle); } .row:focus-visible { @@ -77,11 +77,11 @@ } .pill { - background: rgba(99, 102, 241, 0.15); - border: 1px solid rgba(99, 102, 241, 0.3); - color: #a5b4fc; + background: var(--accent-surface); + border: 1px solid var(--accent-border); + color: var(--accent-text-soft); padding: 3px 10px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.72rem; font-weight: 600; white-space: nowrap; @@ -106,7 +106,7 @@ } .starBg { - color: #475569; + color: var(--border-strong); } .starFill { @@ -119,8 +119,8 @@ } .starFg { - color: #fbbf24; - fill: #fbbf24; + color: var(--status-warning); + fill: var(--status-warning); } .starValue { @@ -134,22 +134,22 @@ .status { display: inline-block; padding: 3px 10px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.75rem; font-weight: 600; text-transform: capitalize; } .statusActive { - background: rgba(16, 185, 129, 0.15); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #6ee7b7; + background: var(--status-success-surface); + border: 1px solid var(--status-success-border); + color: var(--status-success-text-strong); } .statusInactive { - background: rgba(239, 68, 68, 0.12); - border: 1px solid rgba(239, 68, 68, 0.3); - color: #fca5a5; + background: var(--status-danger-surface); + border: 1px solid var(--status-danger-surface-strong); + color: var(--status-danger-text); } .detailsButton { @@ -157,15 +157,15 @@ border: 1px solid var(--panel-border); color: var(--text-primary); padding: 5px 12px; - border-radius: 6px; + border-radius: var(--radius-md); font-size: 0.8rem; font-weight: 600; box-shadow: none; } .detailsButton:hover { - background: rgba(99, 102, 241, 0.15); - border-color: rgba(99, 102, 241, 0.4); + background: var(--accent-surface); + border-color: var(--accent-border-strong); transform: none; box-shadow: none; } @@ -179,8 +179,8 @@ display: block; height: 14px; width: 80%; - border-radius: 6px; - background: rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + background: var(--white-alpha-08); animation: agentPulse 1.5s ease-in-out infinite; } diff --git a/frontend/src/components/agents/CodingRenderer.tsx b/frontend/src/components/agents/CodingRenderer.tsx index e45d69e4..456d18ab 100644 --- a/frontend/src/components/agents/CodingRenderer.tsx +++ b/frontend/src/components/agents/CodingRenderer.tsx @@ -23,9 +23,9 @@ const CodingRenderer: React.FC = ({ result }) => { padding: '24px', textAlign: 'center', color: 'var(--text-secondary)', - background: 'rgba(255, 255, 255, 0.02)', + background: 'var(--white-alpha-02)', borderRadius: '8px', - border: '1px dashed rgba(255, 255, 255, 0.1)', + border: '1px dashed var(--white-alpha-10)', }} > {t('agent.coding.empty')} @@ -51,8 +51,8 @@ const CodingRenderer: React.FC = ({ result }) => { position: 'relative', borderRadius: '8px', overflow: 'hidden', - border: '1px solid rgba(255, 255, 255, 0.1)', - backgroundColor: '#1e1e1e', + border: '1px solid var(--white-alpha-10)', + backgroundColor: 'var(--surface-black)', }} > @@ -313,14 +313,14 @@ const TaskDetailPage: React.FC = () => { {/* DAG Graph Panel */}
- +

{t('page.task.dagTitle')}

- {t('page.task.dagHint')} + {t('page.task.dagHint')}
{flowNodes.length > 0 ? ( @@ -340,11 +340,11 @@ const TaskDetailPage: React.FC = () => { preventScrolling={true} attributionPosition="bottom-left" > - + ) : ( -
+
{t('page.task.dagEmpty')}
)} diff --git a/frontend/src/pages/WalletPage.module.css b/frontend/src/pages/WalletPage.module.css index ff905d52..67202980 100644 --- a/frontend/src/pages/WalletPage.module.css +++ b/frontend/src/pages/WalletPage.module.css @@ -28,7 +28,7 @@ .connectCard { background: var(--bg-primary); border: 1px solid var(--border-color); - border-radius: 12px; + border-radius: var(--radius-xl); padding: 32px; max-width: 440px; } @@ -45,7 +45,7 @@ width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); - border-radius: 8px; + border-radius: var(--radius-lg); font-size: 0.875rem; font-family: monospace; background: var(--bg-secondary); @@ -55,18 +55,18 @@ .secretInput:focus { outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + border-color: var(--accent-secondary); + box-shadow: 0 0 0 3px var(--accent-surface); } .connectButton { width: 100%; margin-top: 16px; padding: 10px 16px; - background: #6366f1; - color: #ffffff; + background: var(--accent-secondary); + color: var(--text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-lg); font-size: 0.875rem; font-weight: 500; cursor: pointer; @@ -74,7 +74,7 @@ } .connectButton:hover:not(:disabled) { - background: #4f46e5; + background: var(--accent-secondary-hover); } .connectButton:disabled { @@ -86,10 +86,10 @@ .freighterButton { width: 100%; padding: 10px 16px; - background: #0f172a; - color: #ffffff; + background: var(--surface-canvas); + color: var(--text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-lg); font-size: 0.875rem; font-weight: 500; cursor: pointer; @@ -97,7 +97,7 @@ } .freighterButton:hover:not(:disabled) { - background: #1e293b; + background: var(--surface-elevated); } .freighterButton:disabled { @@ -110,9 +110,9 @@ display: inline-block; margin-bottom: 12px; padding: 3px 8px; - background: #ecfdf5; - color: #059669; - border-radius: 4px; + background: var(--status-success-surface); + color: var(--status-success-strong); + border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; @@ -147,7 +147,7 @@ } .link { - color: #6366f1; + color: var(--accent-secondary); text-decoration: none; font-weight: 500; } @@ -159,10 +159,10 @@ /* Security warning */ .securityWarning { font-size: 0.75rem; - color: #f59e0b; - background: #fffbeb; - border: 1px solid #fde68a; - border-radius: 6px; + color: var(--status-warning); + background: var(--status-warning-surface); + border: 1px solid var(--status-warning-text); + border-radius: var(--radius-md); padding: 8px 10px; margin: 8px 0 0; line-height: 1.4; @@ -173,8 +173,8 @@ display: inline-block; margin-top: 6px; padding: 2px 8px; - background: rgba(255, 255, 255, 0.2); - border-radius: 4px; + background: var(--white-alpha-20); + border-radius: var(--radius-sm); font-size: 0.6875rem; font-weight: 500; text-transform: uppercase; @@ -183,10 +183,10 @@ /* Balance card */ .balanceCard { - background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); - border-radius: 16px; + background: linear-gradient(135deg, var(--accent-secondary) 0%, var(--accent) 100%); + border-radius: var(--radius-2xl); padding: 28px; - color: #ffffff; + color: var(--text-inverse); margin-bottom: 32px; } @@ -218,8 +218,8 @@ .balanceSkeleton { height: 40px; width: 200px; - background: rgba(255, 255, 255, 0.2); - border-radius: 8px; + background: var(--white-alpha-20); + border-radius: var(--radius-lg); animation: pulse 1.5s infinite; } @@ -238,13 +238,13 @@ align-items: center; gap: 20px; padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.2); + border-top: 1px solid var(--white-alpha-20); } .qrCode { background: var(--bg-primary); padding: 8px; - border-radius: 8px; + border-radius: var(--radius-lg); flex-shrink: 0; line-height: 0; } @@ -272,9 +272,9 @@ .address { font-size: 0.875rem; font-family: monospace; - background: rgba(255, 255, 255, 0.15); + background: var(--white-alpha-15); padding: 4px 8px; - border-radius: 6px; + border-radius: var(--radius-md); word-break: break-all; } @@ -284,38 +284,38 @@ justify-content: center; width: 32px; height: 32px; - background: rgba(255, 255, 255, 0.15); + background: var(--white-alpha-15); border: none; - border-radius: 6px; - color: #ffffff; + border-radius: var(--radius-md); + color: var(--text-inverse); cursor: pointer; transition: background 0.15s; text-decoration: none; } .iconButton:hover { - background: rgba(255, 255, 255, 0.25); + background: var(--white-alpha-25); } .actions { margin-top: 16px; padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.2); + border-top: 1px solid var(--white-alpha-20); } .disconnectButton { padding: 6px 14px; - background: rgba(255, 255, 255, 0.15); - color: #ffffff; - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 6px; + background: var(--white-alpha-15); + color: var(--text-inverse); + border: 1px solid var(--white-alpha-30); + border-radius: var(--radius-md); font-size: 0.8125rem; cursor: pointer; transition: background 0.15s; } .disconnectButton:hover { - background: rgba(255, 255, 255, 0.25); + background: var(--white-alpha-25); } /* Content grid */ @@ -329,7 +329,7 @@ .balanceCardSkeleton { background: var(--bg-surface); border: 1px solid var(--border-color); - border-radius: 16px; + border-radius: var(--radius-2xl); padding: 28px; margin-bottom: 32px; } @@ -395,7 +395,7 @@ .reconnectCard { background: var(--bg-primary); border: 1px solid var(--border-color); - border-radius: 12px; + border-radius: var(--radius-xl); padding: 32px; max-width: 440px; } @@ -418,7 +418,7 @@ font-family: monospace; background: var(--bg-secondary); padding: 2px 6px; - border-radius: 4px; + border-radius: var(--radius-sm); font-size: 0.75rem; } diff --git a/frontend/src/pages/dashboard.module.css b/frontend/src/pages/dashboard.module.css index 0eae96cd..d6db1512 100644 --- a/frontend/src/pages/dashboard.module.css +++ b/frontend/src/pages/dashboard.module.css @@ -9,7 +9,7 @@ flex-direction: column; gap: 0.5rem; min-height: 80px; - border-radius: 8px; + border-radius: var(--radius-lg); } .health { @@ -27,6 +27,6 @@ .heading { font-size: 1.125rem; font-weight: 600; - color: #0f172a; + color: var(--surface-canvas); margin: 0; } diff --git a/frontend/src/pages/tasks/TaskHistoryPage.module.css b/frontend/src/pages/tasks/TaskHistoryPage.module.css index 20711da8..9a1da34d 100644 --- a/frontend/src/pages/tasks/TaskHistoryPage.module.css +++ b/frontend/src/pages/tasks/TaskHistoryPage.module.css @@ -45,21 +45,21 @@ display: flex; align-items: center; gap: 8px; - background: rgba(139, 92, 246, 0.1); - border: 1px solid rgba(139, 92, 246, 0.3); - border-radius: 10px; + background: var(--accent-surface-muted); + border: 1px solid var(--accent-border); + border-radius: var(--radius-xl); padding: 8px 14px; font-size: 0.8rem; font-weight: 600; - color: #c4b5fd; + color: var(--accent-text); } .compareBtn { background: var(--primary); border: none; - color: #fff; + color: var(--text-inverse); padding: 5px 14px; - border-radius: 7px; + border-radius: var(--radius-lg); font-size: 0.78rem; font-weight: 700; box-shadow: none; @@ -77,10 +77,10 @@ align-items: center; gap: 4px; background: transparent; - border: 1px solid rgba(139, 92, 246, 0.35); - color: #a78bfa; + border: 1px solid var(--accent-border-strong); + color: var(--accent-text-strong); padding: 4px 10px; - border-radius: 7px; + border-radius: var(--radius-lg); font-size: 0.75rem; font-weight: 600; box-shadow: none; @@ -89,7 +89,7 @@ } .clearBtn:hover { - background: rgba(139, 92, 246, 0.12); + background: var(--accent-surface-muted); transform: none; box-shadow: none; } @@ -103,10 +103,10 @@ gap: 16px; flex-wrap: wrap; padding: 14px 18px; - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 10px; - color: #fca5a5; + background: var(--status-danger-surface-muted); + border: 1px solid var(--status-danger-surface-strong); + border-radius: var(--radius-xl); + color: var(--status-danger-text); font-size: 0.875rem; margin-bottom: 16px; } @@ -116,11 +116,11 @@ } .retryButton { - background: rgba(239, 68, 68, 0.15); - border: 1px solid rgba(239, 68, 68, 0.4); - color: #fecaca; + background: var(--status-danger-surface); + border: 1px solid var(--status-danger-border-strong); + color: var(--status-danger-text); padding: 6px 14px; - border-radius: 7px; + border-radius: var(--radius-lg); font-size: 0.78rem; font-weight: 600; box-shadow: none; @@ -128,7 +128,7 @@ } .retryButton:hover { - background: rgba(239, 68, 68, 0.25); + background: var(--status-danger-surface-emphasis); transform: none; box-shadow: none; } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3192edcf..ce83e546 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1,34 +1,10 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - +@import './tokens.css'; @import './animations.css'; @import './micro-interactions.css'; -:root { - --font-sans: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - --bg-primary: #0A0E14; - --bg-surface: #11151D; - --bg-surface-alt: #161B24; - --bg-secondary: #1A1F2E; - --border-color: #2A3040; - --border-subtle: #1F2630; - --text-primary: #F5F7FA; - --text-secondary: #8A93A3; - --accent-cyan: #38BDF8; - --accent-purple: #8B5CF6; - --accent-green: #34D399; - --gradient-primary: linear-gradient(90deg, #38BDF8 0%, #8B5CF6 100%); - --panel-bg: var(--bg-surface); - --panel-border: var(--border-subtle); - --primary: var(--accent-purple); - --primary-hover: #7c3aed; - --success: var(--accent-green); - --danger: #ef4444; - --accent: var(--accent-cyan); - --skeleton-base: #1a1f2e; - --skeleton-shine: #2a3040; -} +@tailwind base; +@tailwind components; +@tailwind utilities; * { box-sizing: border-box; @@ -75,17 +51,17 @@ html, body { backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--panel-border); - border-radius: 16px; - box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-md); padding: 24px; } .chip { - background: rgba(99, 102, 241, 0.15); - border: 1px solid rgba(99, 102, 241, 0.3); - color: #a5b4fc; + background: color-mix(in srgb, var(--accent-secondary) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-secondary) 30%, transparent); + color: var(--accent-info); padding: 6px 12px; - border-radius: 9999px; + border-radius: var(--radius-pill); font-size: 0.85rem; font-weight: 600; } @@ -109,7 +85,7 @@ th { } tr:hover td { - background: rgba(255, 255, 255, 0.02); + background: var(--surface-hover-subtle); } .form-group { @@ -127,10 +103,10 @@ label { input[type="text"], input[type="number"], textarea { font-family: var(--font-sans); - background: rgba(15, 23, 42, 0.6); + background: var(--surface-glass); border: 1px solid var(--panel-border); color: var(--text-primary); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 12px; font-size: 1rem; transition: border-color 0.2s ease, box-shadow 0.2s ease; @@ -139,7 +115,7 @@ input[type="text"], input[type="number"], textarea { input:focus, textarea:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); + box-shadow: var(--focus-ring-info); } .error-msg { @@ -154,16 +130,16 @@ input:focus, textarea:focus { justify-content: center; align-items: center; padding: 40px; - border-radius: 12px; - background: rgba(15, 23, 42, 0.4); + border-radius: var(--radius-xl); + background: var(--surface-glass-subtle); border: 1px dashed var(--panel-border); margin-top: 24px; } .dag-node { - background: #334155; - border: 2px solid #475569; - border-radius: 12px; + background: var(--surface-muted); + border: 2px solid var(--border-strong); + border-radius: var(--radius-xl); padding: 16px 24px; min-width: 140px; text-align: center; @@ -173,10 +149,10 @@ input:focus, textarea:focus { } .dag-node.completed { - background: #064e3b; + background: var(--status-success-surface); border-color: var(--success); - color: #a7f3d0; - box-shadow: 0 0 15px rgba(16, 185, 129, 0.4); + color: var(--status-success-text); + box-shadow: var(--glow-success); } .dag-arrow { @@ -191,7 +167,7 @@ input:focus, textarea:focus { .glass-panel { padding: 16px; - border-radius: 12px; + border-radius: var(--radius-xl); } .dag-container { @@ -213,9 +189,9 @@ input:focus, textarea:focus { z-index: 10000; padding: 8px 16px; background: var(--primary); - color: #ffffff; + color: var(--text-inverse); font-weight: 600; - border-radius: 0 0 8px 8px; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); text-decoration: none; transition: top 0.2s ease; } @@ -239,7 +215,7 @@ select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; border-color: var(--primary); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); + box-shadow: var(--focus-ring-info); } /* ─── Screen reader only utility ──────────────────────────────────────────── */ @@ -267,23 +243,3 @@ td, transition: background-color 300ms ease, color 300ms ease, border-color 300ms ease, box-shadow 300ms ease; } -/* Light theme overrides */ -.theme-light { - --bg-primary: #FFFFFF; - --bg-surface: #F8FAFC; - --bg-surface-alt: #F1F5F9; - --bg-secondary: #F3F4F6; - --border-color: #E6E9EE; - --border-subtle: #E9EEF4; - --text-primary: #0A0E14; - --text-secondary: #475569; - --accent-cyan: #0EA5E9; - --accent-purple: #7C3AED; - --accent-green: #059669; - --panel-bg: var(--bg-surface); - --panel-border: var(--border-color); - --primary: var(--accent-purple); - --primary-hover: #6d28d9; - --accent: var(--accent-cyan); -} - diff --git a/frontend/src/styles/micro-interactions.css b/frontend/src/styles/micro-interactions.css index 14a7aa7a..4a87bf7c 100644 --- a/frontend/src/styles/micro-interactions.css +++ b/frontend/src/styles/micro-interactions.css @@ -6,7 +6,7 @@ .hover-lift:hover { transform: translateY(-4px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-md); } .hover-glow { diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 00000000..b4caaa8a --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,255 @@ +:root, +.theme-dark { + color-scheme: dark; + + --font-sans: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + + --surface-canvas: #0a0e14; + --surface-primary: #11151d; + --surface-secondary: #161b24; + --surface-raised: #1a1f2e; + --surface-elevated: #1e293b; + --surface-muted: #334155; + --surface-overlay: rgba(10, 14, 20, 0.88); + --surface-scrim: rgba(0, 0, 0, 0.65); + --surface-glass: rgba(15, 23, 42, 0.6); + --surface-glass-subtle: rgba(15, 23, 42, 0.4); + --surface-hover: rgba(255, 255, 255, 0.06); + --surface-hover-subtle: rgba(255, 255, 255, 0.03); + --surface-panel-translucent: rgba(30, 41, 59, 0.92); + --surface-panel-strong: rgba(30, 41, 59, 0.96); + --surface-backdrop: rgba(2, 6, 23, 0.7); + --surface-black: #000000; + --surface-black-subtle: rgba(0, 0, 0, 0.2); + --surface-black-muted: rgba(0, 0, 0, 0.5); + --surface-black-strong: rgba(0, 0, 0, 0.7); + + --text-primary: #f5f7fa; + --text-secondary: #8a93a3; + --text-muted: #94a3b8; + --text-subtle: rgba(148, 163, 184, 0.6); + --text-disabled: rgba(148, 163, 184, 0.35); + --text-inverse: #ffffff; + + --border-primary: #2a3040; + --border-subtle: #1f2630; + --border-muted: rgba(255, 255, 255, 0.08); + --border-strong: #475569; + + --accent: #8b5cf6; + --accent-hover: #7c3aed; + --accent-info: #38bdf8; + --accent-secondary: #6366f1; + --accent-secondary-hover: #4f46e5; + --accent-text: #c4b5fd; + --accent-text-strong: #a78bfa; + --accent-text-soft: #a5b4fc; + --accent-text-muted: #818cf8; + --accent-surface: rgba(99, 102, 241, 0.15); + --accent-surface-muted: rgba(99, 102, 241, 0.12); + --accent-surface-strong: rgba(99, 102, 241, 0.25); + --accent-border: rgba(99, 102, 241, 0.3); + --accent-border-strong: rgba(99, 102, 241, 0.4); + --info-surface: rgba(56, 189, 248, 0.15); + --info-border: rgba(56, 189, 248, 0.3); + --info-glow-subtle: 0 0 10px rgba(56, 189, 248, 0.05); + --info-glow: 0 0 12px rgba(56, 189, 248, 0.3); + --info-glow-strong: 0 0 20px rgba(56, 189, 248, 0.4); + + --status-success: #34d399; + --status-success-strong: #16a34a; + --status-success-surface: rgba(16, 185, 129, 0.15); + --status-success-border: rgba(16, 185, 129, 0.3); + --status-success-text: #a7f3d0; + --status-success-text-strong: #6ee7b7; + --status-success-surface-muted: rgba(16, 185, 129, 0.12); + --status-success-surface-strong: rgba(16, 185, 129, 0.2); + --status-success-border-strong: rgba(16, 185, 129, 0.4); + --status-warning: #fbbf24; + --status-warning-strong: #eab308; + --status-warning-surface: rgba(245, 158, 11, 0.15); + --status-warning-surface-muted: rgba(245, 158, 11, 0.12); + --status-warning-surface-strong: rgba(245, 158, 11, 0.2); + --status-warning-border: rgba(245, 158, 11, 0.3); + --status-warning-border-strong: rgba(245, 158, 11, 0.4); + --status-warning-text: #fde68a; + --status-danger: #ef4444; + --status-danger-strong: #b91c1c; + --status-danger-surface: rgba(239, 68, 68, 0.12); + --status-danger-surface-muted: rgba(239, 68, 68, 0.08); + --status-danger-surface-strong: rgba(239, 68, 68, 0.2); + --status-danger-surface-emphasis: rgba(239, 68, 68, 0.25); + --status-danger-border: rgba(239, 68, 68, 0.3); + --status-danger-border-strong: rgba(239, 68, 68, 0.4); + --status-danger-text: #fca5a5; + --agent-research: var(--accent-info); + --agent-risk: var(--status-warning); + --agent-coding: var(--accent-text-strong); + --agent-design: #f472b6; + --agent-report: var(--status-success); + --agent-default: var(--text-muted); + --agent-research-surface: rgba(56, 189, 248, 0.13); + --agent-risk-surface: rgba(245, 158, 11, 0.13); + --agent-coding-surface: rgba(167, 139, 250, 0.13); + --agent-design-surface: rgba(244, 114, 182, 0.13); + --agent-report-surface: rgba(52, 211, 153, 0.13); + --agent-default-surface: var(--surface-hover); + --agent-research-border: rgba(56, 189, 248, 0.28); + --agent-risk-border: rgba(245, 158, 11, 0.28); + --agent-coding-border: rgba(167, 139, 250, 0.28); + --agent-design-border: rgba(244, 114, 182, 0.28); + --agent-report-border: rgba(52, 211, 153, 0.28); + --agent-default-border: var(--border-muted); + --white-alpha-02: rgba(255, 255, 255, 0.02); + --white-alpha-04: rgba(255, 255, 255, 0.04); + --white-alpha-05: rgba(255, 255, 255, 0.05); + --white-alpha-06: rgba(255, 255, 255, 0.06); + --white-alpha-08: rgba(255, 255, 255, 0.08); + --white-alpha-10: rgba(255, 255, 255, 0.1); + --white-alpha-12: rgba(255, 255, 255, 0.12); + --white-alpha-15: rgba(255, 255, 255, 0.15); + --white-alpha-20: rgba(255, 255, 255, 0.2); + --white-alpha-25: rgba(255, 255, 255, 0.25); + --white-alpha-30: rgba(255, 255, 255, 0.3); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-pill: 9999px; + --radius-round: 50%; + + --shadow-sm: 0 4px 12px rgba(15, 23, 42, 0.15); + --shadow-md: 0 8px 32px rgba(15, 23, 42, 0.18); + --shadow-lg: 0 20px 50px rgba(0, 0, 0, 0.6); + --shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.5); + --shadow-popover: 0 24px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04); + --focus-ring: 0 0 0 3px rgba(99, 102, 241, 0.15); + --focus-ring-info: 0 0 0 2px rgba(59, 130, 246, 0.25); + --glow-info: 0 0 15px rgba(56, 189, 248, 0.4); + --glow-success: 0 0 15px rgba(16, 185, 129, 0.4); + --glow-danger: 0 0 15px rgba(239, 68, 68, 0.35); + + --gradient-primary: linear-gradient(90deg, var(--accent-info) 0%, var(--accent) 100%); + --gradient-accent: linear-gradient(135deg, var(--accent-secondary) 0%, var(--accent) 100%); + + --bg-primary: var(--surface-canvas); + --bg-surface: var(--surface-primary); + --bg-surface-alt: var(--surface-secondary); + --bg-secondary: var(--surface-raised); + --border-color: var(--border-primary); + --panel-bg: var(--surface-primary); + --panel-border: var(--border-subtle); + --primary: var(--accent); + --primary-hover: var(--accent-hover); + --success: var(--status-success); + --danger: var(--status-danger); + --skeleton-base: var(--surface-raised); + --skeleton-shine: var(--border-primary); +} + +.theme-light { + color-scheme: light; + + --surface-canvas: #ffffff; + --surface-primary: #f8fafc; + --surface-secondary: #f1f5f9; + --surface-raised: #f3f4f6; + --surface-elevated: #e9eef4; + --surface-muted: #e2e8f0; + --surface-overlay: rgba(255, 255, 255, 0.92); + --surface-scrim: rgba(15, 23, 42, 0.42); + --surface-glass: rgba(248, 250, 252, 0.78); + --surface-glass-subtle: rgba(248, 250, 252, 0.58); + --surface-hover: rgba(15, 23, 42, 0.06); + --surface-hover-subtle: rgba(15, 23, 42, 0.03); + --surface-panel-translucent: rgba(255, 255, 255, 0.92); + --surface-panel-strong: rgba(255, 255, 255, 0.96); + --surface-backdrop: rgba(15, 23, 42, 0.42); + --surface-black: #000000; + --surface-black-subtle: rgba(15, 23, 42, 0.08); + --surface-black-muted: rgba(15, 23, 42, 0.32); + --surface-black-strong: rgba(15, 23, 42, 0.45); + + --text-primary: #0a0e14; + --text-secondary: #475569; + --text-muted: #64748b; + --text-subtle: rgba(71, 85, 105, 0.72); + --text-disabled: rgba(71, 85, 105, 0.42); + --text-inverse: #ffffff; + + --border-primary: #e6e9ee; + --border-subtle: #e9eef4; + --border-muted: rgba(15, 23, 42, 0.1); + --border-strong: #cbd5e1; + + --accent: #7c3aed; + --accent-hover: #6d28d9; + --accent-info: #0ea5e9; + --accent-secondary: #6366f1; + --accent-secondary-hover: #4f46e5; + --accent-text: #6d28d9; + --accent-text-strong: #5b21b6; + --accent-text-soft: #4f46e5; + --accent-text-muted: #6366f1; + --accent-surface: rgba(99, 102, 241, 0.12); + --accent-surface-muted: rgba(99, 102, 241, 0.08); + --accent-surface-strong: rgba(99, 102, 241, 0.18); + --accent-border: rgba(99, 102, 241, 0.24); + --accent-border-strong: rgba(99, 102, 241, 0.34); + --info-surface: rgba(14, 165, 233, 0.12); + --info-border: rgba(14, 165, 233, 0.28); + + --status-success: #059669; + --status-success-strong: #047857; + --status-success-surface: #ecfdf5; + --status-success-border: #86efac; + --status-success-text: #166534; + --status-success-text-strong: #047857; + --status-success-surface-muted: #f0fdf4; + --status-success-surface-strong: #dcfce7; + --status-success-border-strong: #86efac; + --status-warning: #f59e0b; + --status-warning-strong: #d97706; + --status-warning-surface: #fffbeb; + --status-warning-surface-muted: #fffbeb; + --status-warning-surface-strong: #fef3c7; + --status-warning-border: #fde68a; + --status-warning-border-strong: #fcd34d; + --status-warning-text: #92400e; + --status-danger: #dc2626; + --status-danger-strong: #b91c1c; + --status-danger-surface: #fef2f2; + --status-danger-surface-muted: #fef2f2; + --status-danger-surface-strong: #fee2e2; + --status-danger-surface-emphasis: #fecaca; + --status-danger-border: #fca5a5; + --status-danger-border-strong: #ef4444; + --status-danger-text: #991b1b; + --agent-design: #db2777; + --agent-research-surface: rgba(14, 165, 233, 0.11); + --agent-risk-surface: rgba(245, 158, 11, 0.11); + --agent-coding-surface: rgba(124, 58, 237, 0.11); + --agent-design-surface: rgba(219, 39, 119, 0.1); + --agent-report-surface: rgba(5, 150, 105, 0.1); + --agent-research-border: rgba(14, 165, 233, 0.24); + --agent-risk-border: rgba(245, 158, 11, 0.24); + --agent-coding-border: rgba(124, 58, 237, 0.24); + --agent-design-border: rgba(219, 39, 119, 0.22); + --agent-report-border: rgba(5, 150, 105, 0.22); + + --shadow-sm: 0 4px 12px rgba(15, 23, 42, 0.08); + --shadow-md: 0 8px 32px rgba(15, 23, 42, 0.12); + --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.18); + --shadow-popover: 0 24px 80px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(15, 23, 42, 0.08); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 947c83db..dd5a08e9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -7,29 +7,52 @@ export default { theme: { extend: { colors: { + surface: { + canvas: 'var(--surface-canvas)', + primary: 'var(--surface-primary)', + secondary: 'var(--surface-secondary)', + raised: 'var(--surface-raised)', + elevated: 'var(--surface-elevated)', + muted: 'var(--surface-muted)', + overlay: 'var(--surface-overlay)', + glass: 'var(--surface-glass)', + hover: 'var(--surface-hover)', + }, background: { primary: 'var(--bg-primary)', surface: 'var(--bg-surface)', 'surface-alt': 'var(--bg-surface-alt)', }, border: { + DEFAULT: 'var(--border-primary)', subtle: 'var(--border-subtle)', + muted: 'var(--border-muted)', + strong: 'var(--border-strong)', }, text: { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', + muted: 'var(--text-muted)', + subtle: 'var(--text-subtle)', + disabled: 'var(--text-disabled)', + inverse: 'var(--text-inverse)', }, accent: { - cyan: 'var(--accent-cyan)', - purple: 'var(--accent-purple)', - green: 'var(--accent-green)', + DEFAULT: 'var(--accent)', + hover: 'var(--accent-hover)', + info: 'var(--accent-info)', + secondary: 'var(--accent-secondary)', + cyan: 'var(--accent-info)', + purple: 'var(--accent)', + green: 'var(--status-success)', }, primary: { DEFAULT: 'var(--primary)', hover: 'var(--primary-hover)', }, - success: 'var(--success)', - danger: 'var(--danger)', + success: 'var(--status-success)', + warning: 'var(--status-warning)', + danger: 'var(--status-danger)', panel: { bg: 'var(--panel-bg)', border: 'var(--panel-border)', @@ -40,6 +63,37 @@ export default { }, backgroundImage: { 'gradient-primary': 'var(--gradient-primary)', + 'gradient-accent': 'var(--gradient-accent)', + }, + borderRadius: { + sm: 'var(--radius-sm)', + md: 'var(--radius-md)', + lg: 'var(--radius-lg)', + xl: 'var(--radius-xl)', + '2xl': 'var(--radius-2xl)', + full: 'var(--radius-pill)', + }, + boxShadow: { + sm: 'var(--shadow-sm)', + md: 'var(--shadow-md)', + lg: 'var(--shadow-lg)', + popover: 'var(--shadow-popover)', + focus: 'var(--focus-ring)', + 'info-glow': 'var(--info-glow)', + 'info-glow-subtle': 'var(--info-glow-subtle)', + 'info-glow-strong': 'var(--info-glow-strong)', + 'success-glow': 'var(--glow-success)', + 'danger-glow': 'var(--glow-danger)', + }, + spacing: { + 1: 'var(--space-1)', + 2: 'var(--space-2)', + 3: 'var(--space-3)', + 4: 'var(--space-4)', + 5: 'var(--space-5)', + 6: 'var(--space-6)', + 8: 'var(--space-8)', + 10: 'var(--space-10)', }, }, }, diff --git a/smart-contracts/contracts/agent_bidding/src/errors.rs b/smart-contracts/contracts/agent_bidding/src/errors.rs index 6297fedc..ae1efaea 100644 --- a/smart-contracts/contracts/agent_bidding/src/errors.rs +++ b/smart-contracts/contracts/agent_bidding/src/errors.rs @@ -5,7 +5,7 @@ //! callers can branch on the numeric code without coupling to a specific SDK //! build. //! -//! The code range used here (`1..=17`) is local to this contract. Codes are +//! The code range used here (`1..=20`) is local to this contract. Codes are //! chosen to read naturally in logs while remaining stable across releases: //! **never renumber an existing variant** once the contract is deployed. @@ -50,4 +50,10 @@ pub enum Error { WinnerNotDetermined = 16, /// The escrow for this auction has already been created. EscrowAlreadyCreated = 17, + /// Contract instance has already been initialized. + AlreadyInitialized = 18, + /// Contract instance has not been initialized with an admin. + NotInitialized = 19, + /// Requested upgrade could not be applied. + UpgradeFailed = 20, } diff --git a/smart-contracts/contracts/agent_bidding/src/lib.rs b/smart-contracts/contracts/agent_bidding/src/lib.rs index 5ec4d1e0..8b7c26fa 100644 --- a/smart-contracts/contracts/agent_bidding/src/lib.rs +++ b/smart-contracts/contracts/agent_bidding/src/lib.rs @@ -72,6 +72,7 @@ use soroban_sdk::{ const TTL_THRESHOLD: u32 = 100_000; /// Target TTL after extension (~31 days at 5s ledgers). const TTL_EXTEND_TO: u32 = 535_680; +const CONTRACT_VERSION: &str = "1.0.0"; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -110,6 +111,16 @@ fn compute_commitment( env.crypto().sha256(&preimage).into() } +fn require_admin(env: &Env) -> Result { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + Ok(admin) +} + // ─── Contract ──────────────────────────────────────────────────────────────── #[contract] @@ -117,6 +128,48 @@ pub struct AgentBiddingContract; #[contractimpl] impl AgentBiddingContract { + // ── Administration / Upgradeability ─────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Version, &String::from_str(&env, CONTRACT_VERSION)); + Ok(()) + } + + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + pub fn contract_version(env: Env) -> String { + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, CONTRACT_VERSION)) + } + + pub fn upgrade( + env: Env, + new_wasm_hash: BytesN<32>, + new_version: String, + ) -> Result<(), Error> { + let admin = require_admin(&env)?; + let old_version = Self::contract_version(env.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.storage().instance().set(&DataKey::Version, &new_version); + env.events().publish( + (symbol_short!("bidding"), symbol_short!("upgraded")), + (old_version, new_version, new_wasm_hash, admin, env.ledger().sequence()), + ); + Ok(()) + } + // ── Creation ───────────────────────────────────────────────────────── /// Initialise a new auction for `task_id`. diff --git a/smart-contracts/contracts/agent_bidding/src/types.rs b/smart-contracts/contracts/agent_bidding/src/types.rs index 3cf0917f..b5942c28 100644 --- a/smart-contracts/contracts/agent_bidding/src/types.rs +++ b/smart-contracts/contracts/agent_bidding/src/types.rs @@ -160,6 +160,10 @@ pub struct Escrow { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { + /// Admin address allowed to upgrade this contract. + Admin, + /// Current semantic contract version. + Version, /// Stores the root [`Auction`] record for a task. Auction(Symbol), /// Stores a single [`SealedBid`] for a given (task, bidder) pair. diff --git a/smart-contracts/contracts/agent_registry/src/lib.rs b/smart-contracts/contracts/agent_registry/src/lib.rs index 31e80a52..64b24865 100644 --- a/smart-contracts/contracts/agent_registry/src/lib.rs +++ b/smart-contracts/contracts/agent_registry/src/lib.rs @@ -511,7 +511,12 @@ impl AgentRegistryContract { if env.storage().instance().has(&DataKey::Admin) { return Err(Error::AlreadyExists); } + admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set( + &DataKey::Agent(Symbol::new(&env, "version")), + &String::from_str(&env, "1.0.0"), + ); env.storage().instance().set(&DataKey::Paused, &false); // Emit (registry, init) so indexers know exactly when the diff --git a/smart-contracts/contracts/agent_registry/src/upgrade.rs b/smart-contracts/contracts/agent_registry/src/upgrade.rs index a92a5e4f..6e9c2f2d 100644 --- a/smart-contracts/contracts/agent_registry/src/upgrade.rs +++ b/smart-contracts/contracts/agent_registry/src/upgrade.rs @@ -6,7 +6,7 @@ //! This module provides the implementation of the Upgradeable trait for the agent registry. use soroban_sdk::{ - contractimpl, symbol_short, Address, BytesN, Env, String, Vec, + contractimpl, symbol_short, Address, BytesN, Env, String, Symbol, Vec, }; use upgrade_manager::{ @@ -35,7 +35,10 @@ pub enum UpgradeDataKey { #[contractimpl] impl Upgradeable for AgentRegistryContract { fn get_version(env: Env) -> String { - String::from_str(&env, CURRENT_VERSION) + env.storage() + .instance() + .get(&DataKey::Agent(Symbol::new(&env, "version"))) + .unwrap_or_else(|| String::from_str(&env, CURRENT_VERSION)) } fn get_wasm_hash(env: Env) -> BytesN<32> { @@ -288,6 +291,45 @@ impl AgentRegistryContract { } } + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + pub fn contract_version(env: Env) -> String { + ::get_version(env) + } + + pub fn upgrade( + env: Env, + new_wasm_hash: BytesN<32>, + new_version: String, + ) -> Result<(), Error> { + let admin = require_admin(&env)?; + let old_version = Self::contract_version(env.clone()); + + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.storage() + .instance() + .set(&DataKey::Agent(Symbol::new(&env, "version")), &new_version); + env.storage() + .instance() + .set(&DataKey::Agent(Symbol::new(&env, "last_upgrade")), &env.ledger().sequence()); + + env.events().publish( + (symbol_short!("registry"), symbol_short!("upgraded")), + crate::events::ContractUpgradedEvent { + old_version, + new_version, + wasm_hash: new_wasm_hash, + admin, + upgrade_ledger: env.ledger().sequence(), + }, + ); + + Ok(()) + } + /// Upgrade the contract with new WASM hash (admin only) pub fn upgrade_contract( env: Env, @@ -301,12 +343,12 @@ impl AgentRegistryContract { let pre_hook_results = ::pre_upgrade_hook( env.clone(), new_version.clone(), - new_wasm_hash, + new_wasm_hash.clone(), ) .map_err(|_| Error::NotAdmin)?; // Convert upgrade error to contract error // Update the contract WASM - env.deployer().update_current_contract_wasm(new_wasm_hash); + env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); // Execute post-upgrade hook let old_version = String::from_str(&env, CURRENT_VERSION); @@ -463,4 +505,4 @@ fn estimate_data_items(env: &Env) -> u32 { // Placeholder estimation based on contract usage 100 // Assuming ~100 agent records on average -} \ No newline at end of file +} diff --git a/smart-contracts/contracts/error-registry/src/lib.rs b/smart-contracts/contracts/error-registry/src/lib.rs index bfceec84..5bdb30ba 100644 --- a/smart-contracts/contracts/error-registry/src/lib.rs +++ b/smart-contracts/contracts/error-registry/src/lib.rs @@ -55,8 +55,8 @@ //! but that situation does not exist here. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, BytesN, Env, Map, Symbol, - Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Map, + String, Symbol, Vec, }; /// Maximum allowed TTL for a single record: **90 days** (in seconds). @@ -77,6 +77,7 @@ pub const MAX_CLEANUP_BATCH: u32 = 100; /// Batch size used when a caller passes `0` to [`cleanup_expired_errors`], /// giving a sensible default for the common "just clean up" call. pub const DEFAULT_CLEANUP_BATCH: u32 = 50; +pub const CONTRACT_VERSION: &str = "1.0.0"; /// A single error report stored on-chain. /// @@ -113,6 +114,10 @@ pub struct CleanupStats { /// Storage keys. All entries live in `persistent` storage. #[contracttype] pub enum DataKey { + /// Admin address allowed to upgrade this contract. + Admin, + /// Current semantic contract version. + Version, /// Primary storage: `error_id` -> [`ErrorRecord`]. Record(BytesN<32>), /// Secondary lookup index: `error_code` -> `Vec`. @@ -130,6 +135,14 @@ pub enum Error { InvalidTtl = 2, /// `created_at + ttl_seconds` would overflow `u64`. TtlOverflow = 3, + /// Contract instance has already been initialized. + AlreadyInitialized = 4, + /// Contract instance has not been initialized with an admin. + NotInitialized = 5, + /// Caller is not authorized for the requested admin action. + Unauthorized = 6, + /// Requested upgrade could not be applied. + UpgradeFailed = 7, } #[contract] @@ -137,6 +150,46 @@ pub struct ErrorRegistryContract; #[contractimpl] impl ErrorRegistryContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Version, &String::from_str(&env, CONTRACT_VERSION)); + Ok(()) + } + + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + pub fn contract_version(env: Env) -> String { + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, CONTRACT_VERSION)) + } + + pub fn upgrade( + env: Env, + new_wasm_hash: BytesN<32>, + new_version: String, + ) -> Result<(), Error> { + let admin = require_admin(&env)?; + let old_version = Self::contract_version(env.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.storage().instance().set(&DataKey::Version, &new_version); + env.events().publish( + (symbol_short!("errreg"), symbol_short!("upgraded")), + (old_version, new_version, new_wasm_hash, admin, env.ledger().sequence()), + ); + Ok(()) + } + /// Submit a new error report with an explicit TTL. /// /// * `error_id` — unique 32-byte key for the record (caller-supplied, e.g. a @@ -354,6 +407,16 @@ impl ErrorRegistryContract { } } +fn require_admin(env: &Env) -> Result { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + Ok(admin) +} + /// Reject `ttl_seconds` outside `(0, MAX_TTL_SECONDS]`. fn validate_ttl(ttl_seconds: u64) -> Result<(), Error> { if ttl_seconds == 0 || ttl_seconds > MAX_TTL_SECONDS { diff --git a/smart-contracts/contracts/error-resolver/src/agent_errors.rs b/smart-contracts/contracts/error-resolver/src/agent_errors.rs index 7978c6b1..0f45a873 100644 --- a/smart-contracts/contracts/error-resolver/src/agent_errors.rs +++ b/smart-contracts/contracts/error-resolver/src/agent_errors.rs @@ -1,7 +1,10 @@ use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, + Symbol, Vec, }; +const CONTRACT_VERSION: &str = "1.0.0"; + /// On-chain per-agent error ledger. Distinct from the off-chain /// `ErrorResolver` lookup table (see `lookup.rs`): this contract tracks how /// many errors have been reported for a given agent, so `agent-registry` can @@ -9,6 +12,7 @@ use soroban_sdk::{ #[contracttype] pub enum DataKey { Admin, + Version, AuthorizedCallers, AgentErrorCount(Symbol), } @@ -19,6 +23,7 @@ pub enum ContractError { AlreadyInitialized = 1, NotInitialized = 2, Unauthorized = 3, + UpgradeFailed = 4, } #[contract] @@ -62,7 +67,11 @@ impl ErrorResolverContract { if env.storage().instance().has(&DataKey::Admin) { return Err(ContractError::AlreadyInitialized); } + admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Version, &String::from_str(&env, CONTRACT_VERSION)); env.storage() .instance() .set(&DataKey::AuthorizedCallers, &Vec::
::new(&env)); @@ -73,6 +82,34 @@ impl ErrorResolverContract { env.storage().instance().get(&DataKey::Admin) } + pub fn admin(env: Env) -> Option
{ + Self::get_admin(env) + } + + pub fn contract_version(env: Env) -> String { + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, CONTRACT_VERSION)) + } + + pub fn upgrade( + env: Env, + new_wasm_hash: BytesN<32>, + new_version: String, + ) -> Result<(), ContractError> { + let admin = require_admin(&env)?; + let old_version = Self::contract_version(env.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.storage().instance().set(&DataKey::Version, &new_version); + env.events().publish( + (symbol_short!("errres"), symbol_short!("upgraded")), + (old_version, new_version, new_wasm_hash, admin, env.ledger().sequence()), + ); + Ok(()) + } + /// Allowlists a contract address (e.g. agent-registry) to call /// `record_error` and `clear_agent_errors`. Admin only. pub fn add_authorized_caller(env: Env, caller: Address) -> Result<(), ContractError> { diff --git a/smart-contracts/contracts/task_store/src/lib.rs b/smart-contracts/contracts/task_store/src/lib.rs index 520e6156..d3c9a83e 100644 --- a/smart-contracts/contracts/task_store/src/lib.rs +++ b/smart-contracts/contracts/task_store/src/lib.rs @@ -7,9 +7,10 @@ pub use types::{ DEFAULT_TTL_DAYS, LEDGERS_PER_DAY, MAX_COMPRESSED_DAG_BYTES, MAX_TTL_DAYS, }; -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, BytesN, Env, Vec}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, BytesN, Env, String, Vec}; const SECONDS_PER_DAY: u64 = 86_400; +const CONTRACT_VERSION: &str = "1.0.0"; fn ttl_ledgers(ttl_days: u32) -> u32 { ttl_days.saturating_mul(LEDGERS_PER_DAY) @@ -55,11 +56,61 @@ fn can_transition(from: TaskStatus, to: TaskStatus) -> bool { ) } +fn require_admin(env: &Env) -> Result { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + Ok(admin) +} + #[contract] pub struct TaskStoreContract; #[contractimpl] impl TaskStoreContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Version, &String::from_str(&env, CONTRACT_VERSION)); + Ok(()) + } + + pub fn admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + pub fn contract_version(env: Env) -> String { + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, CONTRACT_VERSION)) + } + + pub fn upgrade( + env: Env, + new_wasm_hash: BytesN<32>, + new_version: String, + ) -> Result<(), Error> { + let admin = require_admin(&env)?; + let old_version = Self::contract_version(env.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.storage().instance().set(&DataKey::Version, &new_version); + env.events().publish( + (symbol_short!("task_str"), symbol_short!("upgraded")), + (old_version, new_version, new_wasm_hash, admin, env.ledger().sequence()), + ); + Ok(()) + } + pub fn store_task_metadata( env: Env, submitter: Address, diff --git a/smart-contracts/contracts/task_store/src/types.rs b/smart-contracts/contracts/task_store/src/types.rs index 813161e6..992d1668 100644 --- a/smart-contracts/contracts/task_store/src/types.rs +++ b/smart-contracts/contracts/task_store/src/types.rs @@ -30,6 +30,8 @@ pub struct TaskMetadata { #[contracttype] #[derive(Clone)] pub enum DataKey { + Admin, + Version, Task(BytesN<32>), } @@ -63,4 +65,8 @@ pub enum Error { NotAssignedAgent = 7, InvalidStatusTransition = 8, Expired = 9, + AlreadyInitialized = 10, + NotInitialized = 11, + Unauthorized = 12, + UpgradeFailed = 13, } diff --git a/smart-contracts/deployments/futurenet.json.template b/smart-contracts/deployments/futurenet.json.template index 25810e24..21621b0a 100644 --- a/smart-contracts/deployments/futurenet.json.template +++ b/smart-contracts/deployments/futurenet.json.template @@ -3,6 +3,60 @@ "rpc_url": "https://rpc-futurenet.stellar.org", "horizon_url": "https://horizon-futurenet.stellar.org", "deployed_at": null, - "contracts": {}, + "contracts": { + "agent-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "agent-bidding": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "task-store": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-resolver": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + } + }, + "upgrade_verification": { + "script": "scripts/verified-upgrade-sequence.sh", + "network": "futurenet", + "verification_network": "futurenet", + "requires_live_testnet": false, + "contracts": [ + "agent-registry", + "agent-bidding", + "task-store", + "error-resolver", + "error-registry" + ] + }, "deployment_history": [] } diff --git a/smart-contracts/deployments/mainnet.json.template b/smart-contracts/deployments/mainnet.json.template index cb048831..2d9a6650 100644 --- a/smart-contracts/deployments/mainnet.json.template +++ b/smart-contracts/deployments/mainnet.json.template @@ -3,6 +3,60 @@ "rpc_url": "https://soroban-rpc.stellar.org", "horizon_url": "https://horizon.stellar.org", "deployed_at": null, - "contracts": {}, + "contracts": { + "agent-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "agent-bidding": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "task-store": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-resolver": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + } + }, + "upgrade_verification": { + "script": "scripts/verified-upgrade-sequence.sh", + "network": "mainnet", + "verification_network": "testnet", + "requires_live_testnet": true, + "contracts": [ + "agent-registry", + "agent-bidding", + "task-store", + "error-resolver", + "error-registry" + ] + }, "deployment_history": [] } diff --git a/smart-contracts/deployments/testnet.json.template b/smart-contracts/deployments/testnet.json.template index b99a5fdc..cff2b4d2 100644 --- a/smart-contracts/deployments/testnet.json.template +++ b/smart-contracts/deployments/testnet.json.template @@ -3,6 +3,60 @@ "rpc_url": "https://soroban-testnet.stellar.org", "horizon_url": "https://horizon-testnet.stellar.org", "deployed_at": null, - "contracts": {}, + "contracts": { + "agent-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "agent-bidding": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "task-store": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-resolver": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + }, + "error-registry": { + "contract_id": null, + "wasm_hash": null, + "version": "1.0.0", + "admin": null, + "upgrade_function": "upgrade", + "version_function": "contract_version" + } + }, + "upgrade_verification": { + "script": "scripts/verified-upgrade-sequence.sh", + "network": "testnet", + "verification_network": "testnet", + "requires_live_testnet": true, + "contracts": [ + "agent-registry", + "agent-bidding", + "task-store", + "error-resolver", + "error-registry" + ] + }, "deployment_history": [] } diff --git a/smart-contracts/scripts/verified-upgrade-sequence.sh b/smart-contracts/scripts/verified-upgrade-sequence.sh new file mode 100755 index 00000000..f7b61d48 --- /dev/null +++ b/smart-contracts/scripts/verified-upgrade-sequence.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NETWORK="${NETWORK:-testnet}" +NEW_VERSION="${NEW_VERSION:-1.0.1-upgrade-check}" +SKIP_BUILD="${SKIP_BUILD:-false}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +MANIFEST="${MANIFEST:-$PROJECT_ROOT/deployments/${NETWORK}.json}" +TARGET_DIR="$PROJECT_ROOT/target/wasm32-unknown-unknown/release" + +CONTRACTS=( + "agent-registry:agent_registry" + "agent-bidding:agent_bidding" + "task-store:task_store" + "error-resolver:error_resolver" + "error-registry:error_registry" +) + +usage() { + cat <.json. + NEW_VERSION Version string written by contract_version after upgrade. + SKIP_BUILD true to reuse existing release WASM artifacts. +EOF +} + +network_passphrase() { + case "$NETWORK" in + testnet) echo "Test SDF Network ; September 2015" ;; + futurenet) echo "Test SDF Future Network ; October 2022" ;; + mainnet) echo "Public Global Stellar Network ; September 2015" ;; + *) echo "Unsupported NETWORK=$NETWORK" >&2; exit 1 ;; + esac +} + +rpc_url() { + if [[ -n "${STELLAR_RPC_URL:-}" ]]; then + echo "$STELLAR_RPC_URL" + return + fi + + case "$NETWORK" in + testnet) echo "https://soroban-testnet.stellar.org" ;; + futurenet) echo "https://rpc-futurenet.stellar.org" ;; + mainnet) echo "https://mainnet.sorobanrpc.com" ;; + *) echo "Unsupported NETWORK=$NETWORK" >&2; exit 1 ;; + esac +} + +require_tools() { + command -v soroban >/dev/null || { echo "soroban CLI is required" >&2; exit 1; } + command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; } + [[ -n "${STELLAR_SECRET_KEY:-}" ]] || { echo "STELLAR_SECRET_KEY is required" >&2; exit 1; } + [[ -f "$MANIFEST" ]] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; } +} + +build_contracts() { + if [[ "$SKIP_BUILD" == "true" ]]; then + echo "Skipping contract build" + return + fi + cargo build --manifest-path "$PROJECT_ROOT/Cargo.toml" --target wasm32-unknown-unknown --release +} + +contract_id() { + local name="$1" + jq -r --arg name "$name" '.contracts[$name].contract_id // empty' "$MANIFEST" +} + +invoke() { + local contract="$1" + shift + soroban contract invoke \ + --id "$contract" \ + --source "$STELLAR_SECRET_KEY" \ + --rpc-url "$(rpc_url)" \ + --network-passphrase "$(network_passphrase)" \ + -- "$@" +} + +install_wasm() { + local wasm="$1" + soroban contract install \ + --wasm "$wasm" \ + --source "$STELLAR_SECRET_KEY" \ + --rpc-url "$(rpc_url)" \ + --network-passphrase "$(network_passphrase)" +} + +update_manifest() { + local name="$1" + local wasm_hash="$2" + local old_version="$3" + local timestamp + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + local tmp + tmp="$(mktemp)" + jq \ + --arg name "$name" \ + --arg wasmHash "$wasm_hash" \ + --arg oldVersion "$old_version" \ + --arg newVersion "$NEW_VERSION" \ + --arg timestamp "$timestamp" \ + '.contracts[$name].wasm_hash = $wasmHash | + .contracts[$name].version = $newVersion | + .contracts[$name].upgraded_at = $timestamp | + .deployment_history += [{ + action: "verified-upgrade", + contract: $name, + old_version: $oldVersion, + new_version: $newVersion, + wasm_hash: $wasmHash, + network: .network, + timestamp: $timestamp + }]' "$MANIFEST" > "$tmp" + mv "$tmp" "$MANIFEST" +} + +verify_contract() { + local name="$1" + local wasm_name="$2" + local id + id="$(contract_id "$name")" + [[ -n "$id" && "$id" != "null" ]] || { echo "Missing contract_id for $name" >&2; exit 1; } + + local wasm="$TARGET_DIR/${wasm_name}.wasm" + [[ -f "$wasm" ]] || { echo "Missing WASM for $name: $wasm" >&2; exit 1; } + + echo "Verifying $name at $id" + local admin + admin="$(invoke "$id" admin)" + [[ -n "$admin" && "$admin" != "null" ]] || { echo "$name did not expose admin" >&2; exit 1; } + + local old_version + old_version="$(invoke "$id" contract_version)" + [[ -n "$old_version" ]] || { echo "$name did not expose contract_version" >&2; exit 1; } + + local wasm_hash + wasm_hash="$(install_wasm "$wasm" | grep -Eo '[A-Fa-f0-9]{64}' | head -1)" + [[ -n "$wasm_hash" ]] || { echo "No WASM hash returned for $name" >&2; exit 1; } + + invoke "$id" upgrade --new_wasm_hash "$wasm_hash" --new_version "$NEW_VERSION" >/dev/null + + local observed_version + observed_version="$(invoke "$id" contract_version)" + [[ "$observed_version" == "$NEW_VERSION" ]] || { + echo "$name version mismatch: expected $NEW_VERSION, got $observed_version" >&2 + exit 1 + } + + update_manifest "$name" "$wasm_hash" "$old_version" + echo "$name upgraded and verified: $old_version -> $observed_version" +} + +main() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + + require_tools + build_contracts + + for entry in "${CONTRACTS[@]}"; do + IFS=":" read -r name wasm_name <<< "$entry" + verify_contract "$name" "$wasm_name" + done +} + +main "$@"