Merge pull request #212 from builderz-labs/feat-virtual-office-local-mode-upgrades

feat: complete virtual office local-mode feature set
This commit is contained in:
nyk 2026-03-05 16:03:34 +07:00 committed by GitHub
commit 3ce46b24be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 3118 additions and 161 deletions

View File

@ -0,0 +1,4 @@
Source: OpenGameArt - "Hero character sprite sheet" by Fry
URL: https://opengameart.org/content/hero-character-sprite-sheet
License: CC0 1.0 Universal
Notes: Used for Virtual Office worker character rendering.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="40" viewBox="0 0 96 40" shape-rendering="crispEdges">
<rect x="0" y="8" width="96" height="24" fill="#2f4464"/>
<rect x="0" y="8" width="96" height="2" fill="#8bb2ea"/>
<rect x="0" y="30" width="96" height="2" fill="#17263b"/>
<rect x="6" y="12" width="18" height="14" fill="#64b9ff"/>
<rect x="8" y="14" width="14" height="8" fill="#214a71"/>
<rect x="42" y="12" width="18" height="14" fill="#64b9ff"/>
<rect x="44" y="14" width="14" height="8" fill="#214a71"/>
<rect x="78" y="12" width="12" height="14" fill="#64b9ff"/>
<rect x="80" y="14" width="8" height="8" fill="#214a71"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" shape-rendering="crispEdges">
<rect width="32" height="32" fill="#101a2d"/>
<rect x="0" y="0" width="16" height="16" fill="#13213a"/>
<rect x="16" y="16" width="16" height="16" fill="#13213a"/>
<rect x="0" y="0" width="32" height="1" fill="#2a3f66" opacity="0.5"/>
<rect x="0" y="0" width="1" height="32" fill="#2a3f66" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@ -0,0 +1,22 @@
Furniture Pack (1.0) Exclusive
Created/distributed by Kenney (www.kenney.nl)
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting (Kenney or www.kenney.nl), this is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:
@KenneyNL

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96" shape-rendering="crispEdges">
<rect width="160" height="96" fill="#2a3d33"/>
<rect x="8" y="8" width="144" height="80" fill="#3f5d4f"/>
<rect x="14" y="14" width="132" height="68" fill="#304a3f"/>
<rect x="20" y="20" width="120" height="56" fill="#243930"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="8" y="34" width="16" height="10" fill="#6e4f3b"/>
<rect x="10" y="36" width="12" height="6" fill="#8a6548"/>
<rect x="6" y="20" width="20" height="14" fill="#2f9e66"/>
<rect x="4" y="24" width="10" height="10" fill="#3bc27d"/>
<rect x="18" y="22" width="10" height="10" fill="#3bc27d"/>
<rect x="14" y="12" width="4" height="10" fill="#3bc27d"/>
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#8b95ad"/>
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
<rect x="9" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="17" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="8" y="14" width="1" height="12" fill="#1b1e2a"/>
<rect x="23" y="14" width="1" height="12" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="7" y="18" width="4" height="8" fill="#1b1e2a"/>
<rect x="21" y="18" width="4" height="8" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 601 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="11" y="26" width="10" height="12" fill="#2f4f88"/>
<rect x="8" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="18" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="8" y="18" width="4" height="8" fill="#1b1e2a"/>
<rect x="20" y="18" width="4" height="8" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 601 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="10" y="26" width="12" height="12" fill="#2f4f88"/>
<rect x="7" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="19" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="7" y="16" width="2" height="10" fill="#1b1e2a"/>
<rect x="23" y="15" width="2" height="10" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48" shape-rendering="crispEdges">
<rect x="12" y="2" width="8" height="8" fill="#f2c9a2"/>
<rect x="10" y="10" width="12" height="4" fill="#1b1e2a"/>
<rect x="9" y="14" width="14" height="12" fill="#9ea9c2"/>
<rect x="10" y="26" width="12" height="12" fill="#2f4f88"/>
<rect x="9" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="17" y="38" width="6" height="8" fill="#1b2944"/>
<rect x="7" y="15" width="2" height="10" fill="#1b1e2a"/>
<rect x="23" y="16" width="2" height="10" fill="#1b1e2a"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -95,18 +95,6 @@ async function saveCronFile(data: OpenClawCronFile): Promise<boolean> {
} }
} }
/** Deduplicate jobs by name — keep the latest (by createdAtMs) per unique name */
function deduplicateJobs(jobs: OpenClawCronJob[]): OpenClawCronJob[] {
const latest = new Map<string, OpenClawCronJob>()
for (const job of jobs) {
const existing = latest.get(job.name)
if (!existing || (job.createdAtMs ?? 0) > (existing.createdAtMs ?? 0)) {
latest.set(job.name, job)
}
}
return [...latest.values()]
}
function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined { function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined {
if (!status) return undefined if (!status) return undefined
const s = status.toLowerCase() const s = status.toLowerCase()
@ -157,7 +145,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ jobs: [] }) return NextResponse.json({ jobs: [] })
} }
const jobs = deduplicateJobs(cronFile.jobs).map(mapOpenClawJob) const jobs = cronFile.jobs.map(mapOpenClawJob)
return NextResponse.json({ jobs }) return NextResponse.json({ jobs })
} }

