Compare commits

...

3 Commits

Author SHA1 Message Date
argenis de la rosa aa9c6ded42 fix(cron): prevent one-shot jobs from re-executing indefinitely
Handle Schedule::At jobs in reschedule_after_run by disabling them
instead of rescheduling to a past timestamp. Also add a fallback in
persist_job_result to disable one-shot jobs if removal fails.

Closes #3868
2026-03-18 09:52:39 -04:00
Argenis 3d92b2a652 Merge pull request #3833 from zeroclaw-labs/fix/pairing-code-display
fix(web): display pairing code in dashboard
2026-03-17 22:16:50 -04:00
argenis de la rosa 3255051426 fix(web): display pairing code in dashboard instead of terminal-only
Fetch the current pairing code from GET /admin/paircode (localhost-only)
and display it in both the initial PairingDialog and the /pairing
management page. Users no longer need to check the terminal to find
the 6-digit code — it appears directly in the web UI.

Falls back gracefully when the admin endpoint is unreachable (e.g.
non-localhost access), showing the original "check your terminal" prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 22:01:03 -04:00
5 changed files with 142 additions and 20 deletions
+16 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+8
View File
@@ -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)
// ---------------------------------------------------------------------------
+14
View File
@@ -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]);