Spaces:
Runtime error
Runtime error
Nyk commited on
Commit ·
250a974
1
Parent(s): e7f2128
fix: resolve cron calendar and auth regressions from open issues
Browse files- src/components/panels/agent-detail-tabs.tsx +6 -1
- src/components/panels/cron-management-panel.tsx +123 -49
- src/components/panels/memory-browser-panel.tsx +3 -0
- src/components/panels/super-admin-panel.tsx +3 -0
- src/lib/__tests__/auth.test.ts +19 -0
- src/lib/__tests__/cron-occurrences.test.ts +44 -0
- src/lib/auth.ts +21 -2
- src/lib/cron-occurrences.ts +139 -0
- src/proxy.ts +22 -3
src/components/panels/agent-detail-tabs.tsx
CHANGED
|
@@ -510,7 +510,12 @@ export function MemoryTab({
|
|
| 510 |
return (
|
| 511 |
<div className="p-6 space-y-4">
|
| 512 |
<div className="flex justify-between items-center">
|
| 513 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
<div className="flex gap-2">
|
| 515 |
{!editing && (
|
| 516 |
<>
|
|
|
|
| 510 |
return (
|
| 511 |
<div className="p-6 space-y-4">
|
| 512 |
<div className="flex justify-between items-center">
|
| 513 |
+
<div>
|
| 514 |
+
<h4 className="text-lg font-medium text-foreground">Working Memory</h4>
|
| 515 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 516 |
+
Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files.
|
| 517 |
+
</p>
|
| 518 |
+
</div>
|
| 519 |
<div className="flex gap-2">
|
| 520 |
{!editing && (
|
| 521 |
<>
|
src/components/panels/cron-management-panel.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useState, useEffect, useCallback } from 'react'
|
| 4 |
import { useMissionControl, CronJob } from '@/store'
|
|
|
|
| 5 |
|
| 6 |
interface NewJobForm {
|
| 7 |
name: string
|
|
@@ -56,6 +57,7 @@ export function CronManagementPanel() {
|
|
| 56 |
const [availableModels, setAvailableModels] = useState<string[]>([])
|
| 57 |
const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
|
| 58 |
const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
|
|
|
|
| 59 |
const [searchQuery, setSearchQuery] = useState('')
|
| 60 |
const [agentFilter, setAgentFilter] = useState('all')
|
| 61 |
const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
|
@@ -307,39 +309,69 @@ export function CronManagementPanel() {
|
|
| 307 |
return matchesQuery && matchesAgent && matchesState
|
| 308 |
})
|
| 309 |
|
| 310 |
-
const agendaJobs = [...filteredJobs].sort((a, b) => {
|
| 311 |
-
const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY
|
| 312 |
-
const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY
|
| 313 |
-
return aRun - bRun
|
| 314 |
-
})
|
| 315 |
-
|
| 316 |
const dayStart = startOfDay(calendarDate)
|
| 317 |
const dayEnd = addDays(dayStart, 1)
|
| 318 |
-
const dayJobs = filteredJobs
|
| 319 |
-
.filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime())
|
| 320 |
-
.sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
|
| 321 |
|
| 322 |
const weekStart = getWeekStart(calendarDate)
|
| 323 |
const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
|
| 324 |
-
const jobsByWeekDay = weekDays.map((date) => {
|
| 325 |
-
const start = startOfDay(date).getTime()
|
| 326 |
-
const end = addDays(date, 1).getTime()
|
| 327 |
-
const jobs = filteredJobs
|
| 328 |
-
.filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
|
| 329 |
-
.sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
|
| 330 |
-
return { date, jobs }
|
| 331 |
-
})
|
| 332 |
|
| 333 |
const monthGridStart = getMonthStartGrid(calendarDate)
|
| 334 |
const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
const moveCalendar = (direction: -1 | 1) => {
|
| 345 |
setCalendarDate((prev) => {
|
|
@@ -392,7 +424,7 @@ export function CronManagementPanel() {
|
|
| 392 |
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 393 |
<div>
|
| 394 |
<h2 className="text-xl font-semibold">Calendar View</h2>
|
| 395 |
-
<p className="text-sm text-muted-foreground">
|
| 396 |
</div>
|
| 397 |
<div className="flex items-center gap-2">
|
| 398 |
<button
|
|
@@ -466,23 +498,27 @@ export function CronManagementPanel() {
|
|
| 466 |
{calendarView === 'agenda' && (
|
| 467 |
<div className="border border-border rounded-lg overflow-hidden">
|
| 468 |
<div className="max-h-80 overflow-y-auto divide-y divide-border">
|
| 469 |
-
{
|
| 470 |
<div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
|
| 471 |
) : (
|
| 472 |
-
|
| 473 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
<div>
|
| 475 |
-
<div className="font-medium text-foreground">{job.name}</div>
|
| 476 |
<div className="text-xs text-muted-foreground">
|
| 477 |
-
{job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} · {job.schedule}
|
| 478 |
</div>
|
| 479 |
</div>
|
| 480 |
<div className="text-sm text-muted-foreground">
|
| 481 |
-
{
|
| 482 |
</div>
|
| 483 |
-
</
|
| 484 |
))
|
| 485 |
-
)}
|
| 486 |
</div>
|
| 487 |
</div>
|
| 488 |
)}
|
|
@@ -493,13 +529,17 @@ export function CronManagementPanel() {
|
|
| 493 |
<div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
|
| 494 |
) : (
|
| 495 |
<div className="space-y-2">
|
| 496 |
-
{dayJobs.map((
|
| 497 |
-
<
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
<div className="text-xs text-muted-foreground">
|
| 500 |
-
{
|
| 501 |
</div>
|
| 502 |
-
</
|
| 503 |
))}
|
| 504 |
</div>
|
| 505 |
)}
|
|
@@ -509,21 +549,25 @@ export function CronManagementPanel() {
|
|
| 509 |
{calendarView === 'week' && (
|
| 510 |
<div className="grid grid-cols-1 md:grid-cols-7 gap-2">
|
| 511 |
{jobsByWeekDay.map(({ date, jobs }) => (
|
| 512 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
<div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
|
| 514 |
{date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
|
| 515 |
</div>
|
| 516 |
<div className="space-y-1">
|
| 517 |
-
{jobs.slice(0, 4).map((
|
| 518 |
-
<div key={`week-job-${job.id || job.name}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={job.name}>
|
| 519 |
-
{
|
| 520 |
</div>
|
| 521 |
))}
|
| 522 |
{jobs.length > 4 && (
|
| 523 |
<div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
|
| 524 |
)}
|
| 525 |
</div>
|
| 526 |
-
</
|
| 527 |
))}
|
| 528 |
</div>
|
| 529 |
)}
|
|
@@ -535,15 +579,16 @@ export function CronManagementPanel() {
|
|
| 535 |
return (
|
| 536 |
<div
|
| 537 |
key={`month-${date.toISOString()}`}
|
| 538 |
-
|
|
|
|
| 539 |
>
|
| 540 |
<div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
|
| 541 |
{date.getDate()}
|
| 542 |
</div>
|
| 543 |
<div className="space-y-1">
|
| 544 |
-
{jobs.slice(0, 2).map((
|
| 545 |
-
<div key={`month-job-${job.id || job.name}`} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-foreground truncate" title={job.name}>
|
| 546 |
-
{job.name}
|
| 547 |
</div>
|
| 548 |
))}
|
| 549 |
{jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
|
|
@@ -553,6 +598,35 @@ export function CronManagementPanel() {
|
|
| 553 |
})}
|
| 554 |
</div>
|
| 555 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
</div>
|
| 557 |
</div>
|
| 558 |
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useState, useEffect, useCallback, useMemo } from 'react'
|
| 4 |
import { useMissionControl, CronJob } from '@/store'
|
| 5 |
+
import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
|
| 6 |
|
| 7 |
interface NewJobForm {
|
| 8 |
name: string
|
|
|
|
| 57 |
const [availableModels, setAvailableModels] = useState<string[]>([])
|
| 58 |
const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
|
| 59 |
const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
|
| 60 |
+
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date>(startOfDay(new Date()))
|
| 61 |
const [searchQuery, setSearchQuery] = useState('')
|
| 62 |
const [agentFilter, setAgentFilter] = useState('all')
|
| 63 |
const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
|
|
|
| 309 |
return matchesQuery && matchesAgent && matchesState
|
| 310 |
})
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
const dayStart = startOfDay(calendarDate)
|
| 313 |
const dayEnd = addDays(dayStart, 1)
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
const weekStart = getWeekStart(calendarDate)
|
| 316 |
const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
const monthGridStart = getMonthStartGrid(calendarDate)
|
| 319 |
const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
|
| 320 |
+
|
| 321 |
+
const calendarBounds = useMemo(() => {
|
| 322 |
+
if (calendarView === 'day') {
|
| 323 |
+
return { startMs: dayStart.getTime(), endMs: dayEnd.getTime() }
|
| 324 |
+
}
|
| 325 |
+
if (calendarView === 'week') {
|
| 326 |
+
return { startMs: weekStart.getTime(), endMs: addDays(weekStart, 7).getTime() }
|
| 327 |
+
}
|
| 328 |
+
if (calendarView === 'month') {
|
| 329 |
+
return { startMs: monthGridStart.getTime(), endMs: addDays(monthGridStart, 42).getTime() }
|
| 330 |
+
}
|
| 331 |
+
const agendaStart = Date.now()
|
| 332 |
+
return { startMs: agendaStart, endMs: addDays(startOfDay(new Date()), 30).getTime() }
|
| 333 |
+
}, [calendarView, dayEnd, dayStart, monthGridStart, weekStart])
|
| 334 |
+
|
| 335 |
+
const calendarOccurrences = useMemo(() => {
|
| 336 |
+
const rows: Array<{ job: CronJob; atMs: number; dayKey: string }> = []
|
| 337 |
+
for (const job of filteredJobs) {
|
| 338 |
+
const occurrences = getCronOccurrences(job.schedule, calendarBounds.startMs, calendarBounds.endMs, 1000)
|
| 339 |
+
for (const occurrence of occurrences) {
|
| 340 |
+
rows.push({ job, atMs: occurrence.atMs, dayKey: occurrence.dayKey })
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
if (occurrences.length === 0 && typeof job.nextRun === 'number' && job.nextRun >= calendarBounds.startMs && job.nextRun < calendarBounds.endMs) {
|
| 344 |
+
rows.push({ job, atMs: job.nextRun, dayKey: buildDayKey(new Date(job.nextRun)) })
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
rows.sort((a, b) => a.atMs - b.atMs)
|
| 349 |
+
return rows
|
| 350 |
+
}, [calendarBounds.endMs, calendarBounds.startMs, filteredJobs])
|
| 351 |
+
|
| 352 |
+
const occurrencesByDay = useMemo(() => {
|
| 353 |
+
const dayMap = new Map<string, Array<{ job: CronJob; atMs: number }>>()
|
| 354 |
+
for (const row of calendarOccurrences) {
|
| 355 |
+
const existing = dayMap.get(row.dayKey) || []
|
| 356 |
+
existing.push({ job: row.job, atMs: row.atMs })
|
| 357 |
+
dayMap.set(row.dayKey, existing)
|
| 358 |
+
}
|
| 359 |
+
return dayMap
|
| 360 |
+
}, [calendarOccurrences])
|
| 361 |
+
|
| 362 |
+
const dayJobs = occurrencesByDay.get(buildDayKey(dayStart)) || []
|
| 363 |
+
|
| 364 |
+
const jobsByWeekDay = weekDays.map((date) => ({
|
| 365 |
+
date,
|
| 366 |
+
jobs: occurrencesByDay.get(buildDayKey(date)) || [],
|
| 367 |
+
}))
|
| 368 |
+
|
| 369 |
+
const jobsByMonthDay = monthDays.map((date) => ({
|
| 370 |
+
date,
|
| 371 |
+
jobs: occurrencesByDay.get(buildDayKey(date)) || [],
|
| 372 |
+
}))
|
| 373 |
+
|
| 374 |
+
const selectedDayJobs = occurrencesByDay.get(buildDayKey(selectedCalendarDate)) || []
|
| 375 |
|
| 376 |
const moveCalendar = (direction: -1 | 1) => {
|
| 377 |
setCalendarDate((prev) => {
|
|
|
|
| 424 |
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 425 |
<div>
|
| 426 |
<h2 className="text-xl font-semibold">Calendar View</h2>
|
| 427 |
+
<p className="text-sm text-muted-foreground">Interactive schedule across all matching cron jobs</p>
|
| 428 |
</div>
|
| 429 |
<div className="flex items-center gap-2">
|
| 430 |
<button
|
|
|
|
| 498 |
{calendarView === 'agenda' && (
|
| 499 |
<div className="border border-border rounded-lg overflow-hidden">
|
| 500 |
<div className="max-h-80 overflow-y-auto divide-y divide-border">
|
| 501 |
+
{calendarOccurrences.length === 0 ? (
|
| 502 |
<div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
|
| 503 |
) : (
|
| 504 |
+
calendarOccurrences.map((row) => (
|
| 505 |
+
<button
|
| 506 |
+
key={`agenda-${row.job.id || row.job.name}-${row.atMs}`}
|
| 507 |
+
onClick={() => handleJobSelect(row.job)}
|
| 508 |
+
className="w-full p-3 text-left flex flex-col md:flex-row md:items-center md:justify-between gap-2 hover:bg-secondary transition-colors"
|
| 509 |
+
>
|
| 510 |
<div>
|
| 511 |
+
<div className="font-medium text-foreground">{row.job.name}</div>
|
| 512 |
<div className="text-xs text-muted-foreground">
|
| 513 |
+
{row.job.agentId || 'system'} · {row.job.enabled ? 'enabled' : 'disabled'} · {row.job.schedule}
|
| 514 |
</div>
|
| 515 |
</div>
|
| 516 |
<div className="text-sm text-muted-foreground">
|
| 517 |
+
{new Date(row.atMs).toLocaleString()}
|
| 518 |
</div>
|
| 519 |
+
</button>
|
| 520 |
))
|
| 521 |
+
)}
|
| 522 |
</div>
|
| 523 |
</div>
|
| 524 |
)}
|
|
|
|
| 529 |
<div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
|
| 530 |
) : (
|
| 531 |
<div className="space-y-2">
|
| 532 |
+
{dayJobs.map((row) => (
|
| 533 |
+
<button
|
| 534 |
+
key={`day-${row.job.id || row.job.name}-${row.atMs}`}
|
| 535 |
+
onClick={() => handleJobSelect(row.job)}
|
| 536 |
+
className="w-full p-2 rounded border border-border bg-secondary/40 hover:bg-secondary transition-colors text-left"
|
| 537 |
+
>
|
| 538 |
+
<div className="text-sm font-medium text-foreground">{row.job.name}</div>
|
| 539 |
<div className="text-xs text-muted-foreground">
|
| 540 |
+
{new Date(row.atMs).toLocaleTimeString()} · {row.job.agentId || 'system'} · {row.job.enabled ? 'enabled' : 'disabled'}
|
| 541 |
</div>
|
| 542 |
+
</button>
|
| 543 |
))}
|
| 544 |
</div>
|
| 545 |
)}
|
|
|
|
| 549 |
{calendarView === 'week' && (
|
| 550 |
<div className="grid grid-cols-1 md:grid-cols-7 gap-2">
|
| 551 |
{jobsByWeekDay.map(({ date, jobs }) => (
|
| 552 |
+
<button
|
| 553 |
+
key={`week-${date.toISOString()}`}
|
| 554 |
+
onClick={() => setSelectedCalendarDate(startOfDay(date))}
|
| 555 |
+
className={`border border-border rounded-lg p-2 min-h-36 text-left ${isSameDay(date, selectedCalendarDate) ? 'bg-primary/10 border-primary/40' : 'hover:bg-secondary/50'}`}
|
| 556 |
+
>
|
| 557 |
<div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
|
| 558 |
{date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
|
| 559 |
</div>
|
| 560 |
<div className="space-y-1">
|
| 561 |
+
{jobs.slice(0, 4).map((row) => (
|
| 562 |
+
<div key={`week-job-${row.job.id || row.job.name}-${row.atMs}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={row.job.name}>
|
| 563 |
+
{new Date(row.atMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {row.job.name}
|
| 564 |
</div>
|
| 565 |
))}
|
| 566 |
{jobs.length > 4 && (
|
| 567 |
<div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
|
| 568 |
)}
|
| 569 |
</div>
|
| 570 |
+
</button>
|
| 571 |
))}
|
| 572 |
</div>
|
| 573 |
)}
|
|
|
|
| 579 |
return (
|
| 580 |
<div
|
| 581 |
key={`month-${date.toISOString()}`}
|
| 582 |
+
onClick={() => setSelectedCalendarDate(startOfDay(date))}
|
| 583 |
+
className={`border border-border rounded-lg p-2 min-h-24 cursor-pointer ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'} ${isSameDay(date, selectedCalendarDate) ? 'border-primary/40 bg-primary/10' : 'hover:bg-secondary/50'}`}
|
| 584 |
>
|
| 585 |
<div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
|
| 586 |
{date.getDate()}
|
| 587 |
</div>
|
| 588 |
<div className="space-y-1">
|
| 589 |
+
{jobs.slice(0, 2).map((row) => (
|
| 590 |
+
<div key={`month-job-${row.job.id || row.job.name}-${row.atMs}`} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-foreground truncate" title={row.job.name}>
|
| 591 |
+
{row.job.name}
|
| 592 |
</div>
|
| 593 |
))}
|
| 594 |
{jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
|
|
|
|
| 598 |
})}
|
| 599 |
</div>
|
| 600 |
)}
|
| 601 |
+
|
| 602 |
+
{calendarView !== 'agenda' && (
|
| 603 |
+
<div className="border border-border rounded-lg p-3">
|
| 604 |
+
<div className="flex items-center justify-between mb-2">
|
| 605 |
+
<h3 className="text-sm font-medium text-foreground">
|
| 606 |
+
{selectedCalendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })}
|
| 607 |
+
</h3>
|
| 608 |
+
<span className="text-xs text-muted-foreground">{selectedDayJobs.length} jobs</span>
|
| 609 |
+
</div>
|
| 610 |
+
{selectedDayJobs.length === 0 ? (
|
| 611 |
+
<div className="text-sm text-muted-foreground">No jobs scheduled on this date.</div>
|
| 612 |
+
) : (
|
| 613 |
+
<div className="space-y-2">
|
| 614 |
+
{selectedDayJobs.map((row) => (
|
| 615 |
+
<button
|
| 616 |
+
key={`selected-day-${row.job.id || row.job.name}-${row.atMs}`}
|
| 617 |
+
onClick={() => handleJobSelect(row.job)}
|
| 618 |
+
className="w-full text-left p-2 rounded border border-border bg-secondary/40 hover:bg-secondary transition-colors"
|
| 619 |
+
>
|
| 620 |
+
<div className="text-sm font-medium text-foreground">{row.job.name}</div>
|
| 621 |
+
<div className="text-xs text-muted-foreground">
|
| 622 |
+
{new Date(row.atMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} · {row.job.agentId || 'system'}
|
| 623 |
+
</div>
|
| 624 |
+
</button>
|
| 625 |
+
))}
|
| 626 |
+
</div>
|
| 627 |
+
)}
|
| 628 |
+
</div>
|
| 629 |
+
)}
|
| 630 |
</div>
|
| 631 |
</div>
|
| 632 |
|
src/components/panels/memory-browser-panel.tsx
CHANGED
|
@@ -353,6 +353,9 @@ export function MemoryBrowserPanel() {
|
|
| 353 |
? 'Browse and manage local knowledge files and memory'
|
| 354 |
: 'Explore knowledge files and memory structure'}
|
| 355 |
</p>
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
{/* Tab Navigation */}
|
| 358 |
<div className="flex gap-2 mt-4">
|
|
|
|
| 353 |
? 'Browse and manage local knowledge files and memory'
|
| 354 |
: 'Explore knowledge files and memory structure'}
|
| 355 |
</p>
|
| 356 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 357 |
+
This page shows all workspace memory files. The agent profile Memory tab only edits that single agent's working memory.
|
| 358 |
+
</p>
|
| 359 |
|
| 360 |
{/* Tab Navigation */}
|
| 361 |
<div className="flex gap-2 mt-4">
|
src/components/panels/super-admin-panel.tsx
CHANGED
|
@@ -461,6 +461,9 @@ export function SuperAdminPanel() {
|
|
| 461 |
</button>
|
| 462 |
{createExpanded && (
|
| 463 |
<div className="p-4 space-y-3">
|
|
|
|
|
|
|
|
|
|
| 464 |
{gatewayLoadError && (
|
| 465 |
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
|
| 466 |
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
|
|
|
|
| 461 |
</button>
|
| 462 |
{createExpanded && (
|
| 463 |
<div className="p-4 space-y-3">
|
| 464 |
+
<div className="text-xs text-muted-foreground">
|
| 465 |
+
Add a new workspace/client instance here. Fill the form below and click <span className="text-foreground font-medium">Create + Queue</span>.
|
| 466 |
+
</div>
|
| 467 |
{gatewayLoadError && (
|
| 468 |
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
|
| 469 |
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value.
|
src/lib/__tests__/auth.test.ts
CHANGED
|
@@ -105,4 +105,23 @@ describe('requireRole', () => {
|
|
| 105 |
)
|
| 106 |
expect(result.user).toBeDefined()
|
| 107 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
})
|
|
|
|
| 105 |
)
|
| 106 |
expect(result.user).toBeDefined()
|
| 107 |
})
|
| 108 |
+
|
| 109 |
+
it('accepts Authorization Bearer API key', () => {
|
| 110 |
+
const result = requireRole(
|
| 111 |
+
makeRequest({ authorization: 'Bearer test-api-key-secret' }),
|
| 112 |
+
'admin',
|
| 113 |
+
)
|
| 114 |
+
expect(result.user).toBeDefined()
|
| 115 |
+
expect(result.user!.username).toBe('api')
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
it('rejects API key auth when API_KEY is not configured', () => {
|
| 119 |
+
process.env = { ...originalEnv, API_KEY: '' }
|
| 120 |
+
const result = requireRole(
|
| 121 |
+
makeRequest({ 'x-api-key': 'test-api-key-secret' }),
|
| 122 |
+
'viewer',
|
| 123 |
+
)
|
| 124 |
+
expect(result.status).toBe(401)
|
| 125 |
+
expect(result.user).toBeUndefined()
|
| 126 |
+
})
|
| 127 |
})
|
src/lib/__tests__/cron-occurrences.test.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest'
|
| 2 |
+
import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
|
| 3 |
+
|
| 4 |
+
describe('buildDayKey', () => {
|
| 5 |
+
it('formats YYYY-MM-DD in local time', () => {
|
| 6 |
+
const date = new Date(2026, 2, 4, 9, 15, 0, 0)
|
| 7 |
+
expect(buildDayKey(date)).toBe('2026-03-04')
|
| 8 |
+
})
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
describe('getCronOccurrences', () => {
|
| 12 |
+
it('expands daily schedule across range', () => {
|
| 13 |
+
const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
|
| 14 |
+
const end = new Date(2026, 2, 5, 0, 0, 0, 0).getTime()
|
| 15 |
+
const rows = getCronOccurrences('0 0 * * *', start, end)
|
| 16 |
+
expect(rows).toHaveLength(4)
|
| 17 |
+
expect(rows.map((r) => r.dayKey)).toEqual([
|
| 18 |
+
'2026-03-01',
|
| 19 |
+
'2026-03-02',
|
| 20 |
+
'2026-03-03',
|
| 21 |
+
'2026-03-04',
|
| 22 |
+
])
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
it('supports step values', () => {
|
| 26 |
+
const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
|
| 27 |
+
const end = new Date(2026, 2, 1, 3, 0, 0, 0).getTime()
|
| 28 |
+
const rows = getCronOccurrences('*/30 * * * *', start, end)
|
| 29 |
+
expect(rows).toHaveLength(6)
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
it('ignores OpenClaw timezone suffix in display schedule', () => {
|
| 33 |
+
const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
|
| 34 |
+
const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
|
| 35 |
+
const rows = getCronOccurrences('0 6 * * * (UTC)', start, end)
|
| 36 |
+
expect(rows).toHaveLength(1)
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
it('returns empty list for invalid cron', () => {
|
| 40 |
+
const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime()
|
| 41 |
+
const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime()
|
| 42 |
+
expect(getCronOccurrences('invalid', start, end)).toEqual([])
|
| 43 |
+
})
|
| 44 |
+
})
|
src/lib/auth.ts
CHANGED
|
@@ -277,8 +277,9 @@ export function getUserFromRequest(request: Request): User | null {
|
|
| 277 |
}
|
| 278 |
|
| 279 |
// Check API key - return synthetic user
|
| 280 |
-
const
|
| 281 |
-
|
|
|
|
| 282 |
return {
|
| 283 |
id: 0,
|
| 284 |
username: 'api',
|
|
@@ -294,6 +295,24 @@ export function getUserFromRequest(request: Request): User | null {
|
|
| 294 |
return null
|
| 295 |
}
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
/**
|
| 298 |
* Role hierarchy levels for access control.
|
| 299 |
* viewer < operator < admin
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
// Check API key - return synthetic user
|
| 280 |
+
const configuredApiKey = (process.env.API_KEY || '').trim()
|
| 281 |
+
const apiKey = extractApiKeyFromHeaders(request.headers)
|
| 282 |
+
if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) {
|
| 283 |
return {
|
| 284 |
id: 0,
|
| 285 |
username: 'api',
|
|
|
|
| 295 |
return null
|
| 296 |
}
|
| 297 |
|
| 298 |
+
function extractApiKeyFromHeaders(headers: Headers): string | null {
|
| 299 |
+
const direct = (headers.get('x-api-key') || '').trim()
|
| 300 |
+
if (direct) return direct
|
| 301 |
+
|
| 302 |
+
const authorization = (headers.get('authorization') || '').trim()
|
| 303 |
+
if (!authorization) return null
|
| 304 |
+
|
| 305 |
+
const [scheme, ...rest] = authorization.split(/\s+/)
|
| 306 |
+
if (!scheme || rest.length === 0) return null
|
| 307 |
+
|
| 308 |
+
const normalized = scheme.toLowerCase()
|
| 309 |
+
if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
|
| 310 |
+
return rest.join(' ').trim() || null
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return null
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
/**
|
| 317 |
* Role hierarchy levels for access control.
|
| 318 |
* viewer < operator < admin
|
src/lib/cron-occurrences.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface CronOccurrence {
|
| 2 |
+
atMs: number
|
| 3 |
+
dayKey: string
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
interface ParsedField {
|
| 7 |
+
any: boolean
|
| 8 |
+
matches: (value: number) => boolean
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface ParsedCron {
|
| 12 |
+
minute: ParsedField
|
| 13 |
+
hour: ParsedField
|
| 14 |
+
dayOfMonth: ParsedField
|
| 15 |
+
month: ParsedField
|
| 16 |
+
dayOfWeek: ParsedField
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function normalizeCronExpression(raw: string): string {
|
| 20 |
+
const trimmed = raw.trim()
|
| 21 |
+
const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/)
|
| 22 |
+
return (tzSuffixMatch?.[1] || trimmed).trim()
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function parseToken(token: string, min: number, max: number): { any: boolean; values: Set<number> } {
|
| 26 |
+
const valueSet = new Set<number>()
|
| 27 |
+
const trimmed = token.trim()
|
| 28 |
+
if (trimmed === '*') {
|
| 29 |
+
for (let i = min; i <= max; i += 1) valueSet.add(i)
|
| 30 |
+
return { any: true, values: valueSet }
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
for (const part of trimmed.split(',')) {
|
| 34 |
+
const section = part.trim()
|
| 35 |
+
if (!section) continue
|
| 36 |
+
|
| 37 |
+
const [rangePart, stepPart] = section.split('/')
|
| 38 |
+
const step = stepPart ? Number(stepPart) : 1
|
| 39 |
+
if (!Number.isFinite(step) || step <= 0) continue
|
| 40 |
+
|
| 41 |
+
if (rangePart === '*') {
|
| 42 |
+
for (let i = min; i <= max; i += step) valueSet.add(i)
|
| 43 |
+
continue
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (rangePart.includes('-')) {
|
| 47 |
+
const [fromRaw, toRaw] = rangePart.split('-')
|
| 48 |
+
const from = Number(fromRaw)
|
| 49 |
+
const to = Number(toRaw)
|
| 50 |
+
if (!Number.isFinite(from) || !Number.isFinite(to)) continue
|
| 51 |
+
const start = Math.max(min, Math.min(max, from))
|
| 52 |
+
const end = Math.max(min, Math.min(max, to))
|
| 53 |
+
for (let i = start; i <= end; i += step) valueSet.add(i)
|
| 54 |
+
continue
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const single = Number(rangePart)
|
| 58 |
+
if (!Number.isFinite(single)) continue
|
| 59 |
+
if (single >= min && single <= max) valueSet.add(single)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return { any: false, values: valueSet }
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function parseField(token: string, min: number, max: number): ParsedField {
|
| 66 |
+
const parsed = parseToken(token, min, max)
|
| 67 |
+
return {
|
| 68 |
+
any: parsed.any,
|
| 69 |
+
matches: (value: number) => parsed.values.has(value),
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function parseCron(raw: string): ParsedCron | null {
|
| 74 |
+
const normalized = normalizeCronExpression(raw)
|
| 75 |
+
const parts = normalized.split(/\s+/).filter(Boolean)
|
| 76 |
+
if (parts.length !== 5) return null
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
minute: parseField(parts[0], 0, 59),
|
| 80 |
+
hour: parseField(parts[1], 0, 23),
|
| 81 |
+
dayOfMonth: parseField(parts[2], 1, 31),
|
| 82 |
+
month: parseField(parts[3], 1, 12),
|
| 83 |
+
dayOfWeek: parseField(parts[4], 0, 6),
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function matchesDay(parsed: ParsedCron, date: Date): boolean {
|
| 88 |
+
const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate())
|
| 89 |
+
const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay())
|
| 90 |
+
|
| 91 |
+
if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true
|
| 92 |
+
if (parsed.dayOfMonth.any) return dayOfWeekMatches
|
| 93 |
+
if (parsed.dayOfWeek.any) return dayOfMonthMatches
|
| 94 |
+
return dayOfMonthMatches || dayOfWeekMatches
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export function buildDayKey(date: Date): string {
|
| 98 |
+
const year = date.getFullYear()
|
| 99 |
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
| 100 |
+
const day = String(date.getDate()).padStart(2, '0')
|
| 101 |
+
return `${year}-${month}-${day}`
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export function getCronOccurrences(
|
| 105 |
+
schedule: string,
|
| 106 |
+
rangeStartMs: number,
|
| 107 |
+
rangeEndMs: number,
|
| 108 |
+
max = 1000
|
| 109 |
+
): CronOccurrence[] {
|
| 110 |
+
if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return []
|
| 111 |
+
if (rangeEndMs <= rangeStartMs || max <= 0) return []
|
| 112 |
+
|
| 113 |
+
const parsed = parseCron(schedule)
|
| 114 |
+
if (!parsed) return []
|
| 115 |
+
|
| 116 |
+
const occurrences: CronOccurrence[] = []
|
| 117 |
+
const cursor = new Date(rangeStartMs)
|
| 118 |
+
cursor.setSeconds(0, 0)
|
| 119 |
+
if (cursor.getTime() < rangeStartMs) {
|
| 120 |
+
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
while (cursor.getTime() < rangeEndMs && occurrences.length < max) {
|
| 124 |
+
if (
|
| 125 |
+
parsed.month.matches(cursor.getMonth() + 1) &&
|
| 126 |
+
matchesDay(parsed, cursor) &&
|
| 127 |
+
parsed.hour.matches(cursor.getHours()) &&
|
| 128 |
+
parsed.minute.matches(cursor.getMinutes())
|
| 129 |
+
) {
|
| 130 |
+
occurrences.push({
|
| 131 |
+
atMs: cursor.getTime(),
|
| 132 |
+
dayKey: buildDayKey(cursor),
|
| 133 |
+
})
|
| 134 |
+
}
|
| 135 |
+
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
return occurrences
|
| 139 |
+
}
|
src/proxy.ts
CHANGED
|
@@ -52,6 +52,22 @@ function applySecurityHeaders(response: NextResponse): NextResponse {
|
|
| 52 |
return response
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
export function proxy(request: NextRequest) {
|
| 56 |
// Network access control.
|
| 57 |
// In production: default-deny unless explicitly allowed.
|
|
@@ -63,7 +79,8 @@ export function proxy(request: NextRequest) {
|
|
| 63 |
.map((s) => s.trim())
|
| 64 |
.filter(Boolean)
|
| 65 |
|
| 66 |
-
const
|
|
|
|
| 67 |
|
| 68 |
if (!isAllowedHost) {
|
| 69 |
return new NextResponse('Forbidden', { status: 403 })
|
|
@@ -97,8 +114,10 @@ export function proxy(request: NextRequest) {
|
|
| 97 |
|
| 98 |
// API routes: accept session cookie OR API key
|
| 99 |
if (pathname.startsWith('/api/')) {
|
| 100 |
-
const
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
return applySecurityHeaders(NextResponse.next())
|
| 103 |
}
|
| 104 |
|
|
|
|
| 52 |
return response
|
| 53 |
}
|
| 54 |
|
| 55 |
+
function extractApiKeyFromRequest(request: NextRequest): string {
|
| 56 |
+
const direct = (request.headers.get('x-api-key') || '').trim()
|
| 57 |
+
if (direct) return direct
|
| 58 |
+
|
| 59 |
+
const authorization = (request.headers.get('authorization') || '').trim()
|
| 60 |
+
if (!authorization) return ''
|
| 61 |
+
|
| 62 |
+
const [scheme, ...rest] = authorization.split(/\s+/)
|
| 63 |
+
if (!scheme || rest.length === 0) return ''
|
| 64 |
+
const normalized = scheme.toLowerCase()
|
| 65 |
+
if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') {
|
| 66 |
+
return rest.join(' ').trim()
|
| 67 |
+
}
|
| 68 |
+
return ''
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
export function proxy(request: NextRequest) {
|
| 72 |
// Network access control.
|
| 73 |
// In production: default-deny unless explicitly allowed.
|
|
|
|
| 79 |
.map((s) => s.trim())
|
| 80 |
.filter(Boolean)
|
| 81 |
|
| 82 |
+
const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0
|
| 83 |
+
const isAllowedHost = !enforceAllowlist || allowedPatterns.some((p) => hostMatches(p, hostName))
|
| 84 |
|
| 85 |
if (!isAllowedHost) {
|
| 86 |
return new NextResponse('Forbidden', { status: 403 })
|
|
|
|
| 114 |
|
| 115 |
// API routes: accept session cookie OR API key
|
| 116 |
if (pathname.startsWith('/api/')) {
|
| 117 |
+
const configuredApiKey = (process.env.API_KEY || '').trim()
|
| 118 |
+
const apiKey = extractApiKeyFromRequest(request)
|
| 119 |
+
const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
|
| 120 |
+
if (sessionToken || hasValidApiKey) {
|
| 121 |
return applySecurityHeaders(NextResponse.next())
|
| 122 |
}
|
| 123 |
|