diff --git a/.env.example b/.env.example index afe55fe..7a39024 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,8 @@ LAZYBOY_MEMORY_BYTE_BUDGET=6000 LAZYBOY_EVENT_RETENTION_DAYS=30 LAZYBOY_CHECKPOINT_RETENTION_DAYS=7 LAZYBOY_RUN_RETENTION_DAYS=90 +# Per-run trace shown when hovering the thinking avatar (round, tool results, errors). +LAZYBOY_RUN_ACTIVITY_RETENTION_DAYS=7 LAZYBOY_RECORDING_RETENTION_DAYS=30 LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90 LAZYBOY_DB_WARN_MB=1024 diff --git a/Cargo.lock b/Cargo.lock index 505e86f..b945f05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1889,7 +1889,6 @@ name = "lazyboy-api" version = "0.1.0" dependencies = [ "aes-gcm", - "async-trait", "axum", "base64 0.22.1", "cap-std", @@ -1900,9 +1899,7 @@ dependencies = [ "fastembed", "futures-util", "hex", - "hmac", "http", - "http-body-util", "lazyboy-contracts", "lazyboy-control", "lazyboy-harness", @@ -1915,7 +1912,6 @@ dependencies = [ "serde_json", "sha2", "sqlx", - "thiserror", "tokio", "tokio-tungstenite 0.26.2", "tower-http", @@ -1958,9 +1954,7 @@ dependencies = [ "base64 0.22.1", "lazyboy-contracts", "lazyboy-control", - "serde", "serde_json", - "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -1979,7 +1973,6 @@ dependencies = [ "rig-core", "rustls", "rustls-native-certs", - "serde", "serde_json", "thiserror", "tokio", @@ -1992,39 +1985,30 @@ version = "0.1.0" dependencies = [ "async-trait", "base64 0.22.1", - "chrono", - "hex", "lazyboy-contracts", "lazyboy-control", "reqwest 0.12.28", - "serde", "serde_json", - "sha2", - "tokio", ] [[package]] name = "lazyboy-supervisor" version = "0.1.0" dependencies = [ - "async-trait", "axum", "base64 0.22.1", "bollard", "futures-util", "hex", "hmac", - "lazyboy-contracts", "lazyboy-control", "reqwest 0.12.28", "serde", "serde_json", "sha2", - "thiserror", "tokio", "tracing", "tracing-subscriber", - "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c60dcd8..ffb8e7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,28 @@ members = [ [workspace.package] edition = "2024" +rust-version = "1.98" version = "0.1.0" license = "Apache-2.0" publish = false +# --- Lint policy ----------------------------------------------------------- +# A single place decides how the whole workspace is linted; every crate opts in +# with `[lints] workspace = true`. `make lint` runs clippy with -D warnings, so a +# new warning has to be fixed (or relaxed at the call site with a reason) before +# it reaches main. Thresholds live in clippy.toml, which cargo-clippy only reads +# from the directory it was started in - keep running it at the workspace root. +[workspace.lints.rust] +# Safety-relevant code (the vault, the docker socket) has to stay explicit. +unsafe_code = "warn" +unused_must_use = "deny" +unused_crate_dependencies = "warn" + +[workspace.lints.clippy] +# Debug leftovers and placeholder implementations must not reach a branch. +dbg_macro = "warn" +todo = "warn" + [profile.release] lto = true codegen-units = 1 diff --git a/Makefile b/Makefile index 175dd58..3a27ceb 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,9 @@ DATA_DIR ?= ./data .PHONY: help env env-force \ up logs ps health down purge \ - computer postgres postgres-down \ + computer postgres postgres-down pg-collation \ build build-api build-supervisor build-controld \ - fmt clippy test clean \ + fmt fmt-check clippy lint audit test clean \ web \ dev dev-supervisor dev-api @@ -33,6 +33,7 @@ help: ## Show this help @echo " make computer Build the heavy Debian desktop image (lazyboy/computer:local)" @echo " make postgres Start only postgres (127.0.0.1:5434) and wait for ready" @echo " make postgres-down Stop postgres" + @echo " make pg-collation Repair a Postgres collation version mismatch (see docs)" @echo "" @echo " Local dev (postgres in Docker, Rust services on the host):" @echo " make dev Prep .env + postgres + computer image, then print run steps" @@ -43,8 +44,11 @@ help: ## Show this help @echo " make build cargo build --release (whole workspace)" @echo " make build-api cargo build --release -p lazyboy-api" @echo " make fmt cargo fmt --all" - @echo " make clippy cargo clippy (deny warnings)" - @echo " make test cargo test --workspace" + @echo " make fmt-check Report files rustfmt would change (legacy drift exists)" + @echo " make clippy cargo clippy (deny warnings, reads clippy.toml)" + @echo " make lint The Rust gate: clippy with -D warnings" + @echo " make audit cargo deny: RustSec advisories, licenses, sources" + @echo " make test cargo test --workspace (DB tests need: make postgres)" @echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)" @echo " make clean cargo clean" @echo "" @@ -97,6 +101,14 @@ postgres: ## Start only postgres and wait until it is ready postgres-down: ## Stop postgres $(COMPOSE) down postgres +# A pgvector image rebuilt on another glibc leaves every database recording the old +# collation version; Postgres then refuses CREATE DATABASE and `cargo test` hangs on +# PoolTimedOut. Stop the api first, then reindex + refresh each database in place. +pg-collation: ## Repair a Postgres collation version mismatch after an image update + $(COMPOSE) exec -T postgres psql -X -v ON_ERROR_STOP=1 -U lazyboy -d template1 -c "REINDEX DATABASE template1;" -c "ALTER DATABASE template1 REFRESH COLLATION VERSION;" + $(COMPOSE) exec -T postgres psql -X -v ON_ERROR_STOP=1 -U lazyboy -d postgres -c "REINDEX DATABASE postgres;" -c "ALTER DATABASE postgres REFRESH COLLATION VERSION;" + $(COMPOSE) exec -T postgres psql -X -v ON_ERROR_STOP=1 -U lazyboy -d lazyboy -c "REINDEX DATABASE lazyboy;" -c "ALTER DATABASE lazyboy REFRESH COLLATION VERSION;" + # --- Rust / web ------------------------------------------------------------ build: ## Release build of the whole workspace @@ -114,8 +126,23 @@ build-controld: ## Release build of the controld binary fmt: ## Format all Rust code cargo fmt --all -clippy: ## Run clippy, denying warnings - cargo clippy --all-targets -- -D warnings +fmt-check: ## Check formatting without touching files + cargo fmt --all --check + +# Lint policy lives in [workspace.lints] in the root Cargo.toml; the thresholds +# (e.g. too-many-arguments-threshold) live in clippy.toml, which cargo-clippy +# only reads from the directory it is started in - keep running this at the root. +clippy: ## Run clippy over the workspace, denying warnings + cargo clippy --workspace --all-targets -- -D warnings + +lint: clippy ## The Rust quality gate used by CI + +# cargo-deny is a separate CLI: cargo install --locked cargo-deny +audit: ## Supply-chain check (RustSec advisories, licenses, dependency sources) + @command -v cargo-deny >/dev/null 2>&1 || { \ + echo "cargo-deny is not installed: cargo install --locked cargo-deny"; exit 1; } + @echo "(advisories need the RustSec advisory DB, cloned on first run)" + cargo deny check test: ## Run the test suite cargo test --workspace diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 93596e4..76d799a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -19,6 +19,7 @@ import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; import { ChatMarkdown, CopyMessageButton } from "./markdown"; +import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; import { CallOverlay, PhoneIcon } from "./call"; import { VoiceSettingsDialog } from "./voice-settings"; @@ -68,6 +69,7 @@ function localizeStep(step?:string|null){ function localizeError(message:string){ if(message==="示範進行中:先按「完成示範」或「取消」,再送訊息。")return t("teachInProgress"); if(message==="AI 回應逾時(150 秒)")return t("aiTimeout"); + if(message==="run is not retryable")return t("errorRetryFailed"); return message; } function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){ @@ -88,8 +90,8 @@ function readAsBase64(file:File){return new Promise((resolve,reject)=>{c function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`} function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()} function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})} -type MessageChip={kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string;reason?:string;turns?:number;limit?:number}; -function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as MessageChip[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as MessageChip;if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun"||value.kind==="resume")return [value];return []})} +type MessageChip={kind:string;site?:string;why?:string;name?:string;human?:string;cron?:string;reason?:string;turns?:number;limit?:number;code?:string;retryable?:boolean;runId?:string;turn?:number;step?:string|null}; +function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as MessageChip[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as MessageChip;if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun"||value.kind==="resume"||value.kind==="error")return [value];return []})} function resumeTitle(reason?:string){return reason==="budget_exhausted"?t("resumeBudget"):t("resumeMidTask")} function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===`Attached ${file.name}`||text===t("attachedFile",{name:file.name}))} function isAutoScheduleCaption(body:string,chips:{kind?:string}[]){if(!chips.some(chip=>chip.kind==="schedule"||chip.kind==="scheduleRun"))return false;const text=body.trim();return /^\[排程(試跑)?\]/.test(text)||/^\[Schedule( test)?\]/i.test(text)} @@ -115,7 +117,7 @@ export function App(){ const [createOpen,setCreateOpen]=useState(false); const [createMenuOpen,setCreateMenuOpen]=useState(false); const [groupOpen,setGroupOpen]=useState(false); const [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false); const paneStart=readPaneStore(); const [rightCollapsed,setRightCollapsed]=useState(paneStart.collapsed); const [rightPart,setRightPart]=useState(paneStart.part); - const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState(null); const [remembered,setRemembered]=useState>({}); const [resumedChips,setResumedChips]=useState>({}); + const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState(null); const [remembered,setRemembered]=useState>({}); const [resumedChips,setResumedChips]=useState>({}); const [retriedRuns,setRetriedRuns]=useState>({}); const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState(null); const [busy,setBusy]=useState(false); useEffect(()=>{ if(!mobileNav)return; @@ -270,6 +272,7 @@ export function App(){ // normal message: the backend folds it into the paused run instead of // starting a new task. async function continueRun(messageId:string){if(!activeSessionId||sendingRef.current||busy)return;sendingRef.current=true;const text=t("resumeSent");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce()})});setResumedChips(current=>({...current,[messageId]:true}));await loadSessions()})}finally{sendingRef.current=false}} + async function retryRun(runId?:string){if(!runId||busy)return;await action(()=>api(`/api/runs/${runId}/retry`,{method:"POST",body:"{}"}));setRetriedRuns(current=>({...current,[runId]:true}))} async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})} async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?localizeError(e.message):t("rememberFailed"))}} @@ -366,11 +369,11 @@ export function App(){
{activeRoom?<>member.id)}/>{activeRoom.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:active?<>{active.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:<>{t("chooseBot")}{topTools}}
{callOpen&&voiceSettings?.enabled&&active&&activeSessionId&&!activeRoomId?setCallOpen(false)} onTakeOver={()=>{setRightPart("computer");setRightCollapsed(false);if(active)void action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}}/>:null} -
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);return
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}
:{message.body})}{!hideBody&&message.body.trim()&&}
})}{pausedForUser&&paneBot&&
{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>} +
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);return
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="error"?
{errorTitle(chip.code)}
{errorActions(chip.code).map(next=>next==="retry"?:next==="screen"?:)}
:chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}
:{message.body})}{!hideBody&&message.body.trim()&&}
})}{pausedForUser&&paneBot&&
{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>} {error&&
{error}
} {otherSessionBusy&&
{t("anotherConversationQueued")}
}
- {statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return
{label}{!transition&&step?{localizeStep(step)}:null}
})} + {statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return
{label}{!transition&&step?{localizeStep(step)}:null}
})}
{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}>
event.stopPropagation()}>{plusOpen&&
{savedSkills.length>0&&<>
{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}{savedSkills.length>=6&&setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}
{listedSkills.map(skill=>
)}{listedSkills.length===0&&{t("noMatchingSkills")}}
}
}
{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&
{pendingFiles.map(item=>removePendingFile(item.id)}/>)}
}{slashSuggestions.length>0&&
{slashSuggestions.map((skill,index)=>)}
}