Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion agent-node/src/chainSubmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ function getConnectedWallet(chainId) {

// ─── Public API ──────────────────────────────────────────────────────────────

// Serializes all on-chain submissions from the agent signer. Concurrent
// extensions (e.g. one PR fanning out to multiple streams on the same repo)
// otherwise grab the same account nonce and one tx fails with "nonce too low".
// Submissions are infrequent, so a single global queue is simplest and safe.
let _submitChain = Promise.resolve();

/**
* Submit an ExtensionVoucher on-chain.
*
Expand All @@ -82,7 +88,15 @@ function getConnectedWallet(chainId) {
*
* @returns {Promise<{ txHash: string, blockNumber: number, gasUsed: string, chainId: number }>}
*/
export async function submitExtension({
export async function submitExtension(params) {
// Queue behind any in-flight submission so account nonces never collide.
const run = _submitChain.then(() => _submitExtension(params));
// Keep the queue alive whether this one succeeds or fails.
_submitChain = run.then(() => {}, () => {});
return run;
}

async function _submitExtension({
streamId,
extensionDurationSeconds,
nonce,
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/hooks/useProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { useState, useEffect, useRef, useCallback } from 'react';
const AGENT_URL = import.meta.env.VITE_AGENT_URL ?? 'http://localhost:3000';
const CACHE_KEY = addr => `cronstream_profile_${addr?.toLowerCase()}`;

/**
* A profile is "complete" only when every required field is present. A partial
* save (e.g. the onboarding 401 bug) can leave a profile with a role but no
* username, which has no Settings field to fix — so we route such profiles back
* through Setup to collect what's missing.
*/
export function isProfileComplete(p) {
if (!p || !p.role || !p.username || !p.name) return false;
if (p.role === 'contractor' && !p.github) return false;
return true;
}

// ─── Module-level deduplication ──────────────────────────────────────────────
// Multiple components (AppShell, Dashboard, CreateStreamModal, etc.) all call
// useProfile(address) independently. Without deduplication each mount fires its
Expand Down Expand Up @@ -170,6 +182,9 @@ export function useProfile(address) {
};
setProfile(optimistic);
localStorage.setItem(CACHE_KEY(address), JSON.stringify(optimistic));
// Notify other mounted components (AppShell, dashboard) immediately so the
// role/username reflect without waiting for the server round-trip or a refresh.
notifyListeners(address, optimistic);

// Bust the memory cache so next mount re-fetches fresh data
invalidateCache(address);
Expand Down Expand Up @@ -205,7 +220,7 @@ export function useProfile(address) {
if (address) { invalidateCache(address); fetchProfile(address); }
}

return { profile, saveProfile, loading, synced, hasProfile: !!profile, refreshProfile };
return { profile, saveProfile, loading, synced, hasProfile: !!profile, profileComplete: isProfileComplete(profile), refreshProfile };
}

function _shortAddr(addr) {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/Connect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import { useProfile } from '../hooks/useProfile';
export default function Connect() {
const navigate = useNavigate();
const { address, isConnected } = useAccount();
const { hasProfile } = useProfile(address);
const { profileComplete } = useProfile(address);

useEffect(() => {
if (isConnected) {
navigate(hasProfile ? '/app/dashboard' : '/app/setup', { replace: true });
navigate(profileComplete ? '/app/dashboard' : '/app/setup', { replace: true });
}
}, [isConnected, hasProfile, navigate]);
}, [isConnected, profileComplete, navigate]);

return (
<div className="min-h-screen bg-dark flex items-center justify-center px-6 grid-bg">
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/Landing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ export default function Landing() {
const navigate = useNavigate();
const { address, isConnected } = useAccount();
const { openConnectModal } = useConnectModal();
const { hasProfile } = useProfile(address);
const { profileComplete } = useProfile(address);

useEffect(() => {
if (isConnected) {
navigate(hasProfile ? '/app/dashboard' : '/app/setup', { replace: true });
navigate(profileComplete ? '/app/dashboard' : '/app/setup', { replace: true });
}
}, [isConnected, hasProfile, navigate]);
}, [isConnected, profileComplete, navigate]);

function handleLaunch() {
if (isConnected) {
Expand Down
55 changes: 47 additions & 8 deletions frontend/src/pages/app/Setup.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAccount } from 'wagmi';
import { useProfile } from '../../hooks/useProfile';
import { useProfile, isProfileComplete } from '../../hooks/useProfile';
import { useAuth } from '../../context/AuthContext';

const AGENT_URL = import.meta.env.VITE_AGENT_URL ?? 'http://localhost:3000';

function UsernameField({ value, address, onChange }) {
function UsernameField({ value, address, onChange, locked }) {
const [status, setStatus] = useState('idle'); // idle | checking | available | taken | invalid
const debounce = useRef(null);

// Already set and immutable — show it read-only.
if (locked) {
return (
<div>
<label className="label">Username <span className="text-muted/50 normal-case tracking-normal font-normal">(set, can't be changed)</span></label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-muted font-mono text-sm select-none">@</span>
<input value={value} disabled className="input pl-8 opacity-60 cursor-not-allowed" />
</div>
</div>
);
}

useEffect(() => {
if (!value || value.length < 3) { setStatus('idle'); return; }
if (!/^[a-z0-9_-]+$/.test(value)) { setStatus('invalid'); return; }
Expand Down Expand Up @@ -71,10 +84,31 @@ export default function Setup() {
const [form, setForm] = useState({
username: '', name: '', github: '', website: '',
});
const [prefilled, setPrefilled] = useState(false);

// If profile already exists redirect immediately - don't show the form
// A profile that exists but is incomplete (e.g. a missing immutable username
// from a partial save) lands here to be completed rather than dumped on a
// broken dashboard. Role is fixed once set; username is fixed once set.
const completing = synced && !!profile?.role && !isProfileComplete(profile);
const usernameLocked = !!profile?.username;

// Prefill the form once with whatever the existing profile already has.
useEffect(() => {
if (synced && profile?.role) {
if (synced && profile && !prefilled) {
setRole(profile.role ?? null);
setForm({
username: profile.username ?? '',
name: profile.name ?? '',
github: profile.github ?? '',
website: profile.website ?? '',
});
setPrefilled(true);
}
}, [synced, profile, prefilled]);

// Only leave Setup once the profile is genuinely complete.
useEffect(() => {
if (synced && isProfileComplete(profile)) {
navigate('/app/dashboard', { replace: true });
}
}, [synced, profile, navigate]);
Expand Down Expand Up @@ -155,11 +189,14 @@ export default function Setup() {
</div>
<span className="text-accent font-mono font-semibold text-sm">CronStream</span>
</div>
<h1 className="text-2xl font-bold mb-1">Set up your profile</h1>
<p className="text-muted text-sm">Tell us how you'll use CronStream.</p>
<h1 className="text-2xl font-bold mb-1">{completing ? 'Complete your profile' : 'Set up your profile'}</h1>
<p className="text-muted text-sm">
{completing ? 'A required detail is missing. Add it to finish.' : "Tell us how you'll use CronStream."}
</p>
</div>

{/* Role picker */}
{/* Role picker — hidden once a role exists (role is immutable) */}
{!completing && (
<div className="grid grid-cols-2 gap-3 mb-6">
{[
{ value: 'company', icon: '🏢', title: 'Company', desc: 'Create streams, pay contractors' },
Expand All @@ -178,6 +215,7 @@ export default function Setup() {
</button>
))}
</div>
)}

{/* Form */}
{role && (
Expand All @@ -186,6 +224,7 @@ export default function Setup() {
<UsernameField
value={form.username}
address={address}
locked={usernameLocked}
onChange={val => setForm(f => ({ ...f, username: val }))}
/>

Expand Down Expand Up @@ -251,7 +290,7 @@ export default function Setup() {
disabled={loading || !form.name || !form.username || (role === 'contractor' && !form.github.trim())}
className="btn-primary w-full disabled:opacity-40 disabled:cursor-not-allowed mt-1"
>
{loading ? 'Setting up…' : 'Continue to Dashboard →'}
{loading ? 'Saving…' : completing ? 'Save and continue →' : 'Continue to Dashboard →'}
</button>
</form>
)}
Expand Down
Loading