diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 6a9f1fcd..f317aa1e 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -136,6 +136,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); ipcMain.handle(IPC_CHANNELS.updateEmployeeProfile, (_event, input) => mutate(() => organization.updateEmployeeProfile(input))); + ipcMain.handle(IPC_CHANNELS.setAutonomousPaused, (_event, input) => mutate(() => organization.setAutonomousPaused(Boolean(input.paused)))); ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input))); ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input))); diff --git a/src/main/app/task-queue-worker.ts b/src/main/app/task-queue-worker.ts index f15d0a6c..42f9dc55 100644 --- a/src/main/app/task-queue-worker.ts +++ b/src/main/app/task-queue-worker.ts @@ -16,6 +16,13 @@ export interface QueuedRun { org_id: string; } +/** True while the operator has pulled the global autonomous handbrake. */ +export function isAutonomousPaused(connection: DatabaseConnection): boolean { + const row = connection.db.prepare("select autorun_paused from app_state where id = 1") + .get() as { autorun_paused: number } | undefined; + return row?.autorun_paused === 1; +} + /** * Atomically claims the oldest queued task_run by transitioning it * `queued` -> `running` in one transaction; returns the claimed run or null. @@ -48,11 +55,20 @@ export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void { const taskExecution = new TaskExecutionService(input.connection.db, undefined, undefined, notifyDataChanged); const intervalMs = input.intervalMs ?? DEFAULT_INTERVAL_MS; let running = false; + let wasPaused = false; const tick = async (): Promise => { if (running) return; running = true; try { + // The operator's global pause parks the whole engine: nothing new is + // enqueued or claimed until they resume. Queued work simply waits. + const paused = isAutonomousPaused(input.connection); + if (paused !== wasPaused) { + console.info(paused ? "[task-queue] paused by the operator; no new runs start." : "[task-queue] resumed by the operator."); + wasPaused = paused; + } + if (paused) return; const enqueued = input.connection.transaction(() => taskExecution.enqueueHeartbeatRuns()); const claimed = claimNextQueuedRun(input.connection); if (!claimed) { diff --git a/src/main/database/migrations/052-avatar-images.ts b/src/main/database/migrations/052-avatar-images.ts new file mode 100644 index 00000000..4d1df319 --- /dev/null +++ b/src/main/database/migrations/052-avatar-images.ts @@ -0,0 +1,7 @@ +export const avatarImagesSchema = ` + alter table agents add column avatar_image text; + alter table employees add column avatar_image text; + + insert into schema_migrations(version, name, applied_at) + values (52, 'avatar_images', datetime('now')); +`; diff --git a/src/main/database/migrations/053-autonomous-pause.ts b/src/main/database/migrations/053-autonomous-pause.ts new file mode 100644 index 00000000..be1f8514 --- /dev/null +++ b/src/main/database/migrations/053-autonomous-pause.ts @@ -0,0 +1,6 @@ +export const autonomousPauseSchema = ` + alter table app_state add column autorun_paused integer not null default 0; + + insert into schema_migrations(version, name, applied_at) + values (53, 'autonomous_pause', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 39c56542..b1b175a0 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -50,6 +50,8 @@ import { uiPreferencesSchema } from "./migrations/048-ui-preferences"; import { avatarsSchema } from "./migrations/049-avatars"; import { financeSalesDepartmentsSchema } from "./migrations/050-finance-sales-departments"; import { purgeRuntimeSnapshotBloatSchema } from "./migrations/051-purge-runtime-snapshot-bloat"; +import { avatarImagesSchema } from "./migrations/052-avatar-images"; +import { autonomousPauseSchema } from "./migrations/053-autonomous-pause"; interface Migration { version: number; @@ -109,6 +111,8 @@ const MIGRATIONS: Migration[] = [ { version: 49, name: "avatars", sql: avatarsSchema }, { version: 50, name: "finance_sales_departments", sql: financeSalesDepartmentsSchema }, { version: 51, name: "purge_runtime_snapshot_bloat", sql: purgeRuntimeSnapshotBloatSchema }, + { version: 52, name: "avatar_images", sql: avatarImagesSchema }, + { version: 53, name: "autonomous_pause", sql: autonomousPauseSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index ad353c94..a62356ac 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -47,6 +47,20 @@ export class OrganizationService { }); } + /** + * The global handbrake: while paused, the autonomous queue stops enqueuing + * and claiming runs (the run already in flight finishes). Survives + * restarts, so a runaway conversation stays stopped until the operator + * resumes it. + */ + setAutonomousPaused(paused: boolean): void { + this.db.prepare(` + insert into app_state(id, active_org_id, autorun_paused, updated_at) + values (1, null, ?, ?) + on conflict(id) do update set autorun_paused = excluded.autorun_paused, updated_at = excluded.updated_at + `).run(paused ? 1 : 0, new Date().toISOString()); + } + updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void { const current = this.db.prepare("select theme, language from app_state where id = 1") .get() as { theme: string; language: string } | undefined; @@ -148,8 +162,8 @@ export class OrganizationService { } updateAgent(input: UpdateAgentInput): void { - const agent = this.db.prepare("select id, name, avatar_emoji, avatar_color from agents where id = ? and org_id = ?") - .get(input.agentId, input.orgId) as { id: string; name: string; avatar_emoji: string | null; avatar_color: string | null } | undefined; + const agent = this.db.prepare("select id, name, avatar_emoji, avatar_color, avatar_image from agents where id = ? and org_id = ?") + .get(input.agentId, input.orgId) as { id: string; name: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined; if (!agent) throw new Error("Agent was not found."); const role = input.role.trim(); if (!role) throw new Error("Agent role is required."); @@ -168,12 +182,13 @@ export class OrganizationService { const budgetCents = Math.max(0, Math.round(input.budgetCents)); const avatarEmoji = input.avatarEmoji === undefined ? agent.avatar_emoji : (input.avatarEmoji.trim() || null); const avatarColor = input.avatarColor === undefined ? agent.avatar_color : normalizeAvatarColor(input.avatarColor); + const avatarImage = input.avatarImage === undefined ? agent.avatar_image : normalizeAvatarImage(input.avatarImage); const result = this.db.prepare(` update agents set name = ?, role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?, - avatar_emoji = ?, avatar_color = ? + avatar_emoji = ?, avatar_color = ?, avatar_image = ? where id = ? and org_id = ? - `).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, input.agentId, input.orgId); + `).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, avatarImage, input.agentId, input.orgId); if (result.changes === 0) throw new Error("Agent update failed."); this.audit.record({ orgId: input.orgId, @@ -187,15 +202,16 @@ export class OrganizationService { } updateEmployeeProfile(input: UpdateEmployeeProfileInput): void { - const employee = this.db.prepare("select id, avatar_emoji, avatar_color from employees where id = ? and org_id = ?") - .get(input.employeeId, input.orgId) as { id: string; avatar_emoji: string | null; avatar_color: string | null } | undefined; + const employee = this.db.prepare("select id, avatar_emoji, avatar_color, avatar_image from employees where id = ? and org_id = ?") + .get(input.employeeId, input.orgId) as { id: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined; if (!employee) throw new Error("Employee was not found."); const displayName = input.displayName.trim(); if (!displayName) throw new Error("Display name is required."); const avatarEmoji = input.avatarEmoji === undefined ? employee.avatar_emoji : (input.avatarEmoji.trim() || null); const avatarColor = input.avatarColor === undefined ? employee.avatar_color : normalizeAvatarColor(input.avatarColor); - this.db.prepare("update employees set display_name = ?, avatar_emoji = ?, avatar_color = ? where id = ? and org_id = ?") - .run(displayName, avatarEmoji, avatarColor, input.employeeId, input.orgId); + const avatarImage = input.avatarImage === undefined ? employee.avatar_image : normalizeAvatarImage(input.avatarImage); + this.db.prepare("update employees set display_name = ?, avatar_emoji = ?, avatar_color = ?, avatar_image = ? where id = ? and org_id = ?") + .run(displayName, avatarEmoji, avatarColor, avatarImage, input.employeeId, input.orgId); this.audit.record({ orgId: input.orgId, entityType: "employee", @@ -531,3 +547,22 @@ function normalizeAvatarColor(value: string): string | null { if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error("Avatar color must be a #rrggbb hex value."); return color.toLowerCase(); } + +/** + * Avatar portraits persist as small base64 data URLs (the renderer downscales + * before submitting). The cap keeps a rogue caller from turning the snapshot + * back into a megabyte payload. + */ +const MAX_AVATAR_IMAGE_CHARS = 300_000; + +function normalizeAvatarImage(value: string): string | null { + const image = value.trim(); + if (!image) return null; + if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) { + throw new Error("Avatar image must be a base64 data URL (png, jpeg, or webp)."); + } + if (image.length > MAX_AVATAR_IMAGE_CHARS) { + throw new Error("Avatar image is too large - upload a smaller picture."); + } + return image; +} diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 774cb6b8..45d8bed2 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -86,6 +86,7 @@ export class SnapshotService { return { activeOrgId, uiPreferences: this.resolveUiPreferences(), + autonomousPaused: this.resolveAutonomousPaused(), organizations, employees: list(this.db, "employees", "created_at asc"), agents, @@ -182,6 +183,12 @@ export class SnapshotService { return known[0] ?? null; } + private resolveAutonomousPaused(): boolean { + const row = this.db.prepare("select autorun_paused from app_state where id = 1") + .get() as { autorun_paused: number } | undefined; + return row?.autorun_paused === 1; + } + private resolveUiPreferences(): AppSnapshot["uiPreferences"] { const row = this.db.prepare("select theme, language from app_state where id = 1") .get() as { theme: string; language: string } | undefined; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0494fe88..103663b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,7 @@ const api: DesktopApi = { createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), updateEmployeeProfile: (input) => invoke(IPC_CHANNELS.updateEmployeeProfile, input), + setAutonomousPaused: (input) => invoke(IPC_CHANNELS.setAutonomousPaused, input), createTask: (input) => invoke(IPC_CHANNELS.createTask, input), updateTask: (input) => invoke(IPC_CHANNELS.updateTask, input), deleteTask: (input) => invoke(IPC_CHANNELS.deleteTask, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index d2c00674..0e3876c5 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -25,11 +25,15 @@ export function App() { const orgId = snapshot?.activeOrgId ?? null; for (const agent of snapshot?.agents ?? []) { if (agent.orgId !== orgId) continue; - if (agent.avatarEmoji || agent.avatarColor) directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor }); + if (agent.avatarEmoji || agent.avatarColor || agent.avatarImage) { + directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor, image: agent.avatarImage }); + } } for (const employee of snapshot?.employees ?? []) { if (employee.orgId !== orgId) continue; - if (employee.avatarEmoji || employee.avatarColor) directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor }); + if (employee.avatarEmoji || employee.avatarColor || employee.avatarImage) { + directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor, image: employee.avatarImage }); + } } return directory; }, [snapshot?.agents, snapshot?.employees, snapshot?.activeOrgId]); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index ae373ba9..f9d62a08 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -126,6 +126,14 @@ export const zh: Record = { "Edit profile": "编辑个人资料", "Former member": "已离职成员", "Jump to latest": "跳到最新消息", + "Upload photo": "上传照片", + "Pause agents": "暂停智能体", + "Agents paused": "已暂停 · 点击恢复", + "Pause all agents: the run already in flight finishes, nothing new starts.": "暂停全部智能体:进行中的这一条会跑完,之后不再派发新任务。", + "Agents are paused: no new runs start until you resume. Click to resume.": "智能体已暂停:恢复前不会开始任何新任务。点击恢复。", + "Remove photo": "移除照片", + "Avatar image file": "头像图片文件", + "That file could not be read as an image.": "无法将该文件读取为图片。", "Conversation messages": "会话消息", "Whip": "鞭子", "Office tools": "办公室工具", diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index f45bc1ea..cc4c411c 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -93,6 +93,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { modelId: optional(form, "modelId"), avatarEmoji: String(form.get("avatarEmoji") ?? ""), avatarColor: String(form.get("avatarColor") ?? ""), + avatarImage: String(form.get("avatarImage") ?? ""), })).then((result) => { if (result) setEditAgentId(null); }); }; @@ -172,7 +173,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { {editingAgent && ( setEditAgentId(null)} onSubmit={submitEdit}> - + diff --git a/src/renderer/src/shared/AvatarField.tsx b/src/renderer/src/shared/AvatarField.tsx index c94c9a27..447f6697 100644 --- a/src/renderer/src/shared/AvatarField.tsx +++ b/src/renderer/src/shared/AvatarField.tsx @@ -1,28 +1,58 @@ -import { useState } from "react"; -import { Avatar, AVATAR_COLORS, Field } from "./ui"; +import { useRef, useState, type ChangeEvent } from "react"; +import { Avatar, AVATAR_COLORS, Button, Field } from "./ui"; import { t } from "../i18n/i18n"; +/** Longest edge of a stored portrait; keeps data URLs at a few kilobytes. */ +const PORTRAIT_SIZE = 96; + /** - * Form-embedded avatar editor: an emoji (or empty for initials) plus a - * background swatch. Submits through the surrounding form as `avatarEmoji` - * and `avatarColor`; the "auto" swatch clears the color back to the - * name-hashed palette. + * Form-embedded avatar editor: an uploaded portrait (downscaled to a small + * data URL), or an emoji (or empty for initials) on a background swatch. + * Submits through the surrounding form as `avatarImage`, `avatarEmoji`, and + * `avatarColor`; the "auto" swatch clears the color back to the name-hashed + * palette and "Remove photo" falls back to emoji/initials. */ export function AvatarField({ previewName, defaultEmoji, defaultColor, + defaultImage, }: { previewName: string; defaultEmoji: string | null; defaultColor: string | null; + defaultImage?: string | null; }) { const [emoji, setEmoji] = useState(defaultEmoji ?? ""); const [color, setColor] = useState(defaultColor ?? ""); + const [image, setImage] = useState(defaultImage ?? ""); + const [uploadError, setUploadError] = useState(null); + const fileInputRef = useRef(null); + + const onFileChosen = (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0] ?? null; + event.currentTarget.value = ""; + if (!file) return; + setUploadError(null); + void portraitDataUrl(file) + .then(setImage) + .catch((error) => setUploadError(error instanceof Error ? error.message : String(error))); + }; + return (
- + + + + {image && } + + {uploadError &&
{uploadError}
}
); } + +/** + * Downscales the chosen picture to a small square cover crop and returns a + * compact data URL. PNG keeps transparency; everything else compresses to + * JPEG. + */ +async function portraitDataUrl(file: File): Promise { + const bitmap = await createImageBitmap(file).catch(() => { + throw new Error(t("That file could not be read as an image.")); + }); + try { + const canvas = document.createElement("canvas"); + canvas.width = PORTRAIT_SIZE; + canvas.height = PORTRAIT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error(t("That file could not be read as an image.")); + const scale = Math.max(PORTRAIT_SIZE / bitmap.width, PORTRAIT_SIZE / bitmap.height); + const drawW = bitmap.width * scale; + const drawH = bitmap.height * scale; + ctx.drawImage(bitmap, (PORTRAIT_SIZE - drawW) / 2, (PORTRAIT_SIZE - drawH) / 2, drawW, drawH); + return file.type === "image/png" ? canvas.toDataURL("image/png") : canvas.toDataURL("image/jpeg", 0.85); + } finally { + bitmap.close(); + } +} diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx index 4b87b624..6d012cc1 100644 --- a/src/renderer/src/shared/Shell.tsx +++ b/src/renderer/src/shared/Shell.tsx @@ -135,6 +135,18 @@ export function Shell({ {activeLabel}
+ {!isMac && ( void window.agentCompany.windowMinimize()} /> @@ -243,6 +255,7 @@ function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClos displayName: String(form.get("displayName") ?? "").trim(), avatarEmoji: String(form.get("avatarEmoji") ?? ""), avatarColor: String(form.get("avatarColor") ?? ""), + avatarImage: String(form.get("avatarImage") ?? ""), }).then((result) => { if (result.ok) onClose(); else setError(result.error.message); @@ -251,7 +264,7 @@ function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClos return ( - + {error &&
{error}
}
); diff --git a/src/renderer/src/shared/ui.tsx b/src/renderer/src/shared/ui.tsx index 627274f0..71d2f49f 100644 --- a/src/renderer/src/shared/ui.tsx +++ b/src/renderer/src/shared/ui.tsx @@ -4,6 +4,8 @@ import { Icon, type IconName } from "./Icon"; export interface AvatarLook { emoji: string | null; color: string | null; + /** data-URL portrait; wins over emoji and initials when set. */ + image?: string | null; } /** @@ -66,9 +68,18 @@ export function Avatar({ const fg = custom?.color ? "#fff" : hashedFg; const dim = size === "sm" ? 14 : size === "lg" ? 18 : size === "xl" ? 24 : 14; const emoji = custom?.emoji ?? null; + const image = custom?.image ?? null; return ( -
- {emoji ? {emoji} : bot ? : initials(name)} +
+ {image ? ( + + ) : emoji ? ( + {emoji} + ) : bot ? ( + + ) : ( + initials(name) + )}
); } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index e1478ca5..0ee79891 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -303,3 +303,27 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18); } .jump-latest:hover { background: var(--hover); } +.avatar-image { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } + +/* Global autonomous-engine pause toggle in the topbar */ +.autopause-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: none; + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 540; + cursor: pointer; + -webkit-app-region: no-drag; +} +.autopause-toggle:hover { background: var(--hover); color: var(--text-1); } +.autopause-toggle.paused { + background: var(--amber, #f59e0b); + border-color: transparent; + color: #231600; +} diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 884fcdb4..d7d8d1a8 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -40,6 +40,12 @@ export interface UpdateAgentInput { avatarEmoji?: string; /** Background color for the avatar tile; empty string clears it. */ avatarColor?: string; + /** data:image/... portrait (already downscaled); empty string clears it. */ + avatarImage?: string; +} + +export interface SetAutonomousPausedInput { + paused: boolean; } export interface UpdateEmployeeProfileInput { @@ -48,6 +54,7 @@ export interface UpdateEmployeeProfileInput { displayName: string; avatarEmoji?: string; avatarColor?: string; + avatarImage?: string; } export interface CreateAgentInput { diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 9bb77f97..3a55973f 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -6,6 +6,7 @@ import type { BootstrapOnboardingSummary, CancelProviderResponseInput, CreateAgentInput, + SetAutonomousPausedInput, UpdateAgentInput, UpdateEmployeeProfileInput, CreateApprovalInput, @@ -109,6 +110,7 @@ export interface DesktopApi { createAgent(input: CreateAgentInput): Promise>; updateAgent(input: UpdateAgentInput): Promise>; updateEmployeeProfile(input: UpdateEmployeeProfileInput): Promise>; + setAutonomousPaused(input: SetAutonomousPausedInput): Promise>; createTask(input: CreateTaskInput): Promise>; updateTask(input: UpdateTaskInput): Promise>; deleteTask(input: DeleteTaskInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 4c7acb09..9f5b3df0 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -51,6 +51,8 @@ export interface EmployeeRecord { role: string; avatarEmoji: string | null; avatarColor: string | null; + /** Small data-URL portrait; wins over emoji/initials when set. */ + avatarImage: string | null; createdAt: string; } @@ -73,6 +75,8 @@ export interface AgentRecord { lifecycle: string; avatarEmoji: string | null; avatarColor: string | null; + /** Small data-URL portrait; wins over emoji/initials when set. */ + avatarImage: string | null; createdAt: string; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 0bb1bedf..2fc0b737 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,7 @@ export const IPC_CHANNELS = { createAgent: "agentcompany:create-agent", updateAgent: "agentcompany:update-agent", updateEmployeeProfile: "agentcompany:update-employee-profile", + setAutonomousPaused: "agentcompany:set-autonomous-paused", createTask: "agentcompany:create-task", updateTask: "agentcompany:update-task", deleteTask: "agentcompany:delete-task", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 2579866c..cb220a94 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -69,6 +69,8 @@ export interface UiPreferences { export interface AppSnapshot { activeOrgId: string | null; uiPreferences: UiPreferences; + /** True while the operator has paused the autonomous run engine. */ + autonomousPaused: boolean; organizations: OrganizationRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index a19ce1df..5482b48f 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -101,6 +101,20 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.locator(".modal").getByRole("button", { name: "Save" }).click(); await expect(page.getByRole("button", { name: "Edit profile" })).toContainText("Dana Zhang"); await expect(page.getByRole("button", { name: "Edit profile" }).locator(".avatar")).toContainText("🎯"); + + // Uploading a photo replaces the emoji tile with the portrait everywhere. + await page.getByRole("button", { name: "Edit profile" }).click(); + await page.getByLabel("Avatar image file").setInputFiles({ + name: "portrait.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + await expect(page.locator(".modal .avatar img")).toBeVisible(); + await page.locator(".modal").getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("button", { name: "Edit profile" }).locator(".avatar img")).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); await page.getByRole("button", { name: "New task" }).click(); await page.getByLabel("Title").fill("Production deploy"); @@ -170,6 +184,16 @@ test("onboards, hires, and creates work in the Electron shell", async () => { }); expect(skillSlugs).toEqual(["engineering-backend-architect", "humanizer"]); + // The global pause toggle lives in the topbar on every page. + const pauseToggle = page.getByRole("button", { name: "Pause agents" }); + await expect(pauseToggle).toBeVisible(); + await pauseToggle.click(); + const pausedToggle = page.getByRole("button", { name: "Agents paused" }); + await expect(pausedToggle).toBeVisible(); + await expect(pausedToggle).toHaveAttribute("aria-pressed", "true"); + await pausedToggle.click(); + await expect(page.getByRole("button", { name: "Pause agents" })).toHaveAttribute("aria-pressed", "false"); + // Virtual office page await page.getByRole("button", { name: "Office" }).click(); await expect(page.locator("canvas")).toBeVisible(); diff --git a/tests/unit/autonomous-pause.test.ts b/tests/unit/autonomous-pause.test.ts new file mode 100644 index 00000000..fc382749 --- /dev/null +++ b/tests/unit/autonomous-pause.test.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import type { BrowserWindow } from "electron"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { isAutonomousPaused, startTaskQueueWorker } from "../../src/main/app/task-queue-worker"; + +const fakeWindow = { + isDestroyed: () => false, + webContents: { send: () => undefined, isDestroyed: () => false }, +} as unknown as BrowserWindow; + +function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + const poll = () => { + if (predicate()) return resolve(true); + if (Date.now() - startedAt > timeoutMs) return resolve(false); + setTimeout(poll, 20); + }; + poll(); + }); +} + +describe("global autonomous pause", () => { + test("the handbrake parks the queue worker and resuming releases it", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-pause-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let runId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? limit 1").get(orgId) as { id: string }).id; + const agentId = organization.createAgent({ + orgId, name: "Ada", role: "Engineer", department: "Engineering", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, + }); + const taskId = organization.createTask({ + orgId, title: "Queued work", description: "Waits for the engine.", status: "backlog", + assigneeAgentId: agentId, risk: "low", projectId: null, goalId: null, workspaceId: null, + priority: "normal", requiresFilesystem: false, + }); + const serviceIdentityId = (connection.db.prepare("select id from service_identities where org_id = ? and agent_id = ?") + .get(orgId, agentId) as { id: string }).id; + runId = `run_pause_${randomUUID().slice(0, 8)}`; + connection.db.prepare(` + insert into task_runs( + id, org_id, task_id, agent_id, provider_id, provider_request_id, + execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json, + trigger, state, created_at, started_at, completed_at, error_message + ) values (?, ?, ?, ?, ?, null, ?, ?, '[]', '{}', 'heartbeat', 'queued', ?, null, null, null) + `).run(runId, orgId, taskId, agentId, providerId, `ctx_${runId}`, serviceIdentityId, new Date().toISOString()); + organization.setAutonomousPaused(true); + }); + + expect(isAutonomousPaused(connection)).toBe(true); + expect(snapshot.load().autonomousPaused).toBe(true); + + const runState = () => + (connection.db.prepare("select state from task_runs where id = ?").get(runId) as { state: string }).state; + + const stop = startTaskQueueWorker({ connection, mainWindow: fakeWindow, intervalMs: 10 }); + try { + // Paused: many ticks pass and the queued run is never claimed. + const claimedWhilePaused = await waitFor(() => runState() !== "queued", 150); + expect(claimedWhilePaused).toBe(false); + + // Resume: the very next ticks claim it (execution then fails on the + // unconfigured provider, which is fine - leaving "queued" proves the + // engine moved). + connection.transaction(() => organization.setAutonomousPaused(false)); + expect(snapshot.load().autonomousPaused).toBe(false); + const claimedAfterResume = await waitFor(() => runState() !== "queued", 2_000); + expect(claimedAfterResume).toBe(true); + } finally { + stop(); + } + connection.close(); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 7d05c030..580be2ab 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53]); connection.close(); }); diff --git a/tests/unit/file-helpers.test.ts b/tests/unit/file-helpers.test.ts index e3655d4f..6d8f3bab 100644 --- a/tests/unit/file-helpers.test.ts +++ b/tests/unit/file-helpers.test.ts @@ -8,7 +8,7 @@ function agent(id: string, department: string): AgentRecord { modelProviderId: null, modelId: null, budgetCents: 0, managerAgentId: null, title: id, roleLevel: "ic", modelStrategy: "single", workspaceId: null, heartbeatEnabled: false, heartbeatIntervalSec: 0, lifecycle: "active", - avatarEmoji: null, avatarColor: null, createdAt: "2026-01-01", + avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01", }; } diff --git a/tests/unit/profile-identity.test.ts b/tests/unit/profile-identity.test.ts index 48a825fa..7d444a16 100644 --- a/tests/unit/profile-identity.test.ts +++ b/tests/unit/profile-identity.test.ts @@ -122,4 +122,35 @@ describe("identity editing", () => { .toThrow("Display name is required."); h.connection.close(); }); + + test("stores uploaded avatar portraits and rejects anything but small data-URL images", () => { + const h = harness(); + const portrait = `data:image/png;base64,${"A".repeat(120)}`; + h.connection.transaction(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: portrait, + })); + expect((h.connection.db.prepare("select avatar_image from employees where id = ?").get(h.employeeId) as { avatar_image: string }).avatar_image) + .toBe(portrait); + // Clearing falls back to emoji/initials. + h.connection.transaction(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: "", + })); + expect((h.connection.db.prepare("select avatar_image from employees where id = ?").get(h.employeeId) as { avatar_image: string | null }).avatar_image) + .toBeNull(); + // Wrong scheme and oversized payloads are caller bugs, loudly rejected. + expect(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: "https://example.com/me.png", + })).toThrow("base64 data URL"); + expect(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: `data:image/png;base64,${"A".repeat(300_001)}`, + })).toThrow("too large"); + // Agents take portraits through the same rules. + h.connection.transaction(() => h.organization.updateAgent({ + orgId: h.orgId, agentId: h.agentId, role: "CEO", title: "CEO", roleLevel: "c_suite", + department: "Executive", budgetCents: 25000, modelProviderId: null, modelId: null, avatarImage: portrait, + })); + expect((h.connection.db.prepare("select avatar_image from agents where id = ?").get(h.agentId) as { avatar_image: string }).avatar_image) + .toBe(portrait); + h.connection.close(); + }); });