Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa9c6ded42 | |||
| 3d92b2a652 | |||
| 3255051426 |
+16
-2
@@ -242,6 +242,15 @@ async fn persist_job_result(
|
||||
if success {
|
||||
if let Err(e) = remove_job(config, &job.id) {
|
||||
tracing::warn!("Failed to remove one-shot cron job after success: {e}");
|
||||
// Fall back to disabling the job so it won't re-trigger.
|
||||
let _ = update_job(
|
||||
config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
enabled: Some(false),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = record_last_run(config, &job.id, finished_at, false, output);
|
||||
@@ -1038,7 +1047,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_not_deleted() {
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp).await;
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
@@ -1060,8 +1069,13 @@ mod tests {
|
||||
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
|
||||
assert!(success);
|
||||
|
||||
// After reschedule_after_run, At schedule jobs should be disabled
|
||||
// to prevent re-execution with a past next_run timestamp.
|
||||
let updated = cron::get_job(&config, &job.id).unwrap();
|
||||
assert!(updated.enabled);
|
||||
assert!(
|
||||
!updated.enabled,
|
||||
"At schedule job should be disabled after execution via reschedule"
|
||||
);
|
||||
assert_eq!(updated.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
|
||||
+67
-17
@@ -285,26 +285,41 @@ pub fn reschedule_after_run(
|
||||
output: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let bounded_output = truncate_cron_output(output);
|
||||
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
// One-shot `At` schedules have no future occurrence — record the run
|
||||
// result and disable the job so it won't be picked up again.
|
||||
if matches!(job.schedule, Schedule::At { .. }) {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET enabled = 0, last_run = ?1, last_status = ?2, last_output = ?3
|
||||
WHERE id = ?4",
|
||||
params![now.to_rfc3339(), status, bounded_output, job.id],
|
||||
)
|
||||
.context("Failed to disable completed one-shot cron job")?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_run(
|
||||
@@ -852,6 +867,41 @@ mod tests {
|
||||
assert!(stored.len() <= MAX_CRON_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, true, "done").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job_on_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, false, "failed").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule even on failure"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("error"));
|
||||
assert_eq!(stored.last_output.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_truncates_last_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+37
-1
@@ -16,6 +16,7 @@ import Pairing from './pages/Pairing';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { DraftContext, useDraftStore } from './hooks/useDraft';
|
||||
import { setLocale, type Locale } from './lib/i18n';
|
||||
import { getAdminPairCode } from './lib/api';
|
||||
|
||||
// Locale context
|
||||
interface LocaleContextType {
|
||||
@@ -89,6 +90,26 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [displayCode, setDisplayCode] = useState<string | null>(null);
|
||||
const [codeLoading, setCodeLoading] = useState(true);
|
||||
|
||||
// Fetch the current pairing code from the admin endpoint (localhost only)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAdminPairCode()
|
||||
.then((data) => {
|
||||
if (!cancelled && data.pairing_code) {
|
||||
setDisplayCode(data.pairing_code);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Admin endpoint not reachable (non-localhost) — user must check terminal
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCodeLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -120,8 +141,23 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
style={{ boxShadow: '0 0 30px rgba(0,128,255,0.3)' }}
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-gradient-blue mb-2">ZeroClaw</h1>
|
||||
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
|
||||
{displayCode ? (
|
||||
<p className="text-[#556080] text-sm">Your pairing code</p>
|
||||
) : (
|
||||
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show the pairing code if available (localhost) */}
|
||||
{!codeLoading && displayCode && (
|
||||
<div className="mb-6 p-4 rounded-xl text-center" style={{ background: 'rgba(0,128,255,0.08)', border: '1px solid rgba(0,128,255,0.2)' }}>
|
||||
<div className="text-4xl font-mono font-bold tracking-[0.4em] text-white py-2">
|
||||
{displayCode}
|
||||
</div>
|
||||
<p className="text-[#556080] text-xs mt-2">Enter this code below or on another device</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -93,6 +93,14 @@ export async function pair(code: string): Promise<{ token: string }> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAdminPairCode(): Promise<{ pairing_code: string | null; pairing_required: boolean }> {
|
||||
const response = await fetch('/admin/paircode');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch pairing code (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{ pairing_code: string | null; pairing_required: boolean }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public health (no auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAdminPairCode } from '../lib/api';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
@@ -33,6 +34,19 @@ export default function Pairing() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch the current pairing code on mount (if one is active)
|
||||
useEffect(() => {
|
||||
getAdminPairCode()
|
||||
.then((data) => {
|
||||
if (data.pairing_code) {
|
||||
setPairingCode(data.pairing_code);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Admin endpoint not reachable — code will show after clicking "Pair New Device"
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [fetchDevices]);
|
||||
|
||||
Reference in New Issue
Block a user