View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { existsSync, statSync } from 'node:fs'
import { requireRole } from '@/lib/auth'
import { runCommand } from '@/lib/command'
const DEFAULT_DOWNLOAD_URL = 'https://flightdeck.example.com/download'
const DEFAULT_INSTALL_PATHS = [
'/Applications/Flight Deck.app',
'/Applications/Flight Desk.app',
]
function getConfiguredFlightDeckPath(): string | null {
const fromEnv = String(process.env.FLIGHT_DECK_PATH || '').trim()
return fromEnv || null
}
function getFlightDeckBaseUrl(): string {
const fromEnv = String(process.env.FLIGHT_DECK_URL || '').trim()
if (fromEnv) return fromEnv
return 'http://127.0.0.1:4177'
}
function getFlightDeckLaunchUrl(): string {
const fromEnv = String(process.env.FLIGHT_DECK_LAUNCH_URL || '').trim()
if (fromEnv) return fromEnv
return 'flightdeck://open'
}
function isInstalled(targetPath: string): boolean {
try {
return existsSync(targetPath) && statSync(targetPath).isDirectory()
} catch {
return false
}
}
function resolveFlightDeckInstallPath(): string | null {
const configured = getConfiguredFlightDeckPath()
if (configured && isInstalled(configured)) return configured
for (const candidate of DEFAULT_INSTALL_PATHS) {
if (isInstalled(candidate)) return candidate
}
return configured
}
/**
* GET /api/local/flight-deck
* Check Flight Deck local installation status.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const installPath = resolveFlightDeckInstallPath()
const installed = installPath ? isInstalled(installPath) : false
return NextResponse.json({
installed,
installPath: installPath || null,
appUrl: getFlightDeckBaseUrl(),
downloadUrl: DEFAULT_DOWNLOAD_URL,
})
}
/**
* POST /api/local/flight-deck
* Build a Flight Deck URL for the selected agent/session.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const installPath = resolveFlightDeckInstallPath()
const installed = installPath ? isInstalled(installPath) : false
if (!installed) {
return NextResponse.json({
installed: false,
error: 'Flight Deck is not installed locally.',
installPath: installPath || null,
downloadUrl: DEFAULT_DOWNLOAD_URL,
}, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const agent = typeof body?.agent === 'string' ? body.agent : ''
const session = typeof body?.session === 'string' ? body.session : ''
const webUrl = new URL(getFlightDeckBaseUrl())
webUrl.searchParams.set('source', 'mission-control')
if (agent) webUrl.searchParams.set('agent', agent)
if (session) webUrl.searchParams.set('session', session)
const launchUrl = new URL(getFlightDeckLaunchUrl())
launchUrl.searchParams.set('source', 'mission-control')
if (agent) launchUrl.searchParams.set('agent', agent)
if (session) launchUrl.searchParams.set('session', session)
try {
// Launch the native app directly; pass deep-link as payload.
await runCommand('open', ['-a', installPath!, launchUrl.toString()], { timeoutMs: 10_000 })
} catch (error: any) {
try {
// Fallback for apps registered as URL handlers.
await runCommand('open', [launchUrl.toString()], { timeoutMs: 10_000 })
} catch (fallbackError: any) {
return NextResponse.json({
installed: true,
launched: false,
error: fallbackError?.message || error?.message || 'Failed to launch Flight Deck app.',
fallbackUrl: webUrl.toString(),
downloadUrl: DEFAULT_DOWNLOAD_URL,
}, { status: 500 })
}
}
return NextResponse.json({
installed: true,
launched: true,
url: webUrl.toString(),
launchUrl: launchUrl.toString(),
})
}
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { existsSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import { requireRole } from '@/lib/auth'
import { runCommand } from '@/lib/command'
function isAllowedDirectory(input: string): boolean {
const cwd = resolve(input)
if (!cwd.startsWith('/')) return false
if (!(cwd.startsWith('/Users/') || cwd.startsWith('/tmp/') || cwd.startsWith('/var/folders/'))) {
return false
}
if (!existsSync(cwd)) return false
try {
return statSync(cwd).isDirectory()
} catch {
return false
}
}
/**
* POST /api/local/terminal
* Body: { cwd: string }
* Opens a new local Terminal window at the given working directory.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const body = await request.json().catch(() => ({}))
const cwd = typeof body?.cwd === 'string' ? body.cwd.trim() : ''
if (!cwd) {
return NextResponse.json({ error: 'cwd is required' }, { status: 400 })
}
if (!isAllowedDirectory(cwd)) {
return NextResponse.json({ error: 'cwd must be an existing safe local directory' }, { status: 400 })
}
try {
await runCommand('open', ['-a', 'Terminal', cwd], { timeoutMs: 10_000 })
return NextResponse.json({ ok: true, message: `Opened Terminal at ${cwd}` })
} catch (error: any) {
return NextResponse.json({ error: error?.message || 'Failed to open Terminal' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View File

@ -21,10 +21,13 @@ export async function POST(request: NextRequest) {
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const body = await request.json().catch(() => ({})) const body = await request.json().catch(() => ({}))
const taskId = body.task_id const taskId = typeof body?.task_id === 'string' ? body.task_id : ''
const allowedTaskIds = new Set(getSchedulerStatus().map((task) => task.id))
if (!taskId || !['auto_backup', 'auto_cleanup', 'agent_heartbeat'].includes(taskId)) { if (!taskId || !allowedTaskIds.has(taskId)) {
return NextResponse.json({ error: 'task_id required: auto_backup, auto_cleanup, or agent_heartbeat' }, { status: 400 }) return NextResponse.json({
error: `task_id required: ${Array.from(allowedTaskIds).join(', ')}`,
}, { status: 400 })
} }
const result = await triggerTask(taskId) const result = await triggerTask(taskId)

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getAllGatewaySessions } from '@/lib/sessions' import { getAllGatewaySessions } from '@/lib/sessions'
import { syncClaudeSessions } from '@/lib/claude-sessions' import { syncClaudeSessions } from '@/lib/claude-sessions'
import { scanCodexSessions } from '@/lib/codex-sessions'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
@ -49,10 +50,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ sessions }) return NextResponse.json({ sessions })
} }
// Fallback: sync and read local Claude sessions from SQLite // Fallback: sync and read local Claude + Codex sessions from disk/SQLite
await syncClaudeSessions() await syncClaudeSessions()
const claudeSessions = getLocalClaudeSessions() const claudeSessions = getLocalClaudeSessions()
return NextResponse.json({ sessions: claudeSessions }) const codexSessions = getLocalCodexSessions()
const merged = mergeLocalSessions(claudeSessions, codexSessions)
return NextResponse.json({ sessions: merged })
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Sessions API error') logger.error({ err: error }, 'Sessions API error')
return NextResponse.json({ sessions: [] }) return NextResponse.json({ sessions: [] })
@ -89,6 +92,7 @@ function getLocalClaudeSessions() {
toolUses: s.tool_uses || 0, toolUses: s.tool_uses || 0,
estimatedCost: s.estimated_cost || 0, estimatedCost: s.estimated_cost || 0,
lastUserPrompt: s.last_user_prompt || null, lastUserPrompt: s.last_user_prompt || null,
workingDir: s.project_path || null,
} }
}) })
} catch (err) { } catch (err) {
@ -97,6 +101,64 @@ function getLocalClaudeSessions() {
} }
} }
function getLocalCodexSessions() {
try {
const rows = scanCodexSessions(100)
return rows.map((s) => {
const total = s.totalTokens || (s.inputTokens + s.outputTokens)
const lastMsg = s.lastMessageAt ? new Date(s.lastMessageAt).getTime() : 0
const firstMsg = s.firstMessageAt ? new Date(s.firstMessageAt).getTime() : 0
return {
id: s.sessionId,
key: s.projectSlug || s.sessionId,
agent: s.projectSlug || 'codex-local',
kind: 'codex-cli',
age: formatAge(lastMsg),
model: s.model || 'codex',
tokens: `${formatTokens(s.inputTokens || 0)}/${formatTokens(s.outputTokens || 0)}`,
channel: 'local',
flags: [],
active: s.isActive,
startTime: firstMsg,
lastActivity: lastMsg,
source: 'local' as const,
userMessages: s.userMessages || 0,
assistantMessages: s.assistantMessages || 0,
toolUses: 0,
estimatedCost: 0,
lastUserPrompt: null,
totalTokens: total,
workingDir: s.projectPath || null,
}
})
} catch (err) {
logger.warn({ err }, 'Failed to read local Codex sessions')
return []
}
}
function mergeLocalSessions(
claudeSessions: Array<Record<string, any>>,
codexSessions: Array<Record<string, any>>,
) {
const merged = [...claudeSessions, ...codexSessions]
const deduped = new Map<string, Record<string, any>>()
for (const session of merged) {
const id = String(session?.id || '')
if (!id) continue
const existing = deduped.get(id)
const currentActivity = Number(session?.lastActivity || 0)
const existingActivity = Number(existing?.lastActivity || 0)
if (!existing || currentActivity > existingActivity) deduped.set(id, session)
}
return Array.from(deduped.values())
.sort((a, b) => Number(b?.lastActivity || 0) - Number(a?.lastActivity || 0))
.slice(0, 100)
}
function formatTokens(n: number): string { function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m` if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`
if (n >= 1000) return `${Math.round(n / 1000)}k` if (n >= 1000) return `${Math.round(n / 1000)}k`

View File

@ -51,7 +51,8 @@ function formatDateLabel(date: Date): string {
} }
export function CronManagementPanel() { export function CronManagementPanel() {
const { cronJobs, setCronJobs } = useMissionControl() const { cronJobs, setCronJobs, dashboardMode } = useMissionControl()
const isLocalMode = dashboardMode === 'local'
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null) const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
@ -90,15 +91,40 @@ export function CronManagementPanel() {
const loadCronJobs = useCallback(async () => { const loadCronJobs = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const response = await fetch('/api/cron?action=list') const cronResponse = await fetch('/api/cron?action=list')
const data = await response.json() const cronData = await cronResponse.json()
setCronJobs(data.jobs || []) const cronList = Array.isArray(cronData.jobs) ? cronData.jobs : []
if (!isLocalMode) {
setCronJobs(cronList)
return
}
const schedulerResponse = await fetch('/api/scheduler')
const schedulerData = await schedulerResponse.json()
const schedulerTasks = Array.isArray(schedulerData.tasks) ? schedulerData.tasks : []
const mappedSchedulerJobs: CronJob[] = schedulerTasks.map((task: any) => ({
id: task.id,
name: task.name || task.id || 'scheduler-task',
schedule: 'system-managed automation',
command: `Built-in local automation (${task.id || 'unknown'})`,
agentId: 'mission-control-local',
delivery: 'local',
enabled: task.running ? true : !!task.enabled,
lastRun: typeof task.lastRun === 'number' ? task.lastRun : undefined,
nextRun: typeof task.nextRun === 'number' ? task.nextRun : undefined,
lastStatus: task.running
? 'running'
: (task.lastResult?.ok === false ? 'error' : (task.lastResult?.ok === true ? 'success' : undefined)),
}))
setCronJobs([...cronList, ...mappedSchedulerJobs])
} catch (error) { } catch (error) {
log.error('Failed to load cron jobs:', error) log.error('Failed to load cron jobs:', error)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [setCronJobs]) }, [isLocalMode, setCronJobs])
useEffect(() => { useEffect(() => {
loadCronJobs() loadCronJobs()
@ -122,9 +148,44 @@ export function CronManagementPanel() {
loadAvailableModels() loadAvailableModels()
}, []) }, [])
const loadJobLogs = async (jobName: string) => { const loadJobLogs = async (job: CronJob) => {
const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
if (isLocalAutomation) {
const logs: Array<{ timestamp: number; message: string; level: string }> = []
if (job.lastRun) {
logs.push({
timestamp: job.lastRun,
message: `Last run recorded for ${job.name}`,
level: job.lastStatus === 'error' ? 'error' : 'info',
})
}
if (job.lastError) {
logs.push({
timestamp: job.lastRun || Date.now(),
message: `Error: ${job.lastError}`,
level: 'error',
})
}
if (job.nextRun) {
logs.push({
timestamp: Date.now(),
message: `Next scheduled run: ${new Date(job.nextRun).toLocaleString()}`,
level: 'info',
})
}
if (logs.length === 0) {
logs.push({
timestamp: Date.now(),
message: 'No scheduler telemetry available yet for this local automation task',
level: 'info',
})
}
setJobLogs(logs)
return
}
try { try {
const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`) const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(job.name)}`)
const data = await response.json() const data = await response.json()
setJobLogs(data.logs || []) setJobLogs(data.logs || [])
} catch (error) { } catch (error) {
@ -158,7 +219,24 @@ export function CronManagementPanel() {
} }
const triggerJob = async (job: CronJob) => { const triggerJob = async (job: CronJob) => {
const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
try { try {
if (isLocalAutomation) {
const response = await fetch('/api/scheduler', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: job.id }),
})
const result = await response.json()
if (response.ok && result.ok) {
alert(`Local automation executed: ${result.message}`)
} else {
alert(`Local automation failed: ${result.error || result.message || 'Unknown error'}`)
}
await loadCronJobs()
return
}
const response = await fetch('/api/cron', { const response = await fetch('/api/cron', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -253,7 +331,7 @@ export function CronManagementPanel() {
const handleJobSelect = (job: CronJob) => { const handleJobSelect = (job: CronJob) => {
setSelectedJob(job) setSelectedJob(job)
loadJobLogs(job.name) loadJobLogs(job)
} }
const getStatusColor = (status?: string) => { const getStatusColor = (status?: string) => {
@ -426,7 +504,11 @@ export function CronManagementPanel() {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-xl font-semibold">Calendar View</h2> <h2 className="text-xl font-semibold">Calendar View</h2>
<p className="text-sm text-muted-foreground">Interactive schedule across all matching cron jobs</p> <p className="text-sm text-muted-foreground">
{isLocalMode
? 'Read-only schedule visibility across local cron jobs and automations'
: 'Interactive schedule across all matching cron jobs'}
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -647,10 +729,12 @@ export function CronManagementPanel() {
</div> </div>
) : ( ) : (
<div className="space-y-3 max-h-96 overflow-y-auto"> <div className="space-y-3 max-h-96 overflow-y-auto">
{cronJobs.map((job, index) => ( {cronJobs.map((job, index) => {
<div const isLocalAutomation = job.delivery === 'local' && job.agentId === 'mission-control-local'
key={`${job.name}-${index}`} return (
className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${ <div
key={`${job.name}-${index}`}
className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
selectedJob?.name === job.name selectedJob?.name === job.name
? 'bg-primary/10 border-primary/30' ? 'bg-primary/10 border-primary/30'
: 'hover:bg-secondary' : 'hover:bg-secondary'
@ -665,13 +749,15 @@ export function CronManagementPanel() {
{/* Job Type Tag */} {/* Job Type Tag */}
<span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${ <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
isLocalAutomation ? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' :
job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' : job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' : job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' : job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' : job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20' 'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
}`}> }`}>
{job.name.includes('backup') ? 'BACKUP' : {isLocalAutomation ? 'LOCAL AUTO' :
job.name.includes('backup') ? 'BACKUP' :
job.name.includes('alert') ? 'ALERT' : job.name.includes('alert') ? 'ALERT' :
job.name.includes('brief') ? 'BRIEF' : job.name.includes('brief') ? 'BRIEF' :
job.name.includes('scan') ? 'SCAN' : job.name.includes('scan') ? 'SCAN' :
@ -712,11 +798,12 @@ export function CronManagementPanel() {
e.stopPropagation() e.stopPropagation()
toggleJob(job) toggleJob(job)
}} }}
disabled={isLocalAutomation}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
job.enabled job.enabled
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
} transition-colors`} } transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
> >
{job.enabled ? 'Disable' : 'Enable'} {job.enabled ? 'Disable' : 'Enable'}
</button> </button>
@ -734,6 +821,7 @@ export function CronManagementPanel() {
e.stopPropagation() e.stopPropagation()
removeJob(job) removeJob(job)
}} }}
disabled={isLocalAutomation}
className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors" className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
> >
Remove Remove
@ -741,7 +829,7 @@ export function CronManagementPanel() {
</div> </div>
</div> </div>
</div> </div>
))} )})}
</div> </div>
)} )}
</div> </div>
@ -763,6 +851,9 @@ export function CronManagementPanel() {
<div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div> <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
)} )}
<div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div> <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
{selectedJob.delivery === 'local' && selectedJob.agentId === 'mission-control-local' && (
<div><span className="text-muted-foreground">Source:</span> Local scheduler automation</div>
)}
{selectedJob.nextRun && ( {selectedJob.nextRun && (
<div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div> <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
)} )}

File diff suppressed because it is too large Load Diff

View File

@ -63,16 +63,32 @@ interface GatewayOption {
is_primary?: number is_primary?: number
} }
interface SchedulerTask {
id: string
name: string
enabled: boolean
lastRun: number | null
nextRun: number
running: boolean
lastResult?: {
ok: boolean
message: string
timestamp: number
}
}
const TENANT_PAGE_SIZE = 8 const TENANT_PAGE_SIZE = 8
const JOB_PAGE_SIZE = 8 const JOB_PAGE_SIZE = 8
export function SuperAdminPanel() { export function SuperAdminPanel() {
const { currentUser } = useMissionControl() const { currentUser, dashboardMode } = useMissionControl()
const isLocal = dashboardMode === 'local'
const [tenants, setTenants] = useState<TenantRow[]>([]) const [tenants, setTenants] = useState<TenantRow[]>([])
const [jobs, setJobs] = useState<ProvisionJob[]>([]) const [jobs, setJobs] = useState<ProvisionJob[]>([])
const [selectedJobId, setSelectedJobId] = useState<number | null>(null) const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
const [selectedJobEvents, setSelectedJobEvents] = useState<ProvisionEvent[]>([]) const [selectedJobEvents, setSelectedJobEvents] = useState<ProvisionEvent[]>([])
const [localJobEvents, setLocalJobEvents] = useState<Record<number, ProvisionEvent[]>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null)
@ -123,25 +139,96 @@ export function SuperAdminPanel() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [tenantsRes, jobsRes, gatewaysRes] = await Promise.all([ const [tenantsRes, jobsRes, gatewaysRes, schedulerRes] = await Promise.all([
fetch('/api/super/tenants', { cache: 'no-store' }), fetch('/api/super/tenants', { cache: 'no-store' }),
fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }), fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }),
fetch('/api/gateways', { cache: 'no-store' }), fetch('/api/gateways', { cache: 'no-store' }),
isLocal ? fetch('/api/scheduler', { cache: 'no-store' }) : Promise.resolve(null),
]) ])
const tenantsJson = await tenantsRes.json().catch(() => ({})) const tenantsJson = await tenantsRes.json().catch(() => ({}))
const jobsJson = await jobsRes.json().catch(() => ({})) const jobsJson = await jobsRes.json().catch(() => ({}))
const gatewaysJson = await gatewaysRes.json().catch(() => ({})) const gatewaysJson = await gatewaysRes.json().catch(() => ({}))
const schedulerJson = schedulerRes ? await schedulerRes.json().catch(() => ({})) : {}
if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants') if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants')
if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs') if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs')
const tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : [] let tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : []
const jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : [] let jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []
const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : [] const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : []
const schedulerTasks: SchedulerTask[] = Array.isArray(schedulerJson?.tasks) ? schedulerJson.tasks : []
const localEvents: Record<number, ProvisionEvent[]> = {}
if (isLocal) {
if (tenantRows.length === 0) {
const primaryGateway = gatewayRows.find((gw: any) => Number(gw?.is_primary) === 1)
const now = Math.floor(Date.now() / 1000)
tenantRows = [{
id: -1,
slug: 'local-system',
display_name: 'Local Mission Control',
linux_user: currentUser?.username || 'local',
created_by: 'local',
owner_gateway: primaryGateway?.name || 'local',
status: 'active',
plan_tier: 'local',
gateway_port: Number(primaryGateway?.port || 0) || null,
dashboard_port: null,
created_at: now,
latest_job_id: null,
latest_job_status: null,
}]
}
if (jobRows.length === 0 && schedulerTasks.length > 0) {
jobRows = schedulerTasks.map((task, index) => {
const id = -1000 - index
const status = task.running
? 'running'
: (!task.enabled ? 'cancelled' : (task.lastResult?.ok === false ? 'failed' : (task.lastRun ? 'completed' : 'queued')))
const eventRows: ProvisionEvent[] = []
if (task.lastResult) {
eventRows.push({
id: id * -10,
level: task.lastResult.ok ? 'info' : 'error',
step_key: task.id,
message: task.lastResult.message,
created_at: Math.floor(task.lastResult.timestamp / 1000),
})
}
eventRows.push({
id: id * -10 + 1,
level: 'info',
step_key: task.id,
message: `Next run: ${new Date(task.nextRun).toLocaleString()}`,
created_at: Math.floor(Date.now() / 1000),
})
localEvents[id] = eventRows
const lastRunSec = task.lastRun ? Math.floor(task.lastRun / 1000) : null
return {
id,
tenant_id: -1,
tenant_slug: 'local-system',
tenant_display_name: 'Local Mission Control',
job_type: 'automation',
status,
dry_run: 1,
requested_by: 'scheduler',
approved_by: null,
started_at: lastRunSec,
completed_at: status !== 'running' ? lastRunSec : null,
error_text: task.lastResult?.ok === false ? task.lastResult.message : null,
created_at: lastRunSec || Math.floor(task.nextRun / 1000),
} as ProvisionJob
})
}
}
setTenants(tenantRows) setTenants(tenantRows)
setJobs(jobRows) setJobs(jobRows)
setLocalJobEvents(localEvents)
setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary }))) setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary })))
setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways')) setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways'))
setError(null) setError(null)
@ -150,9 +237,16 @@ export function SuperAdminPanel() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [currentUser?.username, isLocal])
const loadJobDetail = useCallback(async (jobId: number) => { const loadJobDetail = useCallback(async (jobId: number) => {
if (isLocal && jobId < 0) {
setSelectedJobId(jobId)
setSelectedJobEvents(localJobEvents[jobId] || [])
setActiveTab('events')
return
}
try { try {
const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' }) const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' })
const json = await res.json().catch(() => ({})) const json = await res.json().catch(() => ({}))
@ -163,7 +257,7 @@ export function SuperAdminPanel() {
} catch (e: any) { } catch (e: any) {
showFeedback(false, e?.message || 'Failed to load job details') showFeedback(false, e?.message || 'Failed to load job details')
} }
}, []) }, [isLocal, localJobEvents])
useEffect(() => { useEffect(() => {
load() load()
@ -406,7 +500,9 @@ export function SuperAdminPanel() {
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Super Mission Control</h2> <h2 className="text-lg font-semibold text-foreground">Super Mission Control</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Multi-tenant provisioning control plane with approval gates and safer destructive actions. {isLocal
? 'Local control plane view over scheduler automations and runtime state.'
: 'Multi-tenant provisioning control plane with approval gates and safer destructive actions.'}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -645,21 +741,27 @@ export function SuperAdminPanel() {
)} )}
</td> </td>
<td className="px-3 py-2 text-right relative"> <td className="px-3 py-2 text-right relative">
<button {isLocal && tenant.id < 0 ? (
onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))} <span className="text-[11px] text-muted-foreground">Local read-only</span>
className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60" ) : (
> <>
Actions
</button>
{openActionMenu === menuKey && (
<div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
<button <button
onClick={() => openDecommissionDialog(tenant)} onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10" className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
> >
Queue Decommission Actions
</button> </button>
</div> {openActionMenu === menuKey && (
<div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
<button
onClick={() => openDecommissionDialog(tenant)}
className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10"
>
Queue Decommission
</button>
</div>
)}
</>
)} )}
</td> </td>
</tr> </tr>
@ -758,42 +860,53 @@ export function SuperAdminPanel() {
<div>Appr: {job.approved_by || '-'}</div> <div>Appr: {job.approved_by || '-'}</div>
</td> </td>
<td className="px-3 py-2 text-right relative"> <td className="px-3 py-2 text-right relative">
<button {isLocal && job.id < 0 ? (
onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))} <button
className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60" onClick={() => loadJobDetail(job.id)}
> className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
Actions >
</button> View
{openActionMenu === menuKey && ( </button>
<div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left"> ) : (
<>
<button <button
onClick={() => loadJobDetail(job.id)} onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40" className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
> >
View events Actions
</button> </button>
<button {openActionMenu === menuKey && (
onClick={() => setJobState(job.id, 'approve')} <div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left">
disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)} <button
className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40" onClick={() => loadJobDetail(job.id)}
> className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40"
Approve >
</button> View events
<button </button>
onClick={() => setJobState(job.id, 'reject')} <button
disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)} onClick={() => setJobState(job.id, 'approve')}
className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40" disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)}
> className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40"
Reject >
</button> Approve
<button </button>
onClick={() => runJob(job.id)} <button
disabled={busyJobId === job.id || job.status !== 'approved'} onClick={() => setJobState(job.id, 'reject')}
className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40" disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)}
> className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40"
{busyJobId === job.id ? 'Running...' : 'Run'} >
</button> Reject
</div> </button>
<button
onClick={() => runJob(job.id)}
disabled={busyJobId === job.id || job.status !== 'approved'}
className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
>
{busyJobId === job.id ? 'Running...' : 'Run'}
</button>
</div>
)}
</>
)} )}
</td> </td>
</tr> </tr>

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useSmartPoll } from '@/lib/use-smart-poll' import { useSmartPoll } from '@/lib/use-smart-poll'
import { useMissionControl } from '@/store'
interface Webhook { interface Webhook {
id: number id: number
@ -33,6 +34,16 @@ interface Delivery {
created_at: number created_at: number
} }
interface SchedulerTask {
id: string
name: string
enabled: boolean
lastRun: number | null
nextRun: number | null
running: boolean
lastResult?: { ok: boolean; message: string; timestamp: number }
}
const AVAILABLE_EVENTS = [ const AVAILABLE_EVENTS = [
{ value: '*', label: 'All events', description: 'Receive all event types' }, { value: '*', label: 'All events', description: 'Receive all event types' },
{ value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' }, { value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
@ -48,7 +59,10 @@ const AVAILABLE_EVENTS = [
] ]
export function WebhookPanel() { export function WebhookPanel() {
const { dashboardMode } = useMissionControl()
const isLocalMode = dashboardMode === 'local'
const [webhooks, setWebhooks] = useState<Webhook[]>([]) const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [webhookAutomations, setWebhookAutomations] = useState<SchedulerTask[]>([])
const [deliveries, setDeliveries] = useState<Delivery[]>([]) const [deliveries, setDeliveries] = useState<Delivery[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -57,6 +71,7 @@ export function WebhookPanel() {
const [testingId, setTestingId] = useState<number | null>(null) const [testingId, setTestingId] = useState<number | null>(null)
const [testResult, setTestResult] = useState<any>(null) const [testResult, setTestResult] = useState<any>(null)
const [newSecret, setNewSecret] = useState<string | null>(null) const [newSecret, setNewSecret] = useState<string | null>(null)
const [runningAutomationId, setRunningAutomationId] = useState<string | null>(null)
const fetchWebhooks = useCallback(async () => { const fetchWebhooks = useCallback(async () => {
try { try {
@ -88,9 +103,30 @@ export function WebhookPanel() {
} catch { /* silent */ } } catch { /* silent */ }
}, [selectedWebhook]) }, [selectedWebhook])
const fetchWebhookAutomations = useCallback(async () => {
if (!isLocalMode) {
setWebhookAutomations([])
return
}
try {
const res = await fetch('/api/scheduler')
if (!res.ok) return
const data = await res.json()
const tasks = Array.isArray(data.tasks) ? data.tasks : []
const webhookTasks = tasks.filter((task: SchedulerTask) =>
typeof task.id === 'string' && task.id.includes('webhook')
)
setWebhookAutomations(webhookTasks)
} catch {
// Keep UI usable if scheduler endpoint is unavailable.
}
}, [isLocalMode])
useEffect(() => { fetchWebhooks() }, [fetchWebhooks]) useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
useEffect(() => { fetchDeliveries() }, [fetchDeliveries]) useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations])
useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true }) useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true })
async function handleCreate(form: { name: string; url: string; events: string[] }) { async function handleCreate(form: { name: string; url: string; events: string[] }) {
try { try {
@ -142,6 +178,29 @@ export function WebhookPanel() {
} }
} }
async function handleRunAutomation(taskId: string) {
setRunningAutomationId(taskId)
try {
const res = await fetch('/api/scheduler', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
})
const data = await res.json()
setTestResult({
success: !!data.ok && res.ok,
error: data.error || (!data.ok ? data.message : null),
duration_ms: undefined,
status_code: res.status,
})
await fetchWebhookAutomations()
} catch {
setTestResult({ success: false, error: 'Failed to run local automation' })
} finally {
setRunningAutomationId(null)
}
}
function formatTime(ts: number) { function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleString(undefined, { return new Date(ts * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
@ -223,6 +282,41 @@ export function WebhookPanel() {
{/* Webhook list */} {/* Webhook list */}
<div className="space-y-2"> <div className="space-y-2">
{isLocalMode && webhookAutomations.length > 0 && (
<div className="rounded-lg border border-cyan-500/30 bg-cyan-500/5 p-3">
<h3 className="text-sm font-semibold text-cyan-200">Local Webhook Automations</h3>
<p className="text-2xs text-cyan-300/80 mt-0.5 mb-2">
Local scheduler tasks that support webhook delivery and retries
</p>
<div className="space-y-2">
{webhookAutomations.map((task) => (
<div key={task.id} className="rounded border border-cyan-500/20 bg-background/30 p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${task.running ? 'bg-blue-400' : task.enabled ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
<span className="text-xs font-medium text-foreground truncate">{task.name}</span>
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cyan-500/15 text-cyan-300 font-mono">{task.id}</span>
</div>
<div className="text-2xs text-muted-foreground mt-1">
{task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'}
{task.lastResult?.message ? ` · ${task.lastResult.message}` : ''}
</div>
</div>
<button
onClick={() => handleRunAutomation(task.id)}
disabled={runningAutomationId === task.id}
className="h-7 px-2.5 text-2xs font-medium text-cyan-300 hover:text-cyan-200 hover:bg-cyan-500/10 rounded transition-smooth disabled:opacity-50"
>
{runningAutomationId === task.id ? 'Running...' : 'Run'}
</button>
</div>
</div>
))}
</div>
</div>
)}
{loading && webhooks.length === 0 ? ( {loading && webhooks.length === 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)} {[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)}

219
src/lib/codex-sessions.ts Normal file
View File

@ -0,0 +1,219 @@
import { readdirSync, readFileSync, statSync } from 'fs'
import { basename, join } from 'path'
import { config } from './config'
import { logger } from './logger'
const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000
const DEFAULT_FILE_SCAN_LIMIT = 120
export interface CodexSessionStats {
sessionId: string
projectSlug: string
projectPath: string | null
model: string | null
userMessages: number
assistantMessages: number
inputTokens: number
outputTokens: number
totalTokens: number
firstMessageAt: string | null
lastMessageAt: string | null
isActive: boolean
}
interface ParsedFile {
path: string
mtimeMs: number
}
function asObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function deriveSessionId(filePath: string): string {
const name = basename(filePath, '.jsonl')
const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i)
return match?.[1] || name
}
function listRecentCodexSessionFiles(limit: number): ParsedFile[] {
const root = join(config.homeDir, '.codex', 'sessions')
const files: ParsedFile[] = []
const stack = [root]
while (stack.length > 0) {
const dir = stack.pop()
if (!dir) continue
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
for (const entry of entries) {
const fullPath = join(dir, entry)
let stat
try {
stat = statSync(fullPath)
} catch {
continue
}
if (stat.isDirectory()) {
stack.push(fullPath)
continue
}
if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue
files.push({ path: fullPath, mtimeMs: stat.mtimeMs })
}
}
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
return files.slice(0, Math.max(1, limit))
}
function parseCodexSessionFile(filePath: string): CodexSessionStats | null {
let content: string
try {
content = readFileSync(filePath, 'utf-8')
} catch {
return null
}
const lines = content.split('\n').filter(Boolean)
if (lines.length === 0) return null
let sessionId = deriveSessionId(filePath)
let projectPath: string | null = null
let model: string | null = null
let userMessages = 0
let assistantMessages = 0
let inputTokens = 0
let outputTokens = 0
let totalTokens = 0
let firstMessageAt: string | null = null
let lastMessageAt: string | null = null
for (const line of lines) {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
continue
}
const entry = asObject(parsed)
if (!entry) continue
const timestamp = asString(entry.timestamp)
if (timestamp) {
if (!firstMessageAt) firstMessageAt = timestamp
lastMessageAt = timestamp
}
const entryType = asString(entry.type)
const payload = asObject(entry.payload)
if (entryType === 'session_meta' && payload) {
const metaId = asString(payload.id)
if (metaId) sessionId = metaId
const cwd = asString(payload.cwd)
if (cwd) projectPath = cwd
const metaModel = asString(payload.model)
if (metaModel) model = metaModel
const startedAt = asString(payload.timestamp)
if (startedAt && !firstMessageAt) firstMessageAt = startedAt
continue
}
if (entryType === 'response_item' && payload) {
const payloadType = asString(payload.type)
const role = asString(payload.role)
if (payloadType === 'message' && role === 'user') userMessages++
if (payloadType === 'message' && role === 'assistant') assistantMessages++
continue
}
if (entryType === 'event_msg' && payload) {
const msgType = asString(payload.type)
if (msgType !== 'token_count') continue
const info = asObject(payload.info)
const totals = info ? asObject(info.total_token_usage) : null
if (totals) {
const inTokens = asNumber(totals.input_tokens) || 0
const cached = asNumber(totals.cached_input_tokens) || 0
const outTokens = asNumber(totals.output_tokens) || 0
const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens)
inputTokens = Math.max(inputTokens, inTokens + cached)
outputTokens = Math.max(outputTokens, outTokens)
totalTokens = Math.max(totalTokens, allTokens)
}
const limits = asObject(payload.rate_limits)
const limitName = limits ? asString(limits.limit_name) : null
if (!model && limitName) model = limitName
}
}
if (!lastMessageAt && !firstMessageAt) return null
const projectSlug = projectPath
? basename(projectPath)
: 'codex-local'
const lastMessageMs = lastMessageAt ? new Date(lastMessageAt).getTime() : 0
const isActive = lastMessageMs > 0 && (Date.now() - lastMessageMs) < ACTIVE_THRESHOLD_MS
return {
sessionId,
projectSlug,
projectPath,
model,
userMessages,
assistantMessages,
inputTokens,
outputTokens,
totalTokens,
firstMessageAt,
lastMessageAt: lastMessageAt || firstMessageAt,
isActive,
}
}
export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] {
try {
const files = listRecentCodexSessionFiles(limit)
const sessions: CodexSessionStats[] = []
for (const file of files) {
const parsed = parseCodexSessionFile(file.path)
if (parsed) sessions.push(parsed)
}
sessions.sort((a, b) => {
const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0
const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0
return bTs - aTs
})
return sessions
} catch (err) {
logger.warn({ err }, 'Failed to scan Codex sessions')
return []
}
}

132
src/lib/office-layout.ts Normal file
View File

@ -0,0 +1,132 @@
import type { Agent } from '@/store'
export type OfficeZoneType = 'engineering' | 'operations' | 'research' | 'product' | 'quality' | 'general'
export interface OfficeZoneDefinition {
id: OfficeZoneType
label: string
icon: string
accentClass: string
roleKeywords: string[]
}
export interface WorkstationAnchor {
deskId: string
seatLabel: string
row: number
col: number
x: number
y: number
}
export interface ZonedAgent {
agent: Agent
anchor: WorkstationAnchor
}
export interface OfficeZoneLayout {
zone: OfficeZoneDefinition
workers: ZonedAgent[]
}
export const OFFICE_ZONES: OfficeZoneDefinition[] = [
{
id: 'engineering',
label: 'Engineering Bay',
icon: '🧑‍💻',
accentClass: 'border-cyan-500/30 bg-cyan-500/10',
roleKeywords: ['engineer', 'dev', 'frontend', 'backend', 'fullstack', 'software'],
},
{
id: 'operations',
label: 'Operations Pod',
icon: '🛠️',
accentClass: 'border-amber-500/30 bg-amber-500/10',
roleKeywords: ['ops', 'sre', 'infra', 'platform', 'reliability'],
},
{
id: 'research',
label: 'Research Corner',
icon: '🔬',
accentClass: 'border-violet-500/30 bg-violet-500/10',
roleKeywords: ['research', 'science', 'analyst', 'ai'],
},
{
id: 'product',
label: 'Product Studio',
icon: '📐',
accentClass: 'border-emerald-500/30 bg-emerald-500/10',
roleKeywords: ['product', 'pm', 'design', 'ux', 'ui'],
},
{
id: 'quality',
label: 'Quality Lab',
icon: '🧪',
accentClass: 'border-rose-500/30 bg-rose-500/10',
roleKeywords: ['qa', 'test', 'quality'],
},
{
id: 'general',
label: 'General Workspace',
icon: '🏢',
accentClass: 'border-slate-500/30 bg-slate-500/10',
roleKeywords: [],
},
]
function normalizeRole(role: string | undefined): string {
return String(role || '').toLowerCase()
}
export function getZoneByRole(role: string | undefined): OfficeZoneDefinition {
const normalized = normalizeRole(role)
for (const zone of OFFICE_ZONES) {
if (zone.id === 'general') continue
if (zone.roleKeywords.some((keyword) => normalized.includes(keyword))) {
return zone
}
}
return OFFICE_ZONES.find((zone) => zone.id === 'general')!
}
function buildAnchor(index: number, columnCount: number): WorkstationAnchor {
const row = Math.floor(index / columnCount)
const col = index % columnCount
const rowLabel = String.fromCharCode(65 + row)
const seatLabel = `${rowLabel}${col + 1}`
return {
deskId: `desk-${seatLabel.toLowerCase()}`,
seatLabel,
row,
col,
// Useful for future absolute-position movement/collision mechanics.
x: col * 220 + 110,
y: row * 160 + 80,
}
}
export function buildOfficeLayout(agents: Agent[]): OfficeZoneLayout[] {
const zoneMap = new Map<OfficeZoneType, Agent[]>()
for (const zone of OFFICE_ZONES) zoneMap.set(zone.id, [])
for (const agent of agents) {
const zone = getZoneByRole(agent.role)
zoneMap.get(zone.id)!.push(agent)
}
const result: OfficeZoneLayout[] = []
for (const zone of OFFICE_ZONES) {
const workers = zoneMap.get(zone.id) || []
if (workers.length === 0) continue
const columns = workers.length >= 8 ? 4 : workers.length >= 4 ? 3 : 2
const zoned = workers.map((agent, i) => ({
agent,
anchor: buildAnchor(i, columns),
}))
result.push({ zone, workers: zoned })
}
return result.sort((a, b) => b.workers.length - a.workers.length)
}