diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 0f8fbbeb..2281c880 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -12,13 +12,18 @@ import { createServer, 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, @@ -39,10 +44,20 @@ import { rateLimitMiddleware, registerRateLimitMiddleware, publicLimiter, authed import { authMiddleware } from "./middleware/auth"; import { createCorsMiddleware } from "./middleware/cors"; 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 { createAuthRouter } from "./routes/auth"; @@ -114,6 +129,11 @@ export function createApp(opts: AppOptions = {}): { app.use(requestLogger); app.use(metricsMiddleware); app.use(versioningMiddleware); + app.use( + readOnlyMiddleware({ + exemptPaths: ["/api/admin", "/api/reconciliation"], + }), + ); if (!opts.disableCompression && config.NODE_ENV !== "test") { app.use(...compressionMiddleware()); @@ -188,6 +208,7 @@ export function createApp(opts: AppOptions = {}): { } else { return v2TasksRouter(req, res, next); } + return v2TasksRouter(req, res, next); }); // ── Admin Queue routes ───────────────────────────────────────────────────── 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/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 51d6db29..7bd09bd5 100644 --- a/frontend/src/components/agents/AgentDetailModal.module.css +++ b/frontend/src/components/agents/AgentDetailModal.module.css @@ -63,22 +63,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 { @@ -88,11 +88,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; @@ -102,12 +102,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/lib.rs b/smart-contracts/contracts/agent_bidding/src/lib.rs index 96fc476a..c6e6754f 100644 --- a/smart-contracts/contracts/agent_bidding/src/lib.rs +++ b/smart-contracts/contracts/agent_bidding/src/lib.rs @@ -105,6 +105,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"; /// Maximum page size accepted by [`AgentBiddingContract::get_bidders`]. const MAX_PAGE_SIZE: u32 = 50; @@ -207,6 +208,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 a3288ae3..9ac583bb 100644 --- a/smart-contracts/contracts/agent_bidding/src/types.rs +++ b/smart-contracts/contracts/agent_bidding/src/types.rs @@ -239,6 +239,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 a3a161c2..2b14ff75 100644 --- a/smart-contracts/contracts/agent_registry/src/lib.rs +++ b/smart-contracts/contracts/agent_registry/src/lib.rs @@ -527,7 +527,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 3b665a73..534891d1 100644 --- a/smart-contracts/contracts/agent_registry/src/upgrade.rs +++ b/smart-contracts/contracts/agent_registry/src/upgrade.rs @@ -31,7 +31,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> { @@ -287,6 +290,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, 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 1113f54e..a67a5a0d 100644 --- a/smart-contracts/contracts/error-resolver/src/agent_errors.rs +++ b/smart-contracts/contracts/error-resolver/src/agent_errors.rs @@ -3,6 +3,8 @@ use soroban_sdk::{ 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 @@ -10,6 +12,7 @@ use soroban_sdk::{ #[contracttype] pub enum DataKey { Admin, + Version, AuthorizedCallers, AgentErrorCount(Symbol), Signers, @@ -182,7 +185,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)); diff --git a/smart-contracts/contracts/task_store/src/lib.rs b/smart-contracts/contracts/task_store/src/lib.rs index 4ae137b1..689a3752 100644 --- a/smart-contracts/contracts/task_store/src/lib.rs +++ b/smart-contracts/contracts/task_store/src/lib.rs @@ -8,9 +8,10 @@ pub use types::{ TASK_LIFECYCLE_EVENT_VERSION, }; -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) @@ -65,6 +66,46 @@ 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 875239ff..c546f3ae 100644 --- a/smart-contracts/contracts/task_store/src/types.rs +++ b/smart-contracts/contracts/task_store/src/types.rs @@ -35,6 +35,8 @@ pub struct TaskMetadata { #[contracttype] #[derive(Clone)] pub enum DataKey { + Admin, + Version, Task(BytesN<32>), } @@ -94,4 +96,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 "$@"