feat/cua-driver-poc #7

Merged
daniel.w merged 9 commits from feat/cua-driver-poc into main 2026-09-08 16:57:24 +00:00
56 changed files with 2235 additions and 392 deletions
Showing only changes of commit aa19f1bccf - Show all commits

View File

@ -33,6 +33,8 @@ LAZYBOY_MEMORY_BYTE_BUDGET=6000
LAZYBOY_EVENT_RETENTION_DAYS=30 LAZYBOY_EVENT_RETENTION_DAYS=30
LAZYBOY_CHECKPOINT_RETENTION_DAYS=7 LAZYBOY_CHECKPOINT_RETENTION_DAYS=7
LAZYBOY_RUN_RETENTION_DAYS=90 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_RECORDING_RETENTION_DAYS=30
LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90 LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90
LAZYBOY_DB_WARN_MB=1024 LAZYBOY_DB_WARN_MB=1024

16
Cargo.lock generated
View File

@ -1889,7 +1889,6 @@ name = "lazyboy-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"cap-std", "cap-std",
@ -1900,9 +1899,7 @@ dependencies = [
"fastembed", "fastembed",
"futures-util", "futures-util",
"hex", "hex",
"hmac",
"http", "http",
"http-body-util",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
"lazyboy-harness", "lazyboy-harness",
@ -1915,7 +1912,6 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tokio-tungstenite 0.26.2", "tokio-tungstenite 0.26.2",
"tower-http", "tower-http",
@ -1958,9 +1954,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
"serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -1979,7 +1973,6 @@ dependencies = [
"rig-core", "rig-core",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "tokio",
@ -1992,39 +1985,30 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"chrono",
"hex",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde",
"serde_json", "serde_json",
"sha2",
"tokio",
] ]
[[package]] [[package]]
name = "lazyboy-supervisor" name = "lazyboy-supervisor"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"bollard", "bollard",
"futures-util", "futures-util",
"hex", "hex",
"hmac", "hmac",
"lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]

View File

@ -12,10 +12,28 @@ members = [
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.98"
version = "0.1.0" version = "0.1.0"
license = "Apache-2.0" license = "Apache-2.0"
publish = false 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] [profile.release]
lto = true lto = true
codegen-units = 1 codegen-units = 1

View File

@ -11,9 +11,9 @@ DATA_DIR ?= ./data
.PHONY: help env env-force \ .PHONY: help env env-force \
up logs ps health down purge \ up logs ps health down purge \
computer postgres postgres-down \ computer postgres postgres-down pg-collation \
build build-api build-supervisor build-controld \ build build-api build-supervisor build-controld \
fmt clippy test clean \ fmt fmt-check clippy lint audit test clean \
web \ web \
dev dev-supervisor dev-api 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 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 Start only postgres (127.0.0.1:5434) and wait for ready"
@echo " make postgres-down Stop postgres" @echo " make postgres-down Stop postgres"
@echo " make pg-collation Repair a Postgres collation version mismatch (see docs)"
@echo "" @echo ""
@echo " Local dev (postgres in Docker, Rust services on the host):" @echo " Local dev (postgres in Docker, Rust services on the host):"
@echo " make dev Prep .env + postgres + computer image, then print run steps" @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 cargo build --release (whole workspace)"
@echo " make build-api cargo build --release -p lazyboy-api" @echo " make build-api cargo build --release -p lazyboy-api"
@echo " make fmt cargo fmt --all" @echo " make fmt cargo fmt --all"
@echo " make clippy cargo clippy (deny warnings)" @echo " make fmt-check Report files rustfmt would change (legacy drift exists)"
@echo " make test cargo test --workspace" @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 web Build the frontend in $(WEB_DIR) (needs node/npm)"
@echo " make clean cargo clean" @echo " make clean cargo clean"
@echo "" @echo ""
@ -97,6 +101,14 @@ postgres: ## Start only postgres and wait until it is ready
postgres-down: ## Stop postgres postgres-down: ## Stop postgres
$(COMPOSE) down 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 ------------------------------------------------------------ # --- Rust / web ------------------------------------------------------------
build: ## Release build of the whole workspace build: ## Release build of the whole workspace
@ -114,8 +126,23 @@ build-controld: ## Release build of the controld binary
fmt: ## Format all Rust code fmt: ## Format all Rust code
cargo fmt --all cargo fmt --all
clippy: ## Run clippy, denying warnings fmt-check: ## Check formatting without touching files
cargo clippy --all-targets -- -D warnings 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 test: ## Run the test suite
cargo test --workspace cargo test --workspace

File diff suppressed because one or more lines are too long

View File

@ -459,5 +459,53 @@ export const en: { [K in keyof typeof zhTW]: string } = {
resumeProgress: "{turns}/{limit} turns", resumeProgress: "{turns}/{limit} turns",
resumeContinue: "Keep going", resumeContinue: "Keep going",
resumeStop: "Stop here", resumeStop: "Stop here",
resumeSent: "Keep going and finish it.", resumeSent: "Keep going and finish it.", monitorTitle: "Live log",
monitorQueued: "Queued",
monitorTurnOf: "turn {turn}/{limit}",
monitorTurn: "turn {turn}",
monitorElapsed: "up {time}",
monitorNow: "Now: {step}",
monitorEmpty: "Nothing yet — the trail fills in as I work.",
monitorLoadFailed: "Couldnt load the log",
monitorCopy: "Copy log",
monitorCopied: "Copied",
monitorPinHint: "Folds away when the mouse leaves",
monitorPinned: "Pinned — press Esc to close",
monitorClose: "Close",
monitorKindModel: "Model",
monitorKindTool: "Action",
monitorKindRetry: "Retry",
monitorKindNotice: "Note",
monitorKindRun: "Status",
monitorRunStarted: "Started: {task}",
monitorRunCompleted: "Done in {turns} turns",
monitorRunFailed: "Failed",
monitorRunWaiting: "Waiting for you: {reason}",
monitorRunPaused: "Paused: {reason}",
monitorRunRetry: "Re-queued",
monitorModelTurn: "turn {turn}, thought for {time}",
monitorRetryAttempt: "attempt {attempt} failed, retrying",
monitorGaveUp: "gave up",
monitorStatusOk: "ok",
monitorStatusError: "failed",
monitorStatusTimedOut: "timed out",
monitorStatusPaused: "needs you",
errorTitleInterrupted: "Interrupted",
errorTitleToolTimeout: "Action timed out",
errorTitleModelKey: "Model API key rejected",
errorTitleModelQuota: "Rate limited or out of quota",
errorTitleModelUnknown: "Model not found",
errorTitleModelTimeout: "Model took too long",
errorTitleNetwork: "Cant reach the model",
errorTitleComputerGone: "Computer is gone",
errorTitleLeaseLost: "Work picked up elsewhere",
errorTitleUnknown: "Something broke",
errorChip: "Task failed",
errorRetry: "Retry",
errorRetryHint: "Continue where it stopped",
errorOpenSettings: "Model settings",
errorDetails: "Show the log",
errorRetried: "Back in the queue",
errorRetryFailed: "Cant re-queue right now — another run may still be busy.",
}; };

View File

@ -154,5 +154,53 @@ export const zhTW = {
resumeProgress: "{turns}/{limit} 輪", resumeProgress: "{turns}/{limit} 輪",
resumeContinue: "繼續", resumeContinue: "繼續",
resumeStop: "就到這裡", resumeStop: "就到這裡",
resumeSent: "繼續,把它做完。", resumeSent: "繼續,把它做完。", monitorTitle: "即時記錄",
monitorQueued: "排隊中",
monitorTurnOf: "第 {turn}/{limit} 輪",
monitorTurn: "第 {turn} 輪",
monitorElapsed: "已運行 {time}",
monitorNow: "現在:{step}",
monitorEmpty: "還沒有記錄。開始動手後會一條條出現。",
monitorLoadFailed: "記錄載入失敗",
monitorCopy: "複製記錄",
monitorCopied: "已複製",
monitorPinHint: "滑鼠移開會自動收起來",
monitorPinned: "已釘住(按 Esc 關閉)",
monitorClose: "關閉",
monitorKindModel: "模型",
monitorKindTool: "動作",
monitorKindRetry: "重試",
monitorKindNotice: "提醒",
monitorKindRun: "狀態",
monitorRunStarted: "開始工作:{task}",
monitorRunCompleted: "完成了({turns} 輪)",
monitorRunFailed: "失敗了",
monitorRunWaiting: "停下來等你:{reason}",
monitorRunPaused: "暫停:{reason}",
monitorRunRetry: "重新排進佇列",
monitorModelTurn: "第 {turn} 輪,想了 {time}",
monitorRetryAttempt: "第 {attempt} 次嘗試失敗,正在重試",
monitorGaveUp: "放棄重試",
monitorStatusOk: "成功",
monitorStatusError: "失敗",
monitorStatusTimedOut: "逾時",
monitorStatusPaused: "要你接手",
errorTitleInterrupted: "被打斷",
errorTitleToolTimeout: "動作逾時",
errorTitleModelKey: "模型金鑰不被接受",
errorTitleModelQuota: "限流或額度不足",
errorTitleModelUnknown: "模型名稱找不到",
errorTitleModelTimeout: "模型太久沒回話",
errorTitleNetwork: "連不到模型",
errorTitleComputerGone: "電腦不見了",
errorTitleLeaseLost: "工作被另一邊接手",
errorTitleUnknown: "出錯了",
errorChip: "任務失敗",
errorRetry: "重試",
errorRetryHint: "從中斷的地方接著做",
errorOpenSettings: "開模型設定",
errorDetails: "看詳細記錄",
errorRetried: "已重新排進佇列",
errorRetryFailed: "現在重試排不進去,可能還有別的工作在跑。",
} as const; } as const;

View File

@ -8,6 +8,7 @@ import "./avatar.css";
import "./chat.css"; import "./chat.css";
import "./computer.css"; import "./computer.css";
import "./schedule.css"; import "./schedule.css";
import "./monitor.css";
import "./call.css"; import "./call.css";
import "./responsive.css"; import "./responsive.css";
import { App } from "./App"; import { App } from "./App";

39
apps/web/src/monitor.css Normal file
View File

@ -0,0 +1,39 @@
/* The live run bubble. It floats above whatever opened it, so it never has to
fit inside the composer or a message bubble. */
.run-probe{position:relative;display:inline-flex;align-items:center;border-radius:10px;outline:none}
.run-probe:focus-visible{box-shadow:0 0 0 2px var(--focus)}
.run-probe.inactive{cursor:default}
.run-monitor{position:fixed;z-index:60;display:flex;flex-direction:column;gap:8px;max-height:min(64vh,460px);padding:12px 13px 10px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 18px 44px rgba(0,0,0,.55);color:var(--ink);font-size:12px;line-height:1.45;text-align:left;cursor:default}
.run-monitor-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px}
.run-monitor-head b{font-size:13px}
.run-monitor-turn{color:var(--accent);font-variant-numeric:tabular-nums}
.run-monitor-step{color:var(--muted);overflow-wrap:anywhere}
.run-monitor-step.bad{color:var(--error-fg)}
.run-monitor-list{display:flex;flex-direction:column;gap:5px;min-height:44px;max-height:min(38vh,300px);overflow-y:auto;padding-right:2px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding-top:7px;padding-bottom:7px}
.run-monitor-empty{color:var(--faint)}
.run-line{display:grid;grid-template-columns:58px 40px 1fr;gap:7px;align-items:baseline}
.run-line-clock{color:var(--faint);font-variant-numeric:tabular-nums}
.run-line-kind{color:var(--muted)}
.run-line-text{overflow-wrap:anywhere}
.run-line.is-error .run-line-text,.run-line.is-timed_out .run-line-text{color:var(--error-fg)}
.run-line.is-paused .run-line-text{color:var(--warn-fg)}
.run-line.kind-run .run-line-text{color:var(--ink)}
.run-line.kind-retry .run-line-text{color:var(--warn-fg)}
.run-monitor-error{display:flex;flex-direction:column;gap:4px;padding:9px 10px;border:1px solid var(--error-border);border-radius:11px;background:var(--error-bg);color:var(--error-fg)}
.run-monitor-error code{display:block;max-height:110px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--error-fg);font-size:11px;opacity:.85}
.run-monitor-foot{display:flex;align-items:center;gap:9px}
.run-monitor-foot button{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:9px;background:var(--elevated);color:var(--ink);font-size:12px;cursor:pointer}
.run-monitor-foot button:hover{background:var(--surface-hover)}
.run-monitor-foot small{flex:1;color:var(--faint)}
.run-monitor-close{padding:3px 8px !important;color:var(--muted)}
/* Failure card in the chat: what broke, and the one thing to do next. */
.error-chip{border-color:var(--error-border);background:var(--error-bg)}
.error-chip .login-label{display:flex;align-items:center;gap:7px;color:var(--error-fg);font-weight:600}
.error-chip .error-body{color:var(--ink);overflow-wrap:anywhere}
.error-chip .error-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:2px}
.error-chip .error-actions button{padding:6px 12px;border:1px solid var(--border);border-radius:9px;background:var(--elevated);color:var(--ink);font-size:13px;cursor:pointer}
.error-chip .error-actions button.primary{border-color:transparent;background:var(--cream);color:var(--on-cream)}
.error-chip .error-actions button:disabled{opacity:.55;cursor:default}
.error-chip .error-probe{margin-left:auto;color:var(--error-fg);cursor:pointer}
.error-chip .error-said{color:var(--error-fg);font-size:12px}

View File

@ -0,0 +1,372 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { api } from "./api";
import { t, type MessageKey } from "./i18n";
import type { RunActivity, RunActivityEntry } from "./types";
/**
* The observability bubble: hover (or tap, or focus) an avatar to watch the run
* that avatar is doing, turn by turn. The trail is polled rather than streamed
* because it is only interesting while somebody is looking at it.
*/
const POLL_MS = 1500;
const CLOSE_MS = 300;
const KEEP = 200;
const PANEL_WIDTH = 380;
const DONE = ["completed", "failed", "cancelled"];
export type RunActionId = "retry" | "screen" | "settings";
/**
* Which buttons actually help for a failure code, best first. A bad API key is
* fixed in settings and a lost computer is checked on screen retrying alone
* would only burn another turn on the same wall.
*/
const ACTIONS: Record<string, RunActionId[]> = {
interrupted: ["screen", "retry"],
tool_timeout: ["screen", "retry"],
model_key: ["settings", "retry"],
model_unknown: ["settings", "retry"],
model_quota: ["retry", "settings"],
model_timeout: ["retry", "settings"],
network: ["retry", "settings"],
computer_gone: ["screen", "retry"],
lease_lost: ["retry"],
unknown: ["retry"],
};
const TITLES: Record<string, MessageKey> = {
interrupted: "errorTitleInterrupted",
tool_timeout: "errorTitleToolTimeout",
model_key: "errorTitleModelKey",
model_quota: "errorTitleModelQuota",
model_unknown: "errorTitleModelUnknown",
model_timeout: "errorTitleModelTimeout",
network: "errorTitleNetwork",
computer_gone: "errorTitleComputerGone",
lease_lost: "errorTitleLeaseLost",
unknown: "errorTitleUnknown",
};
export function errorActions(code?: string | null): RunActionId[] {
return ACTIONS[code || ""] || ACTIONS.unknown;
}
/** Short label for a failure code; the long sentence lives in the message body. */
export function errorTitle(code?: string | null): string {
return t(TITLES[code || ""] || TITLES.unknown);
}
/** 1:05 / 1:02:05 — how long the run has been at it. */
export function formatElapsed(ms: number): string {
const seconds = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0;
const pad = (value: number) => String(value).padStart(2, "0");
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds % 60)}` : `${minutes}:${pad(seconds % 60)}`;
}
/** 340ms / 6.4s / 1:05 — durations inside the trail stay glanceable. */
export function shortDuration(ms?: number | null): string {
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "";
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
return formatElapsed(ms);
}
function kindLabel(kind: string): string {
if (kind === "model") return t("monitorKindModel");
if (kind === "tool") return t("monitorKindTool");
if (kind === "retry") return t("monitorKindRetry");
if (kind === "notice") return t("monitorKindNotice");
return t("monitorKindRun");
}
function statusLabel(status?: string | null): string {
if (status === "ok") return t("monitorStatusOk");
if (status === "timed_out") return t("monitorStatusTimedOut");
if (status === "paused") return t("monitorStatusPaused");
if (status === "error") return t("monitorStatusError");
return "";
}
/** One trail line as plain text — what the panel shows and what gets copied. */
export function trailText(entry: RunActivityEntry): string {
if (entry.kind === "run") {
if (entry.event === "started") return t("monitorRunStarted", { task: entry.task || "" });
if (entry.event === "completed") return t("monitorRunCompleted", { turns: entry.turns ?? 0 });
if (entry.event === "failed") return t("monitorRunFailed");
if (entry.event === "waiting_input") return t("monitorRunWaiting", { reason: entry.reason || "" });
if (entry.event === "paused") return t("monitorRunPaused", { reason: entry.reason || "" });
if (entry.event === "retry") return t("monitorRunRetry");
return entry.text || entry.reason || t("monitorKindRun");
}
if (entry.kind === "model") {
const head = t("monitorModelTurn", { turn: entry.turn ?? 0, time: shortDuration(entry.elapsedMs) });
return entry.text ? `${head}${entry.text}` : head;
}
if (entry.kind === "tool") {
const parts = [entry.step || entry.name || t("monitorKindTool")];
const status = statusLabel(entry.status);
if (status) parts.push(status);
const time = shortDuration(entry.elapsedMs);
if (time) parts.push(time);
const detail = entry.snippet ? `${entry.snippet}` : "";
return `${parts.join(" · ")}${detail}`;
}
if (entry.kind === "retry") {
const head = `${t("monitorRetryAttempt", { attempt: entry.attempt ?? 0 })}${entry.gaveUp ? ` · ${t("monitorGaveUp")}` : ""}`;
return entry.error ? `${head}${entry.error}` : head;
}
return entry.text || t("monitorKindNotice");
}
function clockOf(createdAt: string): string {
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return "";
const pad = (value: number) => String(value).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function logDump(snapshot: RunActivity | null, entries: RunActivityEntry[]): string {
const turn = snapshot?.turnLimit
? t("monitorTurnOf", { turn: snapshot.turn ?? 0, limit: snapshot.turnLimit })
: t("monitorTurn", { turn: snapshot?.turn ?? 0 });
const head = [
snapshot?.runId ?? "",
snapshot?.status ?? "",
snapshot?.turn != null ? turn : "",
snapshot?.step ?? "",
snapshot?.elapsedMs != null ? t("monitorElapsed", { time: formatElapsed(snapshot.elapsedMs) }) : "",
].filter(Boolean).join(" · ");
const trail = entries.map(entry => `${clockOf(entry.createdAt)} ${trailText(entry)}`);
const error = snapshot?.error ? `\n[${snapshot.error.code}] ${snapshot.error.raw}` : "";
return [head, ...trail].join("\n") + error;
}
async function copyLog(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
/**
* Wraps an avatar (or any small trigger) in a live readout of the run behind
* it. Hover opens it and it closes ~300ms after the pointer leaves; a click
* pins it so the panel survives reaching for the copy button; Esc lets go.
*/
export function RunProbe({ runId, align = "start", label, children }: { runId?: string | null; align?: "start" | "end"; label?: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
const [pinned, setPinned] = useState(false);
const [snapshot, setSnapshot] = useState<RunActivity | null>(null);
const [entries, setEntries] = useState<RunActivityEntry[]>([]);
const [stale, setStale] = useState(false);
const [copied, setCopied] = useState<boolean | null>(null);
const [box, setBox] = useState<{ left: number; bottom: number } | null>(null);
const anchorRef = useRef<HTMLSpanElement | null>(null);
const listRef = useRef<HTMLSpanElement | null>(null);
const lastId = useRef(0);
const followTail = useRef(true);
const linger = useRef<number | null>(null);
const pinnedRef = useRef(false);
useEffect(() => {
pinnedRef.current = pinned;
}, [pinned]);
useEffect(() => {
lastId.current = 0;
setSnapshot(null);
setEntries([]);
setStale(false);
}, [runId]);
const place = useCallback(() => {
const node = anchorRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const width = Math.min(PANEL_WIDTH, window.innerWidth - 24);
const edge = align === "end" ? rect.right - width : rect.left;
setBox({
left: Math.max(12, Math.min(edge, window.innerWidth - 12 - width)),
bottom: Math.max(12, window.innerHeight - rect.top + 8),
});
}, [align]);
useEffect(() => {
if (!open || !runId) return;
let stopped = false;
let timer = 0;
const load = async () => {
try {
const after = lastId.current > 0 ? `?after=${lastId.current}` : "";
const next = await api<RunActivity>(`/api/runs/${runId}/activity${after}`);
if (stopped) return;
setStale(false);
setSnapshot(next);
setEntries(current => {
if (lastId.current <= 0) return next.activity.slice(-KEEP);
const seen = new Set(current.map(entry => entry.id));
return [...current, ...next.activity.filter(entry => !seen.has(entry.id))].slice(-KEEP);
});
for (const entry of next.activity) lastId.current = Math.max(lastId.current, entry.id);
if (DONE.includes(next.status)) window.clearInterval(timer);
} catch {
if (!stopped) setStale(true);
}
};
timer = window.setInterval(() => void load(), POLL_MS);
void load();
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [open, runId]);
useEffect(() => {
if (!open) return;
place();
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [open, place]);
useEffect(() => {
const list = listRef.current;
if (open && followTail.current && list) list.scrollTop = list.scrollHeight;
}, [entries, open]);
const hoverIn = useCallback(() => {
if (linger.current) window.clearTimeout(linger.current);
linger.current = null;
setOpen(true);
}, []);
const hoverOut = useCallback(() => {
if (pinnedRef.current) return;
if (linger.current) window.clearTimeout(linger.current);
linger.current = window.setTimeout(() => setOpen(false), CLOSE_MS);
}, []);
useEffect(
() => () => {
if (linger.current) window.clearTimeout(linger.current);
},
[],
);
const onKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
if (event.key === "Escape") {
setPinned(false);
setOpen(false);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setPinned(current => !current);
setOpen(true);
}
};
const turnText =
snapshot?.status === "queued"
? t("monitorQueued")
: snapshot?.turn != null
? snapshot.turnLimit
? t("monitorTurnOf", { turn: snapshot.turn, limit: snapshot.turnLimit })
: t("monitorTurn", { turn: snapshot.turn })
: "—";
return (
<span
className={`run-probe ${runId ? "" : "inactive"}`}
ref={anchorRef}
tabIndex={runId ? 0 : -1}
aria-expanded={open}
aria-label={label || t("monitorTitle")}
onMouseEnter={runId ? hoverIn : undefined}
onMouseLeave={runId ? hoverOut : undefined}
onClick={
runId
? () => {
setOpen(true);
setPinned(current => !current);
}
: undefined
}
onKeyDown={runId ? onKeyDown : undefined}
>
{children}
{open ? (
<span
className={`run-monitor ${pinned ? "pinned" : ""}`}
style={box ? { left: box.left, bottom: box.bottom, width: Math.min(PANEL_WIDTH, window.innerWidth - 24) } : { display: "none" }}
role="dialog"
aria-label={t("monitorTitle")}
onMouseEnter={hoverIn}
onMouseLeave={hoverOut}
>
<span className="run-monitor-head">
<b>{t("monitorTitle")}</b>
<span className="run-monitor-turn">
{turnText}
{snapshot?.elapsedMs != null ? ` · ${t("monitorElapsed", { time: formatElapsed(snapshot.elapsedMs) })}` : ""}
</span>
</span>
<span className={`run-monitor-step ${snapshot?.error ? "bad" : ""}`}>
{snapshot?.step ? t("monitorNow", { step: snapshot.step }) : snapshot?.error?.headline || t("monitorEmpty")}
</span>
<span
className="run-monitor-list"
ref={listRef}
onScroll={event => {
const node = event.currentTarget;
followTail.current = node.scrollHeight - node.scrollTop - node.clientHeight < 32;
}}
>
{entries.length === 0 ? <span className="run-monitor-empty">{stale ? t("monitorLoadFailed") : t("monitorEmpty")}</span> : null}
{entries.map(entry => (
<span className={`run-line kind-${entry.kind} ${entry.status && entry.status !== "ok" ? `is-${entry.status}` : ""}`} key={entry.id}>
<span className="run-line-clock">{clockOf(entry.createdAt)}</span>
<span className="run-line-kind">{kindLabel(entry.kind)}</span>
<span className="run-line-text">{trailText(entry)}</span>
</span>
))}
</span>
{snapshot?.error ? (
<span className="run-monitor-error">
<b>{`[${snapshot.error.code}] ${errorTitle(snapshot.error.code)}`}</b>
<code>{snapshot.error.raw}</code>
</span>
) : null}
<span className="run-monitor-foot">
<button
type="button"
onClick={() =>
void copyLog(logDump(snapshot, entries)).then(ok => {
setCopied(ok);
window.setTimeout(() => setCopied(null), 1800);
})
}
>
{copied === null ? t("monitorCopy") : copied ? t("monitorCopied") : t("clipboardWriteBlocked")}
</button>
<small>{pinned ? t("monitorPinned") : t("monitorPinHint")}</small>
<button
type="button"
className="run-monitor-close"
aria-label={t("monitorClose")}
onClick={() => {
setPinned(false);
setOpen(false);
}}
>
×
</button>
</span>
</span>
) : null}
</span>
);
}

View File

@ -10,6 +10,12 @@ export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:strin
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape } export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number } export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean } export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
/** One line of the live trail a run writes while it works. */
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice";
export interface RunActivityEntry { id:number; kind:RunActivityKind; createdAt:string; turn?:number|null; event?:string|null; task?:string|null; reason?:string|null; turns?:number|null; limit?:number|null; error?:string|null; name?:string|null; step?:string|null; status?:string|null; elapsedMs?:number|null; toolCalls?:number|null; text?:string|null; snippet?:string|null; attempt?:number|null; gaveUp?:boolean|null }
export interface RunActivityError { code:string; headline:string; raw:string }
export interface RunActivity { runId:string; status:string; turn:number|null; turnLimit:number|null; step:string|null; elapsedMs:number|null; error:RunActivityError|null; activity:RunActivityEntry[] }
export interface PlaybookStep { do:string; expect?:string; note?:string } export interface PlaybookStep { do:string; expect?:string; note?:string }
export interface PlaybookInput { name:string; description?:string; example?:string } export interface PlaybookInput { name:string; description?:string; example?:string }
export interface Playbook { name?:string; whenToUse?:string; intent?:string; inputs?:PlaybookInput[]; preconditions?:string[]; steps?:(PlaybookStep|string)[]; howToCheck?:string; whatToReturn?:string; cautions?:string[] } export interface Playbook { name?:string; whenToUse?:string; intent?:string; inputs?:PlaybookInput[]; preconditions?:string[]; steps?:(PlaybookStep|string)[]; howToCheck?:string; whatToReturn?:string; cautions?:string[] }

9
clippy.toml Normal file
View File

@ -0,0 +1,9 @@
# Clippy configuration for the LazyBoy workspace.
#
# cargo-clippy looks for this file next to the working directory it was started
# in, so always run clippy from the workspace root (`make clippy`).
#
# Handlers and run/vault/voice helpers legitimately thread 8-10 parameters
# (state + actor + ids). The default of 7 is too tight for this codebase, so we
# keep the guard for genuinely oversized signatures instead of disabling it.
too-many-arguments-threshold = 10

View File

@ -2,6 +2,7 @@
name = "lazyboy-api" name = "lazyboy-api"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
@ -14,23 +15,19 @@ axum.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
sqlx.workspace = true sqlx.workspace = true
uuid.workspace = true uuid.workspace = true
chrono.workspace = true chrono.workspace = true
tower-http.workspace = true tower-http.workspace = true
async-trait = "0.1"
rig-core.workspace = true rig-core.workspace = true
base64.workspace = true base64.workspace = true
hmac.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true hex.workspace = true
reqwest.workspace = true reqwest.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
futures-util = "0.3" futures-util = "0.3"
http-body-util = "0.1"
dotenvy = "0.15" dotenvy = "0.15"
fastembed = { version = "6.0.2", default-features = false, features = ["hf-hub-rustls-tls", "ort-load-dynamic"] } fastembed = { version = "6.0.2", default-features = false, features = ["hf-hub-rustls-tls", "ort-load-dynamic"] }
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] } rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
@ -41,3 +38,6 @@ chrono-tz = "0.10"
rand = "0.8" rand = "0.8"
cap-std = "3" cap-std = "3"
[lints]
workspace = true

View File

@ -185,13 +185,13 @@ pub async fn llm_parts(
} }
} }
} else if is_text_mime(&stored.mime_type) { } else if is_text_mime(&stored.mime_type) {
if let Some(bytes) = bytes { if let Some(bytes) = bytes
if let Some(text) = utf8_preview(&bytes) { && let Some(text) = utf8_preview(&bytes)
parts.push(UserContent::text(format!( {
"Contents of {}:\n```\n{text}\n```", parts.push(UserContent::text(format!(
stored.name "Contents of {}:\n```\n{text}\n```",
))); stored.name
} )));
} }
} else { } else {
notes.push( notes.push(
@ -220,12 +220,12 @@ pub async fn sweep_all_inboxes(data_dir: &str) {
continue; continue;
}; };
sweep_cap_inbox(&home, "inbox"); sweep_cap_inbox(&home, "inbox");
if let Ok(bots) = home.open_dir("bots") { if let Ok(bots) = home.open_dir("bots")
if let Ok(entries) = bots.entries() { && let Ok(entries) = bots.entries()
for bot in entries.flatten() { {
if let Ok(dir) = bots.open_dir(bot.file_name()) { for bot in entries.flatten() {
sweep_cap_inbox(&dir, "inbox"); if let Ok(dir) = bots.open_dir(bot.file_name()) {
} sweep_cap_inbox(&dir, "inbox");
} }
} }
} }
@ -351,9 +351,7 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
} }
let path = value.get("path").and_then(Value::as_str).unwrap_or(""); let path = value.get("path").and_then(Value::as_str).unwrap_or("");
if !path.is_empty() { if !path.is_empty() {
let Some(stored) = path.strip_prefix("inbox/") else { let stored = path.strip_prefix("inbox/")?;
return None;
};
if stored.is_empty() || stored == "." || stored == ".." || stored.contains(['/', '\\']) { if stored.is_empty() || stored == "." || stored == ".." || stored.contains(['/', '\\']) {
return None; return None;
} }

View File

@ -45,7 +45,7 @@ impl AuthConfig {
pub fn strong_enough_for_network(&self) -> bool { pub fn strong_enough_for_network(&self) -> bool {
self.token self.token
.as_ref() .as_ref()
.map(|token| token.as_bytes().len() >= 32 && token != "dev-token") .map(|token| token.len() >= 32 && token != "dev-token")
.unwrap_or(false) .unwrap_or(false)
} }
@ -83,14 +83,13 @@ impl AuthConfig {
let key = hex::encode(Sha256::digest(value.as_bytes())); let key = hex::encode(Sha256::digest(value.as_bytes()));
let mut sessions = self.sessions.lock().unwrap(); let mut sessions = self.sessions.lock().unwrap();
sessions.retain(|_, expires| *expires > Instant::now()); sessions.retain(|_, expires| *expires > Instant::now());
if sessions.len() >= 4096 { if sessions.len() >= 4096
if let Some(oldest) = sessions && let Some(oldest) = sessions
.iter() .iter()
.min_by_key(|(_, t)| **t) .min_by_key(|(_, t)| **t)
.map(|(k, _)| k.clone()) .map(|(k, _)| k.clone())
{ {
sessions.remove(&oldest); sessions.remove(&oldest);
}
} }
sessions.insert(key, Instant::now() + Duration::from_secs(604800)); sessions.insert(key, Instant::now() + Duration::from_secs(604800));
Some(format!( Some(format!(

View File

@ -1076,15 +1076,14 @@ async fn pause_idle_computers(state: &AppState) {
if computer_has_active_work(state, &computer.id).await { if computer_has_active_work(state, &computer.id).await {
continue; continue;
} }
if let Some(computer_ref) = computer_ref(&computer) { if let Some(computer_ref) = computer_ref(&computer)
if state && state
.sandbox .sandbox
.suspend(&computer_ref, &idle_adapter(&computer, "idle")) .suspend(&computer_ref, &idle_adapter(&computer, "idle"))
.await .await
.is_err() .is_err()
{ {
continue; continue;
}
} }
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE computers SET state = 'suspended', updated_at = now() "UPDATE computers SET state = 'suspended', updated_at = now()

View File

@ -33,9 +33,7 @@ fn parse_file(path: PathBuf, name: String) -> Option<FileSkill> {
let (header, body) = rest.split_once("\n---")?; let (header, body) = rest.split_once("\n---")?;
( (
header, header,
body.trim_start_matches(|ch| ch == '\n' || ch == '\r') body.trim_start_matches(['\n', '\r']).trim().to_string(),
.trim()
.to_string(),
) )
} else { } else {
("", text.trim().to_string()) ("", text.trim().to_string())

View File

@ -6,6 +6,7 @@ mod file_skills;
mod mcp; mod mcp;
mod mcp_catalog; mod mcp_catalog;
mod memory; mod memory;
mod monitor;
mod retention; mod retention;
mod rooms; mod rooms;
mod routes; mod routes;

View File

@ -570,7 +570,7 @@ async fn create_server(
internal(error.to_string()) internal(error.to_string())
} }
})?; })?;
let mut row = load_row(state.pool(), &actor, &id) let row = load_row(state.pool(), &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.ok_or_else(|| internal("missing row".into()))?; .ok_or_else(|| internal("missing row".into()))?;

View File

@ -28,7 +28,9 @@ pub struct MemoryService {
enum ModelState { enum ModelState {
Uninitialized, Uninitialized,
Ready(TextEmbedding), // Boxed: the embedding model is far larger than the other two variants and
// this enum lives inside an Arc<Mutex<..>> shared by every request.
Ready(Box<TextEmbedding>),
Unavailable, Unavailable,
} }
@ -102,7 +104,7 @@ impl MemoryService {
.with_cache_dir(cache_dir) .with_cache_dir(cache_dir)
.with_show_download_progress(false); .with_show_download_progress(false);
match TextEmbedding::try_new(options) { match TextEmbedding::try_new(options) {
Ok(embedding) => *state = ModelState::Ready(embedding), Ok(embedding) => *state = ModelState::Ready(Box::new(embedding)),
Err(error) => { Err(error) => {
*state = ModelState::Unavailable; *state = ModelState::Unavailable;
return Err(format!("FastEmbed unavailable: {error}")); return Err(format!("FastEmbed unavailable: {error}"));
@ -151,7 +153,7 @@ impl MemoryService {
let content = validate_content(&input.content)?; let content = validate_content(&input.content)?;
validate_importance(input.importance)?; validate_importance(input.importance)?;
let embedding = self.embed(content.clone()).await; let embedding = self.embed(content.clone()).await;
let vector = embedding.as_ref().map(vector_literal); let vector = embedding.as_deref().map(vector_literal);
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let mut tx = pool.begin().await.map_err(|error| error.to_string())?; let mut tx = pool.begin().await.map_err(|error| error.to_string())?;
let item: MemoryItem = sqlx::query_as( let item: MemoryItem = sqlx::query_as(
@ -212,7 +214,7 @@ impl MemoryService {
} }
let limit = limit.unwrap_or(self.top_k).clamp(1, 50); let limit = limit.unwrap_or(self.top_k).clamp(1, 50);
let embedding = self.embed(query.to_string()).await; let embedding = self.embed(query.to_string()).await;
let rows = if let Some(vector) = embedding.as_ref().map(vector_literal) { let rows = if let Some(vector) = embedding.as_deref().map(vector_literal) {
sqlx::query_as( sqlx::query_as(
"SELECT id,session_id,source_run_id,source_message_id,content,importance,revision, "SELECT id,session_id,source_run_id,source_message_id,content,importance,revision,
created_at,updated_at,deleted_at created_at,updated_at,deleted_at
@ -269,7 +271,7 @@ impl MemoryService {
let vector = self let vector = self
.embed(content.clone()) .embed(content.clone())
.await .await
.as_ref() .as_deref()
.map(vector_literal); .map(vector_literal);
let mut tx = pool.begin().await.map_err(|error| error.to_string())?; let mut tx = pool.begin().await.map_err(|error| error.to_string())?;
let item: Option<MemoryItem> = sqlx::query_as( let item: Option<MemoryItem> = sqlx::query_as(
@ -377,7 +379,7 @@ async fn insert_revision(
Ok(()) Ok(())
} }
fn vector_literal(vector: &Vec<f32>) -> String { fn vector_literal(vector: &[f32]) -> String {
format!( format!(
"[{}]", "[{}]",
vector vector

538
crates/api/src/monitor.rs Normal file
View File

@ -0,0 +1,538 @@
//! Observability surface for a run: the activity trail the chat bubble reads,
//! and the plain-language reading of a failure that turns into an action item.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use crate::db::Actor;
use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>);
/// Longest a single string inside an activity payload may get. The trail is a
/// glanceable summary, not a second copy of the transcript.
const MAX_STRING_CHARS: usize = 512;
const DEFAULT_ACTIVITY_LIMIT: i32 = 60;
const MAX_ACTIVITY_LIMIT: i32 = 200;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/runs/{id}/activity", get(activity))
.route("/api/runs/{id}/retry", post(retry))
}
/// Append one line to the run's trail. Diagnostics never fail a run: a write
/// that cannot land is logged and dropped.
pub async fn record(state: &AppState, run_id: &str, kind: &str, payload: Value) {
let result = sqlx::query("INSERT INTO run_activity (run_id,kind,payload) VALUES ($1,$2,$3)")
.bind(run_id)
.bind(kind)
.bind(clamp_strings(&payload, MAX_STRING_CHARS))
.execute(state.pool())
.await;
if let Err(error) = result {
tracing::warn!(run_id, kind, "failed to record run activity: {error}");
}
}
/// One line of text for the trail: single line, bounded, never an image.
pub fn snippet(text: &str, max_chars: usize) -> String {
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= max_chars {
return flat;
}
let mut head: String = flat.chars().take(max_chars).collect();
head.push('…');
head
}
/// Bound every string leaf so one chatty tool result cannot bloat the trail.
pub fn clamp_strings(value: &Value, max_chars: usize) -> Value {
match value {
Value::String(text) if text.chars().count() > max_chars => {
let mut head: String = text.chars().take(max_chars).collect();
head.push('…');
Value::String(head)
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|item| clamp_strings(item, max_chars))
.collect(),
),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, item)| (key.clone(), clamp_strings(item, max_chars)))
.collect(),
),
other => other.clone(),
}
}
/// What the user is told, and what they can do about it. `code` is the stable
/// contract the chat card renders from; the Chinese strings are the fallback
/// body that also lives in the message history.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunFailure {
pub code: &'static str,
pub headline: &'static str,
pub action: &'static str,
pub retryable: bool,
pub needs_settings: bool,
pub needs_computer: bool,
}
impl RunFailure {
fn new(
code: &'static str,
headline: &'static str,
action: &'static str,
retryable: bool,
needs_settings: bool,
needs_computer: bool,
) -> Self {
Self {
code,
headline,
action,
retryable,
needs_settings,
needs_computer,
}
}
}
/// Turn a raw run error into a classified, human-readable failure. Ordering is
/// the policy: the most specific and most actionable cause wins, so a 429 that
/// also mentions a timeout is reported as a quota problem, not a slow network.
pub fn classify_run_error(error: &str) -> RunFailure {
let text = error.to_lowercase();
let has = |needles: &[&str]| needles.iter().any(|needle| text.contains(needle));
if has(&["worker interrupted"]) {
return RunFailure::new(
"interrupted",
"我剛剛被打斷了,最後那個動作可能已經做下去了。",
"先按重試;不確定的話,打開它的畫面確認現狀再繼續。",
true,
false,
false,
);
}
if text.contains("tool ") && text.contains("timed out") {
return RunFailure::new(
"tool_timeout",
"有一個動作卡超過 150 秒,我無法確定它有沒有做完。",
"先打開它的畫面看一眼,再按重試;我不會重複已經成功的步驟。",
true,
false,
false,
);
}
if has(&[
"invalid api key",
"incorrect api key",
"unauthorized",
"authentication",
"forbidden",
"401",
"403",
]) {
return RunFailure::new(
"model_key",
"模型不認得這個 API 金鑰。",
"到「設定 → 模型」重新貼一次金鑰,再按重試。",
false,
true,
false,
);
}
if has(&[
"429",
"rate limit",
"ratelimit",
"too many requests",
"insufficient",
"quota",
"credit",
"billing",
]) {
return RunFailure::new(
"model_quota",
"模型那邊限流了,或是額度已經用完。",
"等一下再按重試;如果是額度用完,到「設定 → 模型」換一個或加值。",
true,
true,
false,
);
}
if has(&[
"model not found",
"unknown model",
"invalid model",
"does not exist",
"404",
]) {
return RunFailure::new(
"model_unknown",
"這個模型名稱找不到,可能已下架或打錯了。",
"到「設定 → 模型」選一個存在的模型,再按重試。",
false,
true,
false,
);
}
if has(&["timed out", "timeout", "逾時"]) {
return RunFailure::new(
"model_timeout",
"模型太久沒有回話(超過 165 秒)。",
"先按重試;每次都發生的話,到「設定 → 模型」換一個快一點的模型。",
true,
true,
false,
);
}
if has(&[
"error sending request",
"failed to connect",
"connection refused",
"dns",
"host not found",
"certificate",
"tls",
"network",
]) {
return RunFailure::new(
"network",
"連不到模型服務,網路或位址不對。",
"確認網路與「設定 → 模型」的 Base URL再按重試。",
true,
true,
false,
);
}
if has(&[
"no such container",
"container",
"docker",
"computer not found",
"computer is not running",
]) {
return RunFailure::new(
"computer_gone",
"它的電腦不見了或已經停止。",
"先把電腦啟動,再按重試。",
true,
false,
true,
);
}
if text.contains("lease") {
return RunFailure::new(
"lease_lost",
"這份工作被另一邊接手過,我手上這份已經失效。",
"按重試重新開始這一步就好。",
true,
false,
false,
);
}
RunFailure::new(
"unknown",
"我卡住了,這一輪沒有完成。",
"按重試看看;需要的話把下面的記錄複製起來給我。",
true,
false,
false,
)
}
/// The sentence that goes into the chat: what happened, how far the run got,
/// and the one thing the human can do next.
pub fn failure_message(failure: &RunFailure, last_step: Option<&str>, turn: Option<i64>) -> String {
let progress = match (last_step, turn) {
(Some(step), Some(n)) if n > 0 && !step.trim().is_empty() => {
format!("我最後在做:{}(第 {n} 輪)。", snippet(step, 80))
}
(Some(step), _) if !step.trim().is_empty() => {
format!("我最後在做:{}", snippet(step, 80))
}
_ => "我還沒有開始動手。".to_string(),
};
format!("{} {} {}", failure.headline, progress, failure.action)
}
#[derive(Debug, serde::Deserialize)]
struct ActivityQuery {
after: Option<i64>,
limit: Option<i32>,
}
#[derive(Debug, sqlx::FromRow)]
struct RunRow {
status: String,
step: Option<String>,
turn: Option<i64>,
turn_limit: Option<i64>,
error: Option<String>,
started_at: Option<DateTime<Utc>>,
completed_at: Option<DateTime<Utc>>,
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state.bootstrap().await.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": error.to_string()})),
)
})
}
/// The bubble's whole payload: where the run stands right now plus its trail.
/// `after` pages forward; the first read returns the newest window, oldest
/// first, so the UI renders it and then tails from the last id.
async fn activity(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<ActivityQuery>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let run = sqlx::query_as::<_, RunRow>(
"SELECT status, checkpoint->>'step' AS step, (checkpoint->>'turn')::bigint AS turn,
(checkpoint->>'turnLimit')::bigint AS turn_limit, error, started_at, completed_at
FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let Some(run) = run else {
return Err(not_found("run not found"));
};
let limit = query
.limit
.unwrap_or(DEFAULT_ACTIVITY_LIMIT)
.clamp(1, MAX_ACTIVITY_LIMIT);
let rows = sqlx::query_as::<_, (i64, String, Value, DateTime<Utc>)>(
"SELECT id, kind, payload, created_at FROM (
SELECT id, kind, payload, created_at FROM run_activity
WHERE run_id=$1 AND ($2::bigint IS NULL OR id>$2)
ORDER BY id DESC LIMIT $3
) recent ORDER BY id ASC",
)
.bind(&id)
.bind(query.after)
.bind(limit)
.fetch_all(state.pool())
.await
.map_err(internal)?;
let error = run
.error
.as_deref()
.filter(|raw| !raw.trim().is_empty())
.map(|raw| {
let failure = classify_run_error(raw);
json!({"code": failure.code, "headline": failure.headline, "raw": raw})
});
let elapsed = run.started_at.map(|started| {
(run.completed_at.unwrap_or_else(Utc::now) - started)
.num_milliseconds()
.max(0) as u64
});
Ok(Json(json!({
"runId": id,
"status": run.status,
"turn": run.turn,
"turnLimit": run.turn_limit,
"step": run.step,
"elapsedMs": elapsed,
"error": error,
"activity": rows.into_iter().map(|(row_id, kind, payload, created_at)| {
let mut entry = payload;
if let Some(map) = entry.as_object_mut() {
map.insert("id".to_string(), json!(row_id));
map.insert("kind".to_string(), json!(kind));
map.insert("createdAt".to_string(), json!(created_at.to_rfc3339()));
}
entry
}).collect::<Vec<_>>(),
})))
}
/// Re-queue a run that stopped on an error, keeping its harness checkpoint so
/// it continues where it stopped instead of starting the task over.
async fn retry(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let exists: Option<String> =
sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
if exists.is_none() {
return Err(not_found("run not found"));
}
let queued: Option<String> = sqlx::query_scalar(
"UPDATE runs SET status='queued', retry_count=0, error=NULL, completed_at=NULL,
lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND space_id=$2 AND user_id=$3
AND status IN ('failed','cancelled')
AND NOT EXISTS (
SELECT 1 FROM runs a
WHERE a.bot_id=runs.bot_id AND a.id<>runs.id
AND a.status IN ('leased','running','waiting_input','waiting_takeover')
AND (a.lease_expires_at IS NULL OR a.lease_expires_at >= now())
)
RETURNING thread_id",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
if queued.is_none() {
return Err((
StatusCode::CONFLICT,
Json(json!({"message": "run is not retryable"})),
));
}
record(&state, &id, "run", json!({"event": "retry"})).await;
tracing::info!(run_id = id.as_str(), "run re-queued from the chat");
Ok(Json(json!({"ok": true, "runId": id})))
}
fn not_found(message: &str) -> ApiError {
(StatusCode::NOT_FOUND, Json(json!({"message": message})))
}
fn internal(error: sqlx::Error) -> ApiError {
tracing::error!("monitor: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "internal error"})),
)
}
#[cfg(test)]
mod tests {
use super::{RunFailure, clamp_strings, classify_run_error, failure_message, snippet};
use serde_json::json;
fn code(error: &str) -> String {
classify_run_error(error).code.to_string()
}
#[test]
fn every_failure_offers_an_action_and_a_code() {
for error in [
"boom",
"HTTP 401",
"model request timed out after 165 seconds",
"no such container",
] {
let failure = classify_run_error(error);
assert!(!failure.code.is_empty(), "{error}");
assert!(!failure.action.is_empty(), "{error}");
assert!(!failure.headline.is_empty(), "{error}");
}
}
#[test]
fn auth_quota_and_unknown_model_are_told_apart() {
assert_eq!(
code("Api error 401 Unauthorized: invalid api key"),
"model_key"
);
assert_eq!(code("HTTP status 403 Forbidden"), "model_key");
assert_eq!(code("status 429 Too Many Requests"), "model_quota");
assert_eq!(code("insufficient quota for this project"), "model_quota");
assert_eq!(code("The model gpt-9 does not exist"), "model_unknown");
assert_eq!(code("HTTP status 404 Not Found"), "model_unknown");
}
#[test]
fn the_specific_timeouts_win_over_the_generic_one() {
assert_eq!(
code("tool browser timed out after 150 seconds"),
"tool_timeout"
);
assert_eq!(
code("model request timed out after 165 seconds"),
"model_timeout"
);
assert_eq!(code("AI 回應逾時150 秒)"), "model_timeout");
}
#[test]
fn infrastructure_and_network_failures_are_separate_from_the_model() {
assert_eq!(code("no such container: lazyboy-bot-1"), "computer_gone");
assert_eq!(code("computer not found"), "computer_gone");
assert_eq!(code("error sending request: dns failure"), "network");
assert_eq!(code("run lease was lost"), "lease_lost");
assert_eq!(
code("Worker interrupted after tool execution; inspect current state"),
"interrupted"
);
assert_eq!(code("something odd happened"), "unknown");
}
#[test]
fn a_retryable_answer_never_promises_a_setting_that_cannot_help() {
assert!(!classify_run_error("401 invalid api key").retryable);
assert!(classify_run_error("401 invalid api key").needs_settings);
assert!(classify_run_error("no such container").needs_computer);
assert!(!classify_run_error("no such container").needs_settings);
assert!(classify_run_error("status 429 rate limit").retryable);
}
#[test]
fn a_failure_sentence_names_the_last_step_and_turn() {
let failure = RunFailure::new("tool_timeout", "卡住了。", "按重試。", true, false, false);
let said = failure_message(&failure, Some("browser: click #12"), Some(7));
assert!(said.contains("卡住了。"), "{said}");
assert!(said.contains("browser: click #12"), "{said}");
assert!(said.contains("第 7 輪"), "{said}");
assert!(said.contains("按重試。"), "{said}");
}
#[test]
fn a_failure_before_any_work_says_so_instead_of_inventing_a_step() {
let failure = RunFailure::new("model_key", "金鑰不對。", "改金鑰。", false, true, false);
assert!(failure_message(&failure, None, None).contains("我還沒有開始動手。"));
assert!(failure_message(&failure, Some(" "), Some(0)).contains("我還沒有開始動手。"));
assert!(failure_message(&failure, Some("思考中"), None).contains("思考中"));
}
#[test]
fn snippets_stay_single_line_and_bounded() {
assert_eq!(snippet(" 多行\n文字\t在這裡 ", 40), "多行 文字 在這裡");
let long = "".repeat(500);
let cut = snippet(&long, 200);
assert_eq!(cut.chars().count(), 201);
assert!(cut.ends_with('…'));
}
#[test]
fn payloads_are_clamped_at_every_string_leaf() {
let clamped = clamp_strings(
&json!({"error": "x".repeat(900), "nested": {"raw": "y".repeat(900)}, "n": 7}),
50,
);
assert_eq!(clamped["error"].as_str().unwrap().chars().count(), 51);
assert_eq!(
clamped["nested"]["raw"].as_str().unwrap().chars().count(),
51
);
assert_eq!(clamped["n"].as_i64(), Some(7));
}
}

View File

@ -9,11 +9,13 @@ fn days(name: &str, default: i32) -> i32 {
} }
pub async fn retention_loop(state: AppState) { pub async fn retention_loop(state: AppState) {
let recording_days = days("LAZYBOY_RECORDING_RETENTION_DAYS", 30);
let rules = [ let rules = [
("events", include_str!("retention/events.sql"), days("LAZYBOY_EVENT_RETENTION_DAYS", 30)), ("events", include_str!("retention/events.sql"), days("LAZYBOY_EVENT_RETENTION_DAYS", 30)),
("checkpoints", include_str!("retention/checkpoints.sql"), days("LAZYBOY_CHECKPOINT_RETENTION_DAYS", 7)), ("checkpoints", include_str!("retention/checkpoints.sql"), days("LAZYBOY_CHECKPOINT_RETENTION_DAYS", 7)),
("runs", include_str!("retention/runs.sql"), days("LAZYBOY_RUN_RETENTION_DAYS", 90)), ("runs", include_str!("retention/runs.sql"), days("LAZYBOY_RUN_RETENTION_DAYS", 90)),
("recordings", include_str!("retention/recordings.sql"), days("LAZYBOY_RECORDING_RETENTION_DAYS", 30)), ("run_activity", include_str!("retention/run_activity.sql"), days("LAZYBOY_RUN_ACTIVITY_RETENTION_DAYS", 7)),
("recordings", include_str!("retention/recordings.sql"), recording_days),
("revisions", include_str!("retention/revisions.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)), ("revisions", include_str!("retention/revisions.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)),
("deleted_memories", include_str!("retention/deleted_memories.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)), ("deleted_memories", include_str!("retention/deleted_memories.sql"), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90)),
("leases", include_str!("retention/leases.sql"), 7), ("leases", include_str!("retention/leases.sql"), 7),
@ -32,7 +34,7 @@ pub async fn retention_loop(state: AppState) {
} }
if removed > 0 { tracing::info!(name, rows=removed, "retention cleaned expired diagnostics"); } if removed > 0 { tracing::info!(name, rows=removed, "retention cleaned expired diagnostics"); }
} }
if let Err(error) = clean_frames(&state, rules[3].2).await { if let Err(error) = clean_frames(&state, recording_days).await {
tracing::warn!(%error, "recording file retention failed; retry next hour"); tracing::warn!(%error, "recording file retention failed; retry next hour");
} }
if let Ok(bytes) = sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())").fetch_one(state.pool()).await { if let Ok(bytes) = sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())").fetch_one(state.pool()).await {

View File

@ -0,0 +1,4 @@
DELETE FROM run_activity WHERE id IN (
SELECT id FROM run_activity WHERE created_at < now() - make_interval(days => $1)
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED
)

View File

@ -64,13 +64,21 @@ async fn members_for(state: &AppState, room_id: &str) -> Result<Vec<RoomMember>,
Ok(rows.into_iter().map(member_from_row).collect()) Ok(rows.into_iter().map(member_from_row).collect())
} }
/// `room_from_id` projection: id, name, last message time, preview, unread count.
type RoomSummaryRow = (
String,
String,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
i64,
);
async fn room_from_id( async fn room_from_id(
state: &AppState, state: &AppState,
actor: &Actor, actor: &Actor,
id: &str, id: &str,
) -> Result<Option<Room>, sqlx::Error> { ) -> Result<Option<Room>, sqlx::Error> {
let row: Option<(String, String, Option<chrono::DateTime<chrono::Utc>>, Option<String>, i64)> = let row: Option<RoomSummaryRow> = sqlx::query_as(
sqlx::query_as(
"SELECT r.id, r.name, "SELECT r.id, r.name,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id), (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id),
(SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id (SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id

View File

@ -15,6 +15,7 @@ pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.merge(crate::sessions::router()) .merge(crate::sessions::router())
.merge(crate::memory::router()) .merge(crate::memory::router())
.merge(crate::monitor::router())
.merge(crate::rooms::router()) .merge(crate::rooms::router())
.merge(crate::mcp::router()) .merge(crate::mcp::router())
.merge(crate::workspace::router()) .merge(crate::workspace::router())
@ -347,17 +348,16 @@ async fn delete_bot(
if let Some(computer) = computer if let Some(computer) = computer
.as_ref() .as_ref()
.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) .filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
&& let Some(computer_ref) = computer::computer_ref(computer)
{ {
if let Some(computer_ref) = computer::computer_ref(computer) { state
state .sandbox
.sandbox .destroy(
.destroy( &computer_ref,
&computer_ref, &computer::adapter_context(&actor, &id, "delete-bot"),
&computer::adapter_context(&actor, &id, "delete-bot"), )
) .await
.await .map_err(|error| bad_gateway(error.to_string()))?;
.map_err(|error| bad_gateway(error.to_string()))?;
}
} }
let mut tx = state.pool().begin().await.map_err(internal_error)?; let mut tx = state.pool().begin().await.map_err(internal_error)?;

View File

@ -236,7 +236,7 @@ pub async fn send(
.bind(&member_run) .bind(&member_run)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(member_id) .bind(member_id)
.bind(&thread_id) .bind(thread_id)
.bind(&actor.user_id) .bind(&actor.user_id)
.bind(&stored_body) .bind(&stored_body)
.bind(json!({"messageSeq":seq})) .bind(json!({"messageSeq":seq}))
@ -296,19 +296,43 @@ pub async fn send(
})) }))
} }
/// `worker_loop` claim projection: run id, bot id, thread id, prompt, user id, space id.
type RetryCandidateRow = (String, String, String, String, String, String);
pub async fn worker_loop(state: AppState) { pub async fn worker_loop(state: AppState) {
let inflight = Arc::new(tokio::sync::Semaphore::new(16)); let inflight = Arc::new(tokio::sync::Semaphore::new(16));
let lease_owner = format!("api-{}", Uuid::new_v4()); let lease_owner = format!("api-{}", Uuid::new_v4());
loop { loop {
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
let _ = sqlx::query("UPDATE runs SET status='failed',error='Worker interrupted after tool execution; inspect current state before continuing.',completed_at=now(),lease_owner=NULL,lease_expires_at=NULL WHERE status IN ('leased','running') AND lease_expires_at<now() AND COALESCE((checkpoint->>'toolsStarted')::boolean,false)") let interrupted: Vec<(String, String, String)> = sqlx::query_as(
.execute(state.pool()).await; "WITH doomed AS (
UPDATE runs SET status='failed',error=$1,completed_at=now(),
lease_owner=NULL, lease_expires_at=NULL
WHERE status IN ('leased','running') AND lease_expires_at<now()
AND COALESCE((checkpoint->>'toolsStarted')::boolean,false)
RETURNING id, thread_id, bot_id
)
SELECT id, thread_id, bot_id FROM doomed LIMIT 50",
)
.bind(INTERRUPTED_ERROR)
.fetch_all(state.pool())
.await
.unwrap_or_default();
for (orphan_run, orphan_thread, orphan_bot) in interrupted {
report_run_failure(
&state,
&orphan_run,
&orphan_thread,
&orphan_bot,
INTERRUPTED_ERROR,
)
.await;
}
let Ok(permit) = inflight.clone().try_acquire_owned() else { let Ok(permit) = inflight.clone().try_acquire_owned() else {
continue; continue;
}; };
let queued: Result<Option<(String, String, String, String, String, String)>, _> = let queued: Result<Option<RetryCandidateRow>, _> = sqlx::query_as(
sqlx::query_as( "WITH candidate AS (
"WITH candidate AS (
SELECT r.id SELECT r.id
FROM runs r FROM runs r
WHERE r.retry_count < r.max_retries WHERE r.retry_count < r.max_retries
@ -335,10 +359,10 @@ pub async fn worker_loop(state: AppState) {
lease_fence=lease_fence+1, retry_count=retry_count+1, updated_at=now() lease_fence=lease_fence+1, retry_count=retry_count+1, updated_at=now()
FROM candidate c WHERE r.id=c.id FROM candidate c WHERE r.id=c.id
RETURNING r.id,r.bot_id,r.thread_id,r.prompt,r.user_id,r.space_id", RETURNING r.id,r.bot_id,r.thread_id,r.prompt,r.user_id,r.space_id",
) )
.bind(&lease_owner) .bind(&lease_owner)
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await; .await;
let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else { let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else {
drop(permit); drop(permit);
continue; continue;
@ -371,14 +395,7 @@ pub async fn worker_loop(state: AppState) {
.ok() .ok()
.flatten(); .flatten();
if next_status.as_deref() == Some("failed") { if next_status.as_deref() == Some("failed") {
let _ = append_bot_message( report_run_failure(&state, &run_id, &thread_id, &bot_id, &error).await;
&state,
&thread_id,
&run_id,
&bot_id,
&format!("Run failed after retries: {error}"),
)
.await;
let _ = crate::sessions::append_event( let _ = crate::sessions::append_event(
&state, &state,
&thread_id, &thread_id,
@ -432,6 +449,13 @@ async fn execute_run(
} }
let _ = crate::sessions::append_event(state, thread_id, "run.started", json!({"runId":run_id})) let _ = crate::sessions::append_event(state, thread_id, "run.started", json!({"runId":run_id}))
.await; .await;
crate::monitor::record(
state,
run_id,
"run",
json!({"event": "started", "task": crate::monitor::snippet(prompt, 160)}),
)
.await;
let bot = state let bot = state
.db .db
@ -656,6 +680,13 @@ async fn execute_run(
// model call with no chance of accidentally invoking any capability. // model call with no chance of accidentally invoking any capability.
defs.clear(); defs.clear();
tracing::info!(run_id, "chat-only turn: all tools withheld"); tracing::info!(run_id, "chat-only turn: all tools withheld");
crate::monitor::record(
state,
run_id,
"notice",
json!({"text": "這一輪是對白,工具先收起來(不會動到電腦)。"}),
)
.await;
} }
// Taught skills run long (a 24-page course is 24 clicks); plain chats stay // Taught skills run long (a 24-page course is 24 clicks); plain chats stay
// bounded tighter so a confused model cannot burn budget for as long. // bounded tighter so a confused model cannot burn budget for as long.
@ -668,6 +699,10 @@ async fn execute_run(
} else { } else {
ExecutionMode::Bounded(40) ExecutionMode::Bounded(40)
}; };
let turn_limit = match execution_mode {
ExecutionMode::Goal => None,
ExecutionMode::Bounded(limit) => Some(limit as i64),
};
let mut nudges: u32 = 0; let mut nudges: u32 = 0;
let mut screenshots: u32 = 0; let mut screenshots: u32 = 0;
let mut screenshot_bytes: u64 = 0; let mut screenshot_bytes: u64 = 0;
@ -685,22 +720,21 @@ async fn execute_run(
} }
} }
let mut pending = Message::User { content: first }; let mut pending = Message::User { content: first };
if !resume_after_takeover { if !resume_after_takeover
if let (Some(saved_history), Some(saved_pending)) = ( && let (Some(saved_history), Some(saved_pending)) = (
checkpoint.get("harnessHistory"), checkpoint.get("harnessHistory"),
checkpoint.get("harnessPending"), checkpoint.get("harnessPending"),
) { )
if let (Ok(restored), Ok(mut next)) = ( && let (Ok(restored), Ok(mut next)) = (
serde_json::from_value::<Vec<Message>>(saved_history.clone()), serde_json::from_value::<Vec<Message>>(saved_history.clone()),
serde_json::from_value::<Message>(saved_pending.clone()), serde_json::from_value::<Message>(saved_pending.clone()),
) { )
history = restored; {
if let Message::User { content } = &mut next { history = restored;
content.push(UserContent::text("Resumed after a completed tool batch. Do not repeat completed actions. Observe current browser/desktop before any new mutation; prior element references may be stale.")); if let Message::User { content } = &mut next {
} content.push(UserContent::text("Resumed after a completed tool batch. Do not repeat completed actions. Observe current browser/desktop before any new mutation; prior element references may be stale."));
pending = next;
}
} }
pending = next;
} }
let mut final_text = String::new(); let mut final_text = String::new();
@ -727,6 +761,13 @@ async fn execute_run(
{ {
Ok(items) => state.memory.durable_block(&items), Ok(items) => state.memory.durable_block(&items),
Err(error) => { Err(error) => {
crate::monitor::record(
state,
run_id,
"notice",
json!({"text": format!("記憶讀取失敗,這輪不用記憶:{error}")}),
)
.await;
tracing::warn!("memory retrieval failed for run {run_id}: {error}"); tracing::warn!("memory retrieval failed for run {run_id}: {error}");
String::new() String::new()
} }
@ -791,17 +832,17 @@ async fn execute_run(
preamble.push_str("\n\n"); preamble.push_str("\n\n");
preamble.push_str(&file_skill_index); preamble.push_str(&file_skill_index);
} }
if let Ok(accounts) = crate::vault::list_on(state.pool(), actor, bot_id).await { if let Ok(accounts) = crate::vault::list_on(state.pool(), actor, bot_id).await
if !accounts.is_empty() { && !accounts.is_empty()
let names = accounts {
.iter() let names = accounts
.map(|item| format!("{} ({})", item.site, item.username)) .iter()
.collect::<Vec<_>>() .map(|item| format!("{} ({})", item.site, item.username))
.join(", "); .collect::<Vec<_>>()
preamble.push_str(&format!( .join(", ");
preamble.push_str(&format!(
"\n\nSaved logins (passwords are not shown): {names}. At a login wall call use_saved_login with the matching accountId from list_accounts." "\n\nSaved logins (passwords are not shown): {names}. At a login wall call use_saved_login with the matching accountId from list_accounts."
)); ));
}
} }
} }
@ -837,6 +878,16 @@ async fn execute_run(
guidance.join("\n") guidance.join("\n")
) )
}; };
crate::monitor::record(
state,
run_id,
"notice",
json!({
"turn": turns,
"text": format!("收到新指示:{}", crate::monitor::snippet(&guidance.join(" / "), 160)),
}),
)
.await;
if let Message::User { content } = &mut pending { if let Message::User { content } = &mut pending {
content.push(UserContent::text(text)); content.push(UserContent::text(text));
} }
@ -858,7 +909,7 @@ async fn execute_run(
.await; .await;
} }
drop_history_screenshots(&mut history, &pending); drop_history_screenshots(&mut history, &pending);
set_run_step(state, run_id, MODEL_STEP).await; set_run_progress(state, run_id, MODEL_STEP, turns, turn_limit).await;
let model_started = std::time::Instant::now(); let model_started = std::time::Instant::now();
let content = tokio::select! { let content = tokio::select! {
halt = wait_for_halt(state, run_id) => { halt = wait_for_halt(state, run_id) => {
@ -875,7 +926,14 @@ async fn execute_run(
) )
.await; .await;
} }
result = complete_with_retry(&model, pending.clone(), &preamble, &history, &defs) => result result = complete_with_retry(
&model,
pending.clone(),
&preamble,
&history,
&defs,
Trace { state, run_id, turn: turns },
) => result
}?; }?;
let assistant = Message::Assistant { let assistant = Message::Assistant {
id: None, id: None,
@ -885,20 +943,37 @@ async fn execute_run(
history.push(assistant); history.push(assistant);
let mut calls = Vec::new(); let mut calls = Vec::new();
let mut turn_text = String::new();
for item in &content { for item in &content {
match item { match item {
AssistantContent::Text(text) => final_text.push_str(&text.text), AssistantContent::Text(text) => {
final_text.push_str(&text.text);
turn_text.push_str(&text.text);
}
AssistantContent::ToolCall(call) => calls.push(call.clone()), AssistantContent::ToolCall(call) => calls.push(call.clone()),
_ => {} _ => {}
} }
} }
let model_elapsed = model_started.elapsed().as_millis() as u64;
tracing::info!( tracing::info!(
run_id, run_id,
turn = turns, turn = turns,
elapsed_ms = model_started.elapsed().as_millis() as u64, elapsed_ms = model_elapsed,
tool_calls = calls.len(), tool_calls = calls.len(),
"model turn" "model turn"
); );
crate::monitor::record(
state,
run_id,
"model",
json!({
"turn": turns,
"elapsedMs": model_elapsed,
"toolCalls": calls.len(),
"text": crate::monitor::snippet(&turn_text, 200),
}),
)
.await;
if calls.is_empty() { if calls.is_empty() {
// A model that quits a playbook early, or parrots an earlier reply // A model that quits a playbook early, or parrots an earlier reply
// instead of describing the current screen, gets pushed back to // instead of describing the current screen, gets pushed back to
@ -946,6 +1021,22 @@ async fn execute_run(
verify = verify_chosen, verify = verify_chosen,
"nudging model back to tools" "nudging model back to tools"
); );
crate::monitor::record(
state,
run_id,
"notice",
json!({
"turn": turns,
"text": if verify_chosen {
format!("要求模型先證明做完了才准結束(第 {nudges} 次攔下)。")
} else if parroted {
format!("模型重複了舊的回答,叫它看現在畫面繼續做(第 {nudges} 次)。")
} else {
format!("模型想用一句話收尾,叫它用工具繼續(第 {nudges} 次)。")
},
}),
)
.await;
earlier_replies.push(final_text.trim().to_string()); earlier_replies.push(final_text.trim().to_string());
final_text.clear(); final_text.clear();
let mut content = vec![UserContent::text(text)]; let mut content = vec![UserContent::text(text)];
@ -1047,13 +1138,14 @@ async fn execute_run(
); );
did_work = true; did_work = true;
let step = describe_step(&name, &call.function.arguments); let step = describe_step(&name, &call.function.arguments);
set_run_step(state, run_id, &step).await; set_run_progress(state, run_id, &step, turns, turn_limit).await;
let fence=sqlx::query("UPDATE runs SET checkpoint=COALESCE(checkpoint,'{}'::jsonb)||jsonb_build_object('toolsStarted',true) WHERE id=$1 AND lease_owner=$2 AND status='running'") let fence=sqlx::query("UPDATE runs SET checkpoint=COALESCE(checkpoint,'{}'::jsonb)||jsonb_build_object('toolsStarted',true) WHERE id=$1 AND lease_owner=$2 AND status='running'")
.bind(run_id).bind(lease_owner).execute(state.pool()).await.map_err(|e| e.to_string())?; .bind(run_id).bind(lease_owner).execute(state.pool()).await.map_err(|e| e.to_string())?;
if fence.rows_affected() != 1 { if fence.rows_affected() != 1 {
return Err("run lease lost before tool dispatch".into()); return Err("run lease lost before tool dispatch".into());
} }
let tool_started = std::time::Instant::now(); let tool_started = std::time::Instant::now();
let mut tool_timed_out = false;
let outcome = tokio::select! { let outcome = tokio::select! {
halt = wait_for_halt(state, run_id) => { halt = wait_for_halt(state, run_id) => {
return finish_halt( return finish_halt(
@ -1076,12 +1168,15 @@ async fn execute_run(
Ok(outcome) => outcome, Ok(outcome) => outcome,
// A slow tool is a normal turn, not a dead run: say the // A slow tool is a normal turn, not a dead run: say the
// effects are unknown and let the model re-observe. // effects are unknown and let the model re-observe.
Err(_) => ToolOutcome { Err(_) => {
tool_timed_out = true;
ToolOutcome {
text: format!("tool {name} timed out after 150 seconds. Its effects are unknown: observe the current screen or files before anything else, and never repeat a step that already worked."), text: format!("tool {name} timed out after 150 seconds. Its effects are unknown: observe the current screen or files before anything else, and never repeat a step that already worked."),
image: None, image: None,
pause: false, pause: false,
blocks: Vec::new(), blocks: Vec::new(),
}, }
}
} }
}; };
tracing::info!( tracing::info!(
@ -1094,6 +1189,20 @@ async fn execute_run(
pause = outcome.pause, pause = outcome.pause,
"tool call" "tool call"
); );
crate::monitor::record(
state,
run_id,
"tool",
json!({
"turn": turns,
"name": name.clone(),
"step": step.clone(),
"status": tool_status(tool_timed_out, outcome.pause, &outcome.text),
"elapsedMs": tool_started.elapsed().as_millis() as u64,
"snippet": crate::monitor::snippet(&outcome.text, 200),
}),
)
.await;
// xAI rejects images inside tool results. Attach a changed // xAI rejects images inside tool results. Attach a changed
// screenshot as a following user image instead. // screenshot as a following user image instead.
if let Some(image) = outcome.image { if let Some(image) = outcome.image {
@ -1161,7 +1270,16 @@ async fn execute_run(
results.extend(screenshot_parts(png)); results.extend(screenshot_parts(png));
} }
pending = Message::User { content: results }; pending = Message::User { content: results };
save_harness_checkpoint(state, run_id, lease_owner, &history, &pending, turns, steering_seq).await?; save_harness_checkpoint(
state,
run_id,
lease_owner,
&history,
&pending,
turns,
steering_seq,
)
.await?;
} }
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1") let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1")
@ -1214,8 +1332,21 @@ async fn execute_run(
} }
let needs_input = goal_mode && goal_outcome(&final_text) == GoalOutcome::NeedsInput; let needs_input = goal_mode && goal_outcome(&final_text) == GoalOutcome::NeedsInput;
if needs_input { if needs_input {
let next = Message::User { content: vec![UserContent::text("The goal was paused for required user input. Read the user's new information and continue from completed work.")] }; let next = Message::User {
save_harness_checkpoint(state, run_id, lease_owner, &history, &next, turns, steering_seq).await?; content: vec![UserContent::text(
"The goal was paused for required user input. Read the user's new information and continue from completed work.",
)],
};
save_harness_checkpoint(
state,
run_id,
lease_owner,
&history,
&next,
turns,
steering_seq,
)
.await?;
} }
let final_text = final_text let final_text = final_text
.replace("[GOAL_COMPLETE]", "") .replace("[GOAL_COMPLETE]", "")
@ -1238,6 +1369,16 @@ async fn execute_run(
if completed.rows_affected() != 1 { if completed.rows_affected() != 1 {
return Err("run lease was lost before completion".into()); return Err("run lease was lost before completion".into());
} }
crate::monitor::record(
state,
run_id,
"run",
json!({
"event": if needs_input { "waiting_input" } else { "completed" },
"turns": turns,
}),
)
.await;
let click_misses = *ctx.click_misses.lock().unwrap(); let click_misses = *ctx.click_misses.lock().unwrap();
let takeover = *ctx.takeover_requested.lock().unwrap(); let takeover = *ctx.takeover_requested.lock().unwrap();
record_run_metrics( record_run_metrics(
@ -1331,12 +1472,21 @@ fn retryable_run_error(error: &str) -> bool {
!matches!(code, 400..=499 if !matches!(code, 408 | 409 | 425 | 429)) !matches!(code, 400..=499 if !matches!(code, 408 | 409 | 425 | 429))
} }
/// Where a helper that is not the run loop itself writes its diagnostics.
#[derive(Clone, Copy)]
struct Trace<'a> {
state: &'a AppState,
run_id: &'a str,
turn: u32,
}
async fn complete_with_retry( async fn complete_with_retry(
model: &DynModel, model: &DynModel,
pending: Message, pending: Message,
preamble: &str, preamble: &str,
history: &[Message], history: &[Message],
defs: &[ToolDefinition], defs: &[ToolDefinition],
trace: Trace<'_>,
) -> Result<Vec<AssistantContent>, String> { ) -> Result<Vec<AssistantContent>, String> {
let mut last = String::new(); let mut last = String::new();
for attempt in 0..3 { for attempt in 0..3 {
@ -1349,6 +1499,18 @@ async fn complete_with_retry(
match result { match result {
Ok(Ok(content)) => return Ok(content), Ok(Ok(content)) => return Ok(content),
Ok(Err(error)) => { Ok(Err(error)) => {
crate::monitor::record(
trace.state,
trace.run_id,
"retry",
json!({
"turn": trace.turn,
"attempt": attempt + 1,
"error": error,
"gaveUp": !retryable_run_error(&error),
}),
)
.await;
if !retryable_run_error(&error) { if !retryable_run_error(&error) {
return Err(error); return Err(error);
} }
@ -1360,8 +1522,19 @@ async fn complete_with_retry(
last = error; last = error;
} }
Err(_) => { Err(_) => {
tracing::warn!(attempt = attempt + 1, "model attempt exceeded its 165 second budget"); let error = "model request timed out after 165 seconds";
last = "model request timed out after 165 seconds".into(); crate::monitor::record(
trace.state,
trace.run_id,
"retry",
json!({"turn": trace.turn, "attempt": attempt + 1, "error": error}),
)
.await;
tracing::warn!(
attempt = attempt + 1,
"model attempt exceeded its 165 second budget"
);
last = error.into();
} }
} }
if attempt < 2 { if attempt < 2 {
@ -1702,6 +1875,13 @@ async fn finish_halt(
.bind(run_id) .bind(run_id)
.execute(state.pool()) .execute(state.pool())
.await; .await;
crate::monitor::record(
state,
run_id,
"run",
json!({"event": "paused", "reason": "takeover", "turns": turns}),
)
.await;
let click_misses = *ctx.click_misses.lock().unwrap(); let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics( record_run_metrics(
state, state,
@ -1792,6 +1972,18 @@ async fn pause_for_answer(
if paused.rows_affected() != 1 { if paused.rows_affected() != 1 {
return Err("run lease was lost before pausing".into()); return Err("run lease was lost before pausing".into());
} }
crate::monitor::record(
state,
run_id,
"run",
json!({
"event": "paused",
"reason": req.reason.as_str(),
"turns": req.turns,
"limit": req.limit,
}),
)
.await;
append_bot_message_with( append_bot_message_with(
state, state,
thread_id, thread_id,
@ -2369,7 +2561,7 @@ fn is_greeting(normalized: &str) -> bool {
"🙂", "🙂",
"😀", "😀",
]; ];
if EXACT.iter().any(|item| normalized == *item) { if EXACT.contains(&normalized) {
return true; return true;
} }
const PREFIXES: &[&str] = &["hi ", "hey ", "hello ", "", "你好"]; const PREFIXES: &[&str] = &["hi ", "hey ", "hello ", "", "你好"];
@ -2641,6 +2833,113 @@ fn describe_step(name: &str, args: &Value) -> String {
} }
} }
/// The error the claim sweep writes when a worker died holding a tool open.
const INTERRUPTED_ERROR: &str =
"Worker interrupted after tool execution; inspect current state before continuing.";
/// Step plus turn counter: what the bubble header reads out of the checkpoint.
pub(crate) async fn set_run_progress(
state: &AppState,
run_id: &str,
step: &str,
turn: u32,
limit: Option<i64>,
) {
let result = sqlx::query(
"UPDATE runs SET checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|| jsonb_build_object('step', $2::text, 'stepAt', now(),
'turn', $3::bigint, 'turnLimit', $4::bigint),
updated_at = now()
WHERE id = $1",
)
.bind(run_id)
.bind(step)
.bind(turn as i64)
.bind(limit)
.execute(state.pool())
.await;
if let Err(error) = result {
tracing::warn!(run_id, "failed to record run progress: {error}");
}
}
/// Tell the human a run died, in their language, with the one next action. The
/// raw error stays available through the run activity endpoint.
async fn report_run_failure(
state: &AppState,
run_id: &str,
thread_id: &str,
bot_id: &str,
error: &str,
) {
let progress: Option<(Option<String>, Option<i64>)> = sqlx::query_as(
"SELECT checkpoint->>'step', (checkpoint->>'turn')::bigint FROM runs WHERE id=$1",
)
.bind(run_id)
.fetch_optional(state.pool())
.await
.unwrap_or(None);
let (last_step, turn) = progress.unwrap_or((None, None));
let failure = crate::monitor::classify_run_error(error);
let body = crate::monitor::failure_message(&failure, last_step.as_deref(), turn);
let _ = append_bot_message_with(
state,
thread_id,
run_id,
bot_id,
&body,
json!([{
"kind": "error",
"code": failure.code,
"retryable": failure.retryable,
"runId": run_id,
"turn": turn,
"step": last_step,
}]),
)
.await;
crate::monitor::record(
state,
run_id,
"run",
json!({"event": "failed", "error": error}),
)
.await;
}
/// Display-only reading of a tool result. Tools report failure in prose, so the
/// trail marks the shapes it recognises rather than pretending to know more.
fn tool_status(timed_out: bool, pause: bool, text: &str) -> &'static str {
if timed_out {
return "timed_out";
}
if pause {
return "paused";
}
const FAILURES: [&str; 14] = [
"error",
"failed",
"failure",
"unable to",
"cannot ",
"can't ",
"timed out",
"denied",
"no such",
"exception",
"not available",
"失敗",
"錯誤",
"無法",
];
let head: String = text.to_lowercase().chars().take(160).collect();
if FAILURES.iter().any(|needle| head.contains(needle)) {
"error"
} else {
"ok"
}
}
pub(crate) async fn set_run_step(state: &AppState, run_id: &str, step: &str) { pub(crate) async fn set_run_step(state: &AppState, run_id: &str, step: &str) {
let result = sqlx::query( let result = sqlx::query(
"UPDATE runs SET checkpoint = COALESCE(checkpoint, '{}'::jsonb) "UPDATE runs SET checkpoint = COALESCE(checkpoint, '{}'::jsonb)
@ -2696,11 +2995,23 @@ mod tests {
use super::{ use super::{
RunHalt, SCREENSHOT_CAPTION, describe_step, drop_history_screenshots, halt_from_status, RunHalt, SCREENSHOT_CAPTION, describe_step, drop_history_screenshots, halt_from_status,
history_window_start, is_plain_chat, retryable_run_error, screenshot_parts, tool_needs_gui, history_window_start, is_plain_chat, retryable_run_error, screenshot_parts, tool_needs_gui,
tool_needs_sandbox, tool_needs_sandbox, tool_status,
}; };
use rig_core::completion::message::{Message, UserContent}; use rig_core::completion::message::{Message, UserContent};
use serde_json::json; use serde_json::json;
#[test]
fn the_trail_reads_a_tool_result_as_ok_error_timeout_or_pause() {
assert_eq!(tool_status(false, false, "clicked element #12"), "ok");
assert_eq!(
tool_status(false, false, "Error: selector not found"),
"error"
);
assert_eq!(tool_status(false, false, "操作失敗:視窗關閉"), "error");
assert_eq!(tool_status(true, false, "tool timed out"), "timed_out");
assert_eq!(tool_status(false, true, "需要人先登入"), "paused");
}
#[test] #[test]
fn chat_tools_do_not_need_the_desktop() { fn chat_tools_do_not_need_the_desktop() {
assert!(!tool_needs_sandbox("remember")); assert!(!tool_needs_sandbox("remember"));

View File

@ -617,7 +617,7 @@ pub fn describe_cron(expr: &str) -> String {
if let Ok(Some(seconds)) = interval_seconds(expr) { if let Ok(Some(seconds)) = interval_seconds(expr) {
return format!("每隔 {} 分鐘(固定間隔)", seconds / 60); return format!("每隔 {} 分鐘(固定間隔)", seconds / 60);
} }
let parts: Vec<&str> = expr.trim().split_whitespace().collect(); let parts: Vec<&str> = expr.split_whitespace().collect();
if parts.len() != 5 { if parts.len() != 5 {
return expr.to_string(); return expr.to_string();
} }
@ -625,36 +625,40 @@ pub fn describe_cron(expr: &str) -> String {
if expr.trim() == "* * * * *" { if expr.trim() == "* * * * *" {
return "每分鐘".into(); return "每分鐘".into();
} }
if let Some(rest) = min.strip_prefix("*/") { if let Some(rest) = min.strip_prefix("*/")
if hour == "*" && dom == "*" && month == "*" && dow == "*" { && hour == "*"
return if rest && dom == "*"
.parse::<u32>() && month == "*"
.ok() && dow == "*"
.is_some_and(|n| n > 0 && 60 % n == 0) {
{ return if rest
format!("{rest} 分鐘") .parse::<u32>()
} else { .ok()
format!("日曆排程:{expr}") .is_some_and(|n| n > 0 && 60 % n == 0)
}; {
} format!("{rest} 分鐘")
} else {
format!("日曆排程:{expr}")
};
} }
if min == "0" && hour == "*" && dom == "*" && month == "*" && dow == "*" { if min == "0" && hour == "*" && dom == "*" && month == "*" && dow == "*" {
return "每小時".into(); return "每小時".into();
} }
if min == "0" { if min == "0"
if let Some(rest) = hour.strip_prefix("*/") { && let Some(rest) = hour.strip_prefix("*/")
if dom == "*" && month == "*" && dow == "*" { && dom == "*"
return if rest && month == "*"
.parse::<u32>() && dow == "*"
.ok() {
.is_some_and(|n| n > 0 && 24 % n == 0) return if rest
{ .parse::<u32>()
format!("{rest} 小時") .ok()
} else { .is_some_and(|n| n > 0 && 24 % n == 0)
format!("日曆排程:{expr}") {
}; format!("{rest} 小時")
} } else {
} format!("日曆排程:{expr}")
};
} }
if min.parse::<u32>().is_ok() && hour.parse::<u32>().is_ok() && month == "*" { if min.parse::<u32>().is_ok() && hour.parse::<u32>().is_ok() && month == "*" {
let at = format!("{hour:0>2}:{min:0>2}"); let at = format!("{hour:0>2}:{min:0>2}");

View File

@ -241,10 +241,10 @@ async fn proxy_socket(mut client: WebSocket, host: String, port: u16, rest: Stri
if client.send(AxumMessage::Text(text.to_string().into())).await.is_err() { break; } if client.send(AxumMessage::Text(text.to_string().into())).await.is_err() { break; }
} }
Some(Ok(WsMessage::Ping(data))) => { Some(Ok(WsMessage::Ping(data))) => {
if client.send(AxumMessage::Ping(data.into())).await.is_err() { break; } if client.send(AxumMessage::Ping(data)).await.is_err() { break; }
} }
Some(Ok(WsMessage::Pong(data))) => { Some(Ok(WsMessage::Pong(data))) => {
if client.send(AxumMessage::Pong(data.into())).await.is_err() { break; } if client.send(AxumMessage::Pong(data)).await.is_err() { break; }
} }
Some(Ok(WsMessage::Close(_))) | Some(Ok(WsMessage::Frame(_))) | None => break, Some(Ok(WsMessage::Close(_))) | Some(Ok(WsMessage::Frame(_))) | None => break,
Some(Err(_)) => break, Some(Err(_)) => break,

View File

@ -220,9 +220,7 @@ async fn delete_session(
Json(json!({"message":"session not found"})), Json(json!({"message":"session not found"})),
)); ));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id).await.map_err(internal)?;
.await
.map_err(|error| internal(error))?;
let mut tx = state let mut tx = state
.pool() .pool()
.begin() .begin()
@ -286,9 +284,7 @@ async fn clear_messages(
Json(json!({"message":"session not found"})), Json(json!({"message":"session not found"})),
)); ));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id).await.map_err(internal)?;
.await
.map_err(|error| internal(error))?;
let mut tx = state let mut tx = state
.pool() .pool()
.begin() .begin()
@ -364,9 +360,7 @@ async fn stop_session(
Json(json!({"message":"session not found"})), Json(json!({"message":"session not found"})),
)); ));
} }
cancel_session_runs(&state, &id) cancel_session_runs(&state, &id).await.map_err(internal)?;
.await
.map_err(|error| internal(error))?;
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -447,26 +441,29 @@ pub async fn default_session_for_bot(
.await .await
} }
/// `messages_for_session` projection: message columns joined with the speaking bot.
type MessageWithSpeakerRow = (
String,
String,
i32,
String,
String,
Value,
Option<String>,
Option<String>,
chrono::DateTime<chrono::Utc>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
pub async fn messages_for_session( pub async fn messages_for_session(
state: &AppState, state: &AppState,
actor: &Actor, actor: &Actor,
id: &str, id: &str,
) -> Result<Vec<SessionMessage>, ApiError> { ) -> Result<Vec<SessionMessage>, ApiError> {
let rows: Vec<( let rows: Vec<MessageWithSpeakerRow> = sqlx::query_as(
String,
String,
i32,
String,
String,
Value,
Option<String>,
Option<String>,
chrono::DateTime<chrono::Utc>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id, "SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape
FROM messages m FROM messages m

View File

@ -1394,7 +1394,11 @@ pub fn fallback_playbook(goal: &str, events: &[Value]) -> Value {
}) })
.filter_map(|event| describe_event(event, t0)) .filter_map(|event| describe_event(event, t0))
.map(|line| { .map(|line| {
let text = line.splitn(2, ' ').nth(1).unwrap_or(&line).to_string(); let text = line
.split_once(' ')
.map(|x| x.1)
.unwrap_or(&line)
.to_string();
json!({ "do": text, "expect": "", "note": "" }) json!({ "do": text, "expect": "", "note": "" })
}) })
.take(40) .take(40)

View File

@ -728,20 +728,20 @@ async fn browser(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
request[key] = value.clone(); request[key] = value.clone();
} }
} }
if request.get("selector").and_then(Value::as_str).is_none() { if request.get("selector").and_then(Value::as_str).is_none()
if let Some(id) = element_id(args.get("element").or_else(|| args.get("id"))) { && let Some(id) = element_id(args.get("element").or_else(|| args.get("id")))
let elements = ctx.elements.lock().unwrap().clone(); {
match elements let elements = ctx.elements.lock().unwrap().clone();
.iter() match elements
.find(|element| u64::from(element.id) == id) .iter()
.and_then(|element| element.selector.clone()) .find(|element| u64::from(element.id) == id)
{ .and_then(|element| element.selector.clone())
Some(selector) => request["selector"] = json!(selector), {
None if matches!(action, "click" | "type") => { Some(selector) => request["selector"] = json!(selector),
return pause_unknown_element(ctx, id as u32, &elements); None if matches!(action, "click" | "type") => {
} return pause_unknown_element(ctx, id as u32, &elements);
None => {}
} }
None => {}
} }
} }
if matches!(action, "click") && request.get("selector").and_then(Value::as_str).is_none() { if matches!(action, "click") && request.get("selector").and_then(Value::as_str).is_none() {

View File

@ -4,7 +4,7 @@ use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce}; use aes_gcm::{Aes256Gcm, Nonce};
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::{delete, get, patch, post}; use axum::routing::{get, patch};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use rand::RngCore; use rand::RngCore;
@ -161,14 +161,18 @@ pub async fn list_on(
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
pub async fn get_secret( /// `get_secret_on` projection: vault columns plus the encrypted password.
state: &AppState, type SecretRow = (
actor: &Actor, String,
bot_id: &str, String,
account_id: &str, String,
) -> Result<Option<(VaultAccount, String, String)>, String> { String,
get_secret_on(state.pool(), actor, bot_id, account_id).await String,
} String,
DateTime<Utc>,
DateTime<Utc>,
String,
);
pub async fn get_secret_on( pub async fn get_secret_on(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
@ -176,8 +180,7 @@ pub async fn get_secret_on(
bot_id: &str, bot_id: &str,
account_id: &str, account_id: &str,
) -> Result<Option<(VaultAccount, String, String)>, String> { ) -> Result<Option<(VaultAccount, String, String)>, String> {
let row: Option<(String, String, String, String, String, String, DateTime<Utc>, DateTime<Utc>, String)> = let row: Option<SecretRow> = sqlx::query_as(
sqlx::query_as(
"SELECT id, bot_id, site, host, username, notes, created_at, updated_at, password_ciphertext "SELECT id, bot_id, site, host, username, notes, created_at, updated_at, password_ciphertext
FROM vault_accounts FROM vault_accounts
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4", WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
@ -368,6 +371,9 @@ mod tests {
use super::{decrypt, encrypt, normalize_host}; use super::{decrypt, encrypt, normalize_host};
#[test] #[test]
// Rust 2024 marks `set_var` unsafe; this only seeds a test-only key and no
// other test in this binary reads it.
#[allow(unsafe_code)]
fn round_trips_a_password() { fn round_trips_a_password() {
unsafe { std::env::set_var("LAZYBOY_VAULT_KEY", "test-vault-key-for-unit-tests") }; unsafe { std::env::set_var("LAZYBOY_VAULT_KEY", "test-vault-key-for-unit-tests") };
let packed = encrypt("s3cret!").unwrap(); let packed = encrypt("s3cret!").unwrap();

View File

@ -2,6 +2,7 @@
name = "lazyboy-contracts" name = "lazyboy-contracts"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
@ -11,3 +12,6 @@ serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
chrono.workspace = true chrono.workspace = true
base64.workspace = true base64.workspace = true
[lints]
workspace = true

View File

@ -113,8 +113,10 @@ pub fn computer_home_key(
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum BrowserProfileMode { pub enum BrowserProfileMode {
Shared, Shared,
#[default]
PerBot, PerBot,
PerTask, PerTask,
} }
@ -129,12 +131,6 @@ impl BrowserProfileMode {
} }
} }
impl Default for BrowserProfileMode {
fn default() -> Self {
Self::PerBot
}
}
impl std::str::FromStr for BrowserProfileMode { impl std::str::FromStr for BrowserProfileMode {
type Err = UnknownProfileMode; type Err = UnknownProfileMode;

View File

@ -2,6 +2,7 @@
name = "lazyboy-control" name = "lazyboy-control"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
@ -15,3 +16,6 @@ hex.workspace = true
chrono.workspace = true chrono.workspace = true
async-trait = "0.1" async-trait = "0.1"
image.workspace = true image.workspace = true
[lints]
workspace = true

View File

@ -130,7 +130,6 @@ mod tests {
kind: Some("a11y".into()), kind: Some("a11y".into()),
selector: Some(format!("0/{id}")), selector: Some(format!("0/{id}")),
role: Some("push button".into()), role: Some("push button".into()),
..UiElement::default()
} }
} }

View File

@ -200,22 +200,22 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.unwrap_or_default(); .unwrap_or_default();
match kind { match kind {
"click" | "move" | "down" | "up" => { "click" | "move" | "down" | "up" => {
if kind == "click" { if kind == "click"
if let Some(target) = ref_target(action) { && let Some(target) = ref_target(action)
let pointer = ComputerAction::Ref { {
verb: RefVerb::Click, let pointer = ComputerAction::Ref {
target, verb: RefVerb::Click,
ref_kind: ref_kind(action), target,
text: None, ref_kind: ref_kind(action),
}; text: None,
let doubled = action.get("double").and_then(Value::as_bool) == Some(true); };
actions.push(pointer.clone()); let doubled = action.get("double").and_then(Value::as_bool) == Some(true);
if doubled { actions.push(pointer.clone());
actions.push(ComputerAction::Wait { ms: 70 }); if doubled {
actions.push(pointer); actions.push(ComputerAction::Wait { ms: 70 });
} actions.push(pointer);
continue;
} }
continue;
} }
let x = coordinate(action.get("x"), "x")?; let x = coordinate(action.get("x"), "x")?;
let y = coordinate(action.get("y"), "y")?; let y = coordinate(action.get("y"), "y")?;
@ -558,7 +558,6 @@ mod tests {
y: 20, y: 20,
w: 80, w: 80,
h: 24, h: 24,
..UiElement::default()
} }
} }

View File

@ -150,7 +150,7 @@ fn draw_badge(img: &mut RgbImage, x: u32, y: u32, id: u32) {
2, 2,
); );
for (i, ch) in label.bytes().enumerate() { for (i, ch) in label.bytes().enumerate() {
if !(b'0'..=b'9').contains(&ch) { if !ch.is_ascii_digit() {
continue; continue;
} }
let dx = bx + pad + i as u32 * digit_w; let dx = bx + pad + i as u32 * digit_w;

View File

@ -2,7 +2,7 @@ use lazyboy_contracts::{ComputerAction, ComputerCapabilities, ComputerObservatio
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AdapterContext { pub struct AdapterContext {
pub operation_id: String, pub operation_id: String,
pub space_id: String, pub space_id: String,
@ -20,23 +20,6 @@ pub struct AdapterContext {
pub profile_path: Option<String>, pub profile_path: Option<String>,
} }
impl Default for AdapterContext {
fn default() -> Self {
Self {
operation_id: String::new(),
space_id: String::new(),
user_id: String::new(),
bot_id: None,
run_id: None,
screen_lease_id: None,
screen_id: None,
screen_slot: None,
display: None,
profile_path: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComputerRef { pub struct ComputerRef {
pub id: String, pub id: String,
@ -53,7 +36,7 @@ pub struct ProvisionRequest {
pub provider_ref: Option<String>, pub provider_ref: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CommandRequest { pub struct CommandRequest {
pub argv: Vec<String>, pub argv: Vec<String>,
pub cwd: Option<String>, pub cwd: Option<String>,
@ -64,17 +47,6 @@ pub struct CommandRequest {
pub stdin: Option<String>, pub stdin: Option<String>,
} }
impl Default for CommandRequest {
fn default() -> Self {
Self {
argv: Vec::new(),
cwd: None,
timeout_ms: None,
stdin: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandResult { pub struct CommandResult {
pub stdout: String, pub stdout: String,

View File

@ -386,6 +386,17 @@ pub fn screenshot_command_on(display: &str) -> Vec<String> {
] ]
} }
/// Native clipboard path; text is supplied on stdin, never process arguments.
pub fn paste_command_on(display: &str) -> Vec<String> {
vec![
"env".into(),
display_env(display),
"python3".into(),
"-c".into(),
include_str!("clipboard.py").into(),
]
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -490,14 +501,3 @@ mod tests {
assert!(parse_ui_elements("not-json").is_empty()); assert!(parse_ui_elements("not-json").is_empty());
} }
} }
/// Native clipboard path; text is supplied on stdin, never process arguments.
pub fn paste_command_on(display: &str) -> Vec<String> {
vec![
"env".into(),
display_env(display),
"python3".into(),
"-c".into(),
include_str!("clipboard.py").into(),
]
}

View File

@ -2,6 +2,7 @@
name = "lazyboy-controld" name = "lazyboy-controld"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
@ -10,9 +11,10 @@ lazyboy-contracts.workspace = true
lazyboy-control.workspace = true lazyboy-control.workspace = true
axum.workspace = true axum.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
base64.workspace = true base64.workspace = true
[lints]
workspace = true

View File

@ -2,12 +2,12 @@
name = "lazyboy-harness" name = "lazyboy-harness"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
lazyboy-contracts.workspace = true lazyboy-contracts.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
rig-core.workspace = true rig-core.workspace = true
@ -21,3 +21,6 @@ http = "1"
reqwest.workspace = true reqwest.workspace = true
rustls.workspace = true rustls.workspace = true
rustls-native-certs = "0.8" rustls-native-certs = "0.8"
[lints]
workspace = true

View File

@ -2,6 +2,7 @@
name = "lazyboy-sandbox" name = "lazyboy-sandbox"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
@ -10,10 +11,8 @@ lazyboy-contracts.workspace = true
lazyboy-control.workspace = true lazyboy-control.workspace = true
async-trait = "0.1" async-trait = "0.1"
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true
base64.workspace = true base64.workspace = true
chrono.workspace = true
sha2.workspace = true [lints]
hex.workspace = true workspace = true

View File

@ -36,20 +36,20 @@ impl DockerSandbox {
if let Some(bot_id) = &context.bot_id { if let Some(bot_id) = &context.bot_id {
headers.insert("x-lazyboy-bot-id", bot_id.parse().unwrap()); headers.insert("x-lazyboy-bot-id", bot_id.parse().unwrap());
} }
if let Some(display) = &context.display { if let Some(display) = &context.display
if let Ok(value) = display.parse() { && let Ok(value) = display.parse()
headers.insert("x-lazyboy-display", value); {
} headers.insert("x-lazyboy-display", value);
} }
if let Some(profile) = &context.profile_path { if let Some(profile) = &context.profile_path
if let Ok(value) = profile.parse() { && let Ok(value) = profile.parse()
headers.insert("x-lazyboy-profile", value); {
} headers.insert("x-lazyboy-profile", value);
} }
if let Some(slot) = context.screen_slot { if let Some(slot) = context.screen_slot
if let Ok(value) = slot.to_string().parse() { && let Ok(value) = slot.to_string().parse()
headers.insert("x-lazyboy-screen-slot", value); {
} headers.insert("x-lazyboy-screen-slot", value);
} }
headers headers
} }

View File

@ -55,22 +55,22 @@ impl SandboxProvider for FakeSandbox {
request: CommandRequest, request: CommandRequest,
_context: &AdapterContext, _context: &AdapterContext,
) -> Result<CommandResult, SandboxError> { ) -> Result<CommandResult, SandboxError> {
if request.argv.get(0).map(String::as_str) == Some("mkdir") { if request.argv.first().map(String::as_str) == Some("mkdir") {
return Ok(CommandResult { return Ok(CommandResult {
stdout: String::new(), stdout: String::new(),
stderr: String::new(), stderr: String::new(),
code: 0, code: 0,
}); });
} }
if request.argv.get(0).map(String::as_str) == Some("touch") { if request.argv.first().map(String::as_str) == Some("touch")
if let Some(path) = request.argv.get(1) { && let Some(path) = request.argv.get(1)
self.files {
.lock() self.files
.unwrap() .lock()
.entry(computer.home_key.clone()) .unwrap()
.or_default() .entry(computer.home_key.clone())
.insert(path.clone(), Vec::new()); .or_default()
} .insert(path.clone(), Vec::new());
} }
Ok(CommandResult { Ok(CommandResult {
stdout: request.argv.join(" "), stdout: request.argv.join(" "),

View File

@ -2,26 +2,26 @@
name = "lazyboy-supervisor" name = "lazyboy-supervisor"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
lazyboy-contracts.workspace = true
lazyboy-control.workspace = true lazyboy-control.workspace = true
axum.workspace = true axum.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
bollard.workspace = true bollard.workspace = true
uuid.workspace = true
base64.workspace = true base64.workspace = true
reqwest.workspace = true reqwest.workspace = true
async-trait = "0.1"
futures-util = "0.3" futures-util = "0.3"
hmac.workspace = true hmac.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true hex.workspace = true
[lints]
workspace = true

View File

@ -34,20 +34,20 @@ pub struct Provisioned {
} }
pub struct ObservePayload { pub struct ObservePayload {
pub png: Vec<u8>,
pub json: serde_json::Value, pub json: serde_json::Value,
} }
impl ObservePayload { impl ObservePayload {
fn from_json(value: serde_json::Value) -> Result<Self, String> { fn from_json(value: serde_json::Value) -> Result<Self, String> {
let encoded = value // The screenshot travels inside `json` as base64; only its presence is
// checked here so a broken capture fails fast instead of returning an
// empty observation.
value
.get("png_base64") .get("png_base64")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.filter(|encoded| !encoded.is_empty())
.ok_or_else(|| "missing png".to_string())?; .ok_or_else(|| "missing png".to_string())?;
let png = base64::engine::general_purpose::STANDARD Ok(Self { json: value })
.decode(encoded)
.map_err(|error| error.to_string())?;
Ok(Self { png, json: value })
} }
} }
@ -76,7 +76,7 @@ impl DockerHost {
return Err("invalid home key".into()); return Err("invalid home key".into());
} }
let expected = PathBuf::from(&data_dir).join("homes").join(home_key); let expected = PathBuf::from(&data_dir).join("homes").join(home_key);
if PathBuf::from(home_path) != expected { if *home_path != expected {
return Err("home path is outside managed homes".into()); return Err("home path is outside managed homes".into());
} }
// Work on the container-local path, not the host daemon's bind path. // Work on the container-local path, not the host daemon's bind path.
@ -251,42 +251,6 @@ impl DockerHost {
}) })
} }
pub async fn exec(&self, id: &str, request: CommandRequest) -> Result<CommandResult, String> {
let cwd = match request.cwd {
Some(cwd) if PathBuf::from(&cwd).is_absolute() => cwd,
Some(cwd) => {
let relative = normalize_workspace_path(&cwd).map_err(|error| error.to_string())?;
if relative.is_empty() {
HOME.to_string()
} else {
format!("{HOME}/{relative}")
}
}
None => HOME.to_string(),
};
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()]
} else {
request.argv
};
let mut bounded = vec![
"timeout".into(),
"--signal=TERM".into(),
"--kill-after=2s".into(),
format!("{}s", timeout_ms as f64 / 1000.0),
];
bounded.extend(argv);
self.exec_raw_cmd(
id,
&bounded,
Some(&cwd),
&ScreenTarget::default(),
request.stdin,
)
.await
}
pub async fn exec_on( pub async fn exec_on(
&self, &self,
id: &str, id: &str,
@ -322,13 +286,6 @@ impl DockerHost {
.await .await
} }
pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
Ok(self
.observe_payload(id, &ScreenTarget::default())
.await?
.png)
}
pub async fn observe_payload( pub async fn observe_payload(
&self, &self,
id: &str, id: &str,
@ -352,23 +309,22 @@ impl DockerHost {
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout) "png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout)
}); });
if let Ok(meta) = self.pointer_state(id, target).await { if let Ok(meta) = self.pointer_state(id, target).await
if let serde_json::Value::Object(map) = meta { && let serde_json::Value::Object(map) = meta
if let Some(obj) = body.as_object_mut() { && let Some(obj) = body.as_object_mut()
if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) { {
obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y })); if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) {
} obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y }));
if map }
.get("id") if map
.and_then(serde_json::Value::as_str) .get("id")
.is_some_and(|id| !id.is_empty()) .and_then(serde_json::Value::as_str)
{ .is_some_and(|id| !id.is_empty())
obj.insert( {
"activeWindow".into(), obj.insert(
serde_json::json!({ "id": map.get("id"), "title": map.get("title") }), "activeWindow".into(),
); serde_json::json!({ "id": map.get("id"), "title": map.get("title") }),
} );
}
} }
} }
body body

View File

@ -387,10 +387,9 @@ async fn managed_boundary(
.path() .path()
.strip_prefix("/computers/") .strip_prefix("/computers/")
.and_then(|p| p.split('/').next()) .and_then(|p| p.split('/').next())
&& app.docker.container_control_token(id).await.is_err()
{ {
if app.docker.container_control_token(id).await.is_err() { return StatusCode::NOT_FOUND.into_response();
return StatusCode::NOT_FOUND.into_response();
}
} }
} }
next.run(req).await next.run(req).await

58
deny.toml Normal file
View File

@ -0,0 +1,58 @@
# Supply-chain policy for the LazyBoy workspace, checked by `make audit`.
# cargo-deny is not part of the Rust toolchain:
# cargo install --locked cargo-deny (or grab a release binary)
#
# Three things are enforced: known vulnerabilities (RustSec), the license set we
# are willing to ship, and where dependencies may come from.
[graph]
targets = [
{ triple = "x86_64-unknown-linux-gnu" },
{ triple = "aarch64-unknown-linux-gnu" },
]
[advisories]
yanked = "deny"
ignore = [
# `paste` is an eager-macro helper pulled in by fastembed -> tokenizers. It is
# archived upstream (no safe upgrade), only used while compiling macros, and we
# keep embeddings optional. Re-check on the next fastembed/tokenizers bump.
{ id = "RUSTSEC-2024-0436", reason = "transitive proc-macro helper behind fastembed; unmaintained, no upgrade available" },
]
[licenses]
# Permissive set only; copyleft candidates (MPL-2.0) stay file-level.
confidence-threshold = 0.93
allow = [
"0BSD",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
[bans]
# Duplicate major/minor versions are normal in an application graph (they are
# compiled once each); we only want to know when a dependency disappears.
multiple-versions = "allow"
wildcards = "deny"
# Workspace members are path dependencies and carry no version requirement.
allow-workspace = true
allow-wildcard-paths = true
highlight = "all"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []

View File

@ -1,5 +1,14 @@
# Opt-in host access for local Rust development only. # Opt-in host access for local Rust development only.
# The project `database` network is internal, and Docker silently ignores published
# ports for containers attached only to internal networks. Postgres therefore also
# joins this host bridge, so 127.0.0.1:5434 really listens for `cargo test`.
services: services:
postgres: postgres:
ports: ports:
- "127.0.0.1:5434:5432" - "127.0.0.1:5434:5432"
networks:
- database
- dev_host
networks:
dev_host:
name: lazyboy_dev_host

View File

@ -21,9 +21,12 @@ npm run dev # http://127.0.0.1:5173
### 檢查與測試 ### 檢查與測試
```bash ```bash
make fmt # 有資料庫的 Rust 測試需要 127.0.0.1:5434先用 dev overlay 啟動 Postgres
make clippy make postgres
make test
make lint # clippy-D warnings政策見下方
make test # Rust 測試(需要 127.0.0.1:5434
make audit # 依賴漏洞、授權、來源掃描(需要 cargo-deny
cd apps/web cd apps/web
npm run typecheck npm run typecheck
@ -34,11 +37,46 @@ node --test tests/frontend.test.mjs
python3 tests/control.test.py python3 tests/control.test.py
python3 tests/log-rotation.test.py python3 tests/log-rotation.test.py
# 需要已啟動的 PostgreSQL Compose service # Python 整合測試用 docker compose exec 連進 Postgres自己建一次性資料庫後清掉
docker compose up -d postgres
python3 tests/retention.test.py python3 tests/retention.test.py
python3 tests/run-resume.test.py
python3 tests/run-activity.test.py
``` ```
`make postgres` 才會發布 `127.0.0.1:5434``docker-compose.dev.yml`);整套堆疊已經用
基礎設定跑著時,請改用 `export COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml`
`docker compose up -d`,否則後續的 `docker compose` 指令會拿基礎設定重建 Postgres、
把發布埠拿掉。
### Rust 品質檢查
工具鏈以穩定版為準workspace 宣告 `rust-version = "1.98"`MSRV代表 let-chains
等語法下限),容器用 `rust:1-*` 映像會自動跟最新小版。本機更新只需:
```bash
rustup update stable && rustc --version
```
Lint 政策集中在三處,新增 crate 時只要補 `[lints] workspace = true` 就會繼承:
- 根 `Cargo.toml``[workspace.lints]`:不安全程式碼預設警告、未使用的
`Result`/`Future` 直接拒絕,並用 `unused_crate_dependencies` 抓多餘依賴
(不需要另外裝 `cargo-machete`)。
- `clippy.toml`:閾值類設定。`too-many-arguments-threshold = 10` 是因為 handler
與 run/vault/voice 協助函式本來就要帶 state + actor + 多個 id超過 10 個參數才會警告。
`cargo-clippy` 只看得到啟動目錄下的 `clippy.toml`,請一律在 repo 根目錄跑 `make lint`
- 各 crate 的 `[lints] workspace = true`
要放寬一條 lint 時,請在**呼叫點**加 `#[allow(...)]` 並附一句理由(例:
`crates/api/src/vault.rs` 測試裡的 `#[allow(unsafe_code)]`),不要在工作區層級關掉規則。
`make fmt` 會重排整個 workspace目前倉庫仍有歷史格式偏差`make fmt-check` 會列出來,
等到一次性重整後再併入 CI。CI 目前只需 `make lint` + `make test`
供應鏈檢查用 `cargo-deny``make audit`,設定見 `deny.toml`RustSec 漏洞、授權白名單、
依賴來源。它不是內建工具,第一次要先 `cargo install --locked cargo-deny`
已知無法升級的項目會寫進 `deny.toml``ignore` 並附原因與重檢時機。
### 專案結構 ### 專案結構
```text ```text
@ -59,6 +97,8 @@ LazyBoy/
├── docs/hero.html README 首頁視覺原稿 ├── docs/hero.html README 首頁視覺原稿
├── docs/workflow.html 可互動產品流程圖 ├── docs/workflow.html 可互動產品流程圖
├── docker-compose.yml 正式堆疊 ├── docker-compose.yml 正式堆疊
├── clippy.toml Clippy 閾值(要在 repo 根目錄執行才讀得到)
├── deny.toml cargo-deny 供應鏈政策make audit
└── Makefile 常用開發與部署指令 └── Makefile 常用開發與部署指令
``` ```

View File

@ -77,6 +77,37 @@ docker compose up -d api
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。 - 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。
- 桌面 noVNC 走 `LAZYBOY_SCREEN_NETWORK` 這條 internal 網路,不佔主機埠;同機跑多組 LazyBoy 時請為每組取不同名稱compose 與 supervisor 會共用同一個值。 - 桌面 noVNC 走 `LAZYBOY_SCREEN_NETWORK` 這條 internal 網路,不佔主機埠;同機跑多組 LazyBoy 時請為每組取不同名稱compose 與 supervisor 會共用同一個值。
## 執行記錄與錯誤診斷
任務執行中的每一步會寫進 `run_activity`:模型每一輪在想什麼、哪個動作成功或
失敗、花了幾秒、現在是第幾輪。這屬於診斷資料,不是對話內容,因此可以被清理。
**看即時記錄。** 在對話框把滑鼠移到「思考中」的頭像上(或點一下釘住、用 `Tab`
聚焦),會浮出即時記錄面板:
- 標題列顯示「第 {n}/{limit} 輪」、已運行時間與現在在做什麼goal 模式沒有輪次
上限時只顯示目前輪次,不會假裝有上限。
- 記錄由舊到新排列並自動捲到底,包含模型回合、動作成敗與逾時、重試第幾次,以及
停下來等你的原因,最後停在哪裡一目了然。
- 「複製記錄」可把整份純文字記錄貼給別人除錯;錯誤原文以等寬字型原樣顯示。
- 面板只在開啟時每 1.5 秒增量抓取 `GET /api/runs/{id}/activity`run 結束
(完成/失敗/取消)後停止輪詢,不開就完全不打 API。
**出錯時說什麼。** 任務失敗會在對話框出現錯誤卡,而不是一行紅字:
- 一句人話講清楚原因與下一步,例如「模型不認得這個 API 金鑰。到「設定 →
模型」重新貼一次金鑰,再按重試。」
- 按鈕依錯誤類型給:`重試`(從中斷的地方接著做,保留 checkpoint不重複已成功的
步驟)、`開模型設定`、`打開它的畫面`。金鑰錯誤不會只給重試,電腦消失也不會叫
你去看設定。
- `ⓘ` 直接展開同一個即時記錄面板看詳細 log錯誤代碼與原文都在裡面。
- 重試走 `POST /api/runs/{id}/retry`,只對失敗或已取消的 run 生效;同一台電腦還有
別的工作在跑時會被擋下,並明確告訴你是因為還有別的工作。
**保留多久。** `LAZYBOY_RUN_ACTIVITY_RETENTION_DAYS` 控制記錄保留天數,預設 7 天,
每小時清理一次。想留更久的除錯軌跡就調大在意資料庫體積就調小run 本身被
`LAZYBOY_RUN_RETENTION_DAYS` 清掉時,它的記錄一併消失。
## 網站連線驗證 ## 網站連線驗證
AI 遇到可辨識的 Cloudflare 連線驗證頁時,會先取得最新桌面截圖,再使用 AI 遇到可辨識的 Cloudflare 連線驗證頁時,會先取得最新桌面截圖,再使用
@ -95,3 +126,31 @@ AI 遇到可辨識的 Cloudflare 連線驗證頁時,會先取得最新桌面
這會重建桌面容器,讓 `lazyboy` 使用者能執行免密碼 `sudo`;可用 這會重建桌面容器,讓 `lazyboy` 使用者能執行免密碼 `sudo`;可用
`sudo -n id -u` 檢查,預期輸出 `0`。既有容器只暫停/恢復不會套用此設定。 `sudo -n id -u` 檢查,預期輸出 `0`。既有容器只暫停/恢復不會套用此設定。
重新啟動前請儲存工作;家目錄保留,容器系統層自行安裝的套件需重新安裝。 重新啟動前請儲存工作;家目錄保留,容器系統層自行安裝的套件需重新安裝。
## Postgres collation 版本不符
`pgvector/pgvector:pg16` 是浮動 tag。重新 `docker compose pull` 之後,容器內的 glibc
版本可能和 `pgdata` 卷建立時不同Postgres 會在使用每個資料庫時抱怨:
```text
WARNING: database "template1" has a collation version mismatch
DETAIL: The database was created using collation version 2.41, but the operating system provides version 2.36.
```
此時 `CREATE DATABASE` 直接被拒(`ERROR: template database "template1" has a collation
version mismatch`),凡是需要建立資料庫的測試都會失敗:`cargo test` 裡的 `#[sqlx::test]`
表現在連線逾時(`PoolTimedOut``tests/retention.test.py`、`tests/run-resume.test.py`
則在 `CREATE DATABASE` 就掛掉。
原地修好,資料不遺失(會重建索引,資料量大時安排在離峰):
```bash
docker compose stop api
make pg-collation
docker compose up -d api
```
它對 `template1`、`postgres`、`lazyboy` 各跑一次 `REINDEX DATABASE`
`ALTER DATABASE ... REFRESH COLLATION VERSION`。collation provider 是 libc排序規則
真的可能跟著 glibc 變,所以先重建索引再更新記錄的版號,不要只改版號。想避免重複發生,
把 Postgres 映像改成固定 digest讓同一個資料卷不會被不同 glibc 開起來。

View File

@ -0,0 +1,12 @@
-- Per-run activity trail for the chat's live monitor bubble. Diagnostics only:
-- never user-authored content, and cheap enough to write once per model turn.
CREATE TABLE IF NOT EXISTS run_activity (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS run_activity_run_idx ON run_activity (run_id, id);
CREATE INDEX IF NOT EXISTS run_activity_retention_idx ON run_activity (created_at);

View File

@ -241,3 +241,90 @@ test('view-only pointer controls never send mouse input and trackpad asks for co
assert.equal(v.pointerEvents.length,0); assert.equal(v.pointerEvents.length,0);
assert.equal(v.sent.at(-1).type,'lazyboy-request-control'); assert.equal(v.sent.at(-1).type,'lazyboy-request-control');
}); });
const monitorJs=ts.transpileModule(fs.readFileSync('apps/web/src/run-monitor.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const monitorBox={exports:{},require:name=>{
if(name==='react')return{useCallback:fn=>fn,useEffect:()=>{},useRef:()=>({current:null}),useState:()=>[null,()=>{}]};
if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'};
if(name==='./api')return{api:async()=>({activity:[]})};
if(name==='./i18n')return{t:i18n.t,getLocale:()=>i18n.locale};
throw new Error('unexpected import '+name);
}};
vm.runInNewContext(monitorJs,monitorBox);
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
test('run timings stay on one glanceable line',()=>{
assert.equal(formatElapsed(0),'0:00');
assert.equal(formatElapsed(9_500),'0:09');
assert.equal(formatElapsed(65_000),'1:05');
assert.equal(formatElapsed(3_725_000),'1:02:05');
assert.equal(formatElapsed(-5),'0:00');
assert.equal(formatElapsed(NaN),'0:00');
assert.equal(shortDuration(340),'340ms');
assert.equal(shortDuration(6_400),'6.4s');
assert.equal(shortDuration(65_000),'1:05');
assert.equal(shortDuration(null),'');
assert.equal(shortDuration(-1),'');
});
test('every failure code offers at least one action, in the right order',()=>{
const buttons=code=>errorActions(code).join(',');
assert.equal(buttons('model_key'),'settings,retry');
assert.equal(buttons('model_unknown'),'settings,retry');
assert.equal(buttons('model_quota'),'retry,settings');
assert.equal(buttons('model_timeout'),'retry,settings');
assert.equal(buttons('network'),'retry,settings');
assert.equal(buttons('computer_gone'),'screen,retry');
assert.equal(buttons('tool_timeout'),'screen,retry');
assert.equal(buttons('interrupted'),'screen,retry');
assert.equal(buttons('lease_lost'),'retry');
for(const code of [...FAILURE_CODES,'made_up',undefined,null]){
const actions=errorActions(code);
assert.ok(actions.length>0,String(code));
assert.ok(actions.every(action=>['retry','screen','settings'].includes(action)),code);
assert.equal(new Set(actions).size,actions.length,'no duplicate buttons for '+code);
}
});
test('failure titles resolve in both catalogs and never repeat themselves',()=>{
for(const locale of Object.keys(catalogs)){
i18n.locale=locale;
const titles=FAILURE_CODES.map(code=>errorTitle(code));
for(const [index,title] of titles.entries()){
assert.notEqual(title,title.startsWith('errorTitle')?title:`${title}-missing`,FAILURE_CODES[index]);
assert.ok(!title.startsWith('errorTitle'),`${locale} ${FAILURE_CODES[index]} has no copy`);
assert.ok(title.trim().length>0);
}
assert.equal(new Set(titles).size,titles.length,locale);
assert.ok(errorTitle('made_up').length>0);
}
i18n.locale='en';
});
test('trail lines read as sentences with the detail a stuck run needs',()=>{
assert.equal(trailText({id:1,kind:'model',createdAt:'',turn:3,elapsedMs:6400,text:'先打開網頁'}),'turn 3, thought for 6.4s — 先打開網頁');
assert.match(trailText({id:2,kind:'tool',createdAt:'',step:'browser: click #12',status:'ok',elapsedMs:400}),/browser: click #12 · ok · 400ms/);
assert.match(trailText({id:3,kind:'tool',createdAt:'',step:'shell: npm test',status:'timed_out',snippet:'killed after 150s'}),/timed out — killed after 150s/);
assert.match(trailText({id:4,kind:'run',createdAt:'',event:'started',task:'整理下載資料'}),/整理下載資料/);
assert.match(trailText({id:5,kind:'run',createdAt:'',event:'completed',turns:9}),/Done in 9 turns/);
assert.match(trailText({id:6,kind:'run',createdAt:'',event:'waiting_input',reason:'登入'}),/Waiting for you: 登入/);
assert.match(trailText({id:7,kind:'run',createdAt:'',event:'retry'}),/Re-queued/);
assert.match(trailText({id:8,kind:'retry',createdAt:'',attempt:2,gaveUp:true,error:'429 rate limit'}),/attempt 2 failed, retrying · gave up — 429 rate limit/);
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
});
test('the bubble reads the run activity endpoint the API actually mounts',()=>{
const monitor=fs.readFileSync('crates/api/src/monitor.rs','utf8');
assert.match(monitor,/\.route\("\/api\/runs\/\{id\}\/activity", get\(activity\)\)/);
assert.match(monitor,/\.route\("\/api\/runs\/\{id\}\/retry", post\(retry\)\)/);
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/chip\.kind==="error"\?/);
assert.match(app,/errorActions\(chip\.code\)/);
assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/);
assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
const probe=fs.readFileSync('apps/web/src/run-monitor.tsx','utf8');
assert.match(probe,/`\/api\/runs\/\$\{runId\}\/activity\$\{after\}`/);
assert.match(fs.readFileSync('apps/web/src/main.tsx','utf8'),/import "\.\/monitor\.css";/);
});

View File

@ -44,6 +44,11 @@ try:
FROM unnest(ARRAY['completed','failed','cancelled','running','queued','leased','waiting_input','waiting_takeover']) status; FROM unnest(ARRAY['completed','failed','cancelled','running','queued','leased','waiting_input','waiting_takeover']) status;
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,checkpoint,updated_at,completed_at) INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,checkpoint,updated_at,completed_at)
VALUES ('recent-completed','s','b','t','u','completed','{"keep":true}',now(),now()); VALUES ('recent-completed','s','b','t','u','completed','{"keep":true}',now(),now());
INSERT INTO run_activity(run_id,kind,payload,created_at)
SELECT run_id,'notice','{"text":"step from an old run"}',now()-interval '10 days'
FROM unnest(ARRAY['completed','failed','running','recent-completed']) run_id;
INSERT INTO run_activity(run_id,kind,payload,created_at)
VALUES ('recent-completed','notice','{"text":"fresh step"}',now());
INSERT INTO memory_items(id,space_id,user_id,bot_id,content,revision,source_run_id) INSERT INTO memory_items(id,space_id,user_id,bot_id,content,revision,source_run_id)
VALUES ('00000000-0000-0000-0000-000000000001','s','u','b','keep current memory',15,'completed'); VALUES ('00000000-0000-0000-0000-000000000001','s','u','b','keep current memory',15,'completed');
INSERT INTO memory_revisions(memory_id,revision,space_id,user_id,bot_id,content,importance,action) INSERT INTO memory_revisions(memory_id,revision,space_id,user_id,bot_id,content,importance,action)
@ -69,6 +74,8 @@ try:
clean('runs',90) clean('runs',90)
check('(SELECT count(*) FROM runs)=6') check('(SELECT count(*) FROM runs)=6')
check("EXISTS(SELECT 1 FROM memory_items WHERE content='keep current memory' AND source_run_id IS NULL)") check("EXISTS(SELECT 1 FROM memory_items WHERE content='keep current memory' AND source_run_id IS NULL)")
clean('run_activity',7)
check("(SELECT count(*) FROM run_activity)=1 AND EXISTS(SELECT 1 FROM run_activity WHERE run_id='recent-completed' AND payload->>'text'='fresh step')")
clean('recordings',30) clean('recordings',30)
check("(SELECT count(*) FROM taught_skills WHERE recording='{}')=3 AND (SELECT count(*) FROM taught_skills WHERE playbook->>'keep'='true')=5") check("(SELECT count(*) FROM taught_skills WHERE recording='{}')=3 AND (SELECT count(*) FROM taught_skills WHERE playbook->>'keep'='true')=5")
clean('revisions',90) clean('revisions',90)
@ -80,9 +87,9 @@ try:
check("(SELECT count(*) FROM computer_execution_leases)=1 AND EXISTS(SELECT 1 FROM computer_execution_leases WHERE id='active')") check("(SELECT count(*) FROM computer_execution_leases)=1 AND EXISTS(SELECT 1 FROM computer_execution_leases WHERE id='active')")
check("(SELECT count(*) FROM computer_profile_locks)=1 AND EXISTS(SELECT 1 FROM computer_profile_locks WHERE profile_key='active')") check("(SELECT count(*) FROM computer_profile_locks)=1 AND EXISTS(SELECT 1 FROM computer_profile_locks WHERE profile_key='active')")
check("EXISTS(SELECT 1 FROM messages WHERE body='keep my conversation')") check("EXISTS(SELECT 1 FROM messages WHERE body='keep my conversation')")
for name,age in [('events',30),('checkpoints',7),('runs',90),('recordings',30),('revisions',90),('deleted_memories',90),('leases',7),('profile_locks',7)]: for name,age in [('events',30),('checkpoints',7),('runs',90),('run_activity',7),('recordings',30),('revisions',90),('deleted_memories',90),('leases',7),('profile_locks',7)]:
clean(name,age) clean(name,age)
check('(SELECT count(*) FROM runs)=6 AND (SELECT count(*) FROM memory_revisions)=9') check('(SELECT count(*) FROM runs)=6 AND (SELECT count(*) FROM memory_revisions)=9')
print('PASS: all 8 retention rules; bounded batches; live work and user content preserved; repeat-safe') print('PASS: all 9 retention rules; bounded batches; live work and user content preserved; repeat-safe')
finally: finally:
sql(f'DROP DATABASE {DB};','postgres') sql(f'DROP DATABASE {DB};','postgres')

210
tests/run-activity.test.py Normal file
View File

@ -0,0 +1,210 @@
"""Integration regression: the run trail the hover bubble reads, and the manual
retry that re-queues a dead run without losing where it stopped.
Requires the project's Postgres Compose service; never modifies the app database.
"""
from pathlib import Path
import re
import subprocess
import uuid
ROOT = Path(__file__).resolve().parents[1]
DB = 'run_activity_test_' + uuid.uuid4().hex
MONITOR_RS = (ROOT/'crates/api/src/monitor.rs').read_text()
RUNS_RS = (ROOT/'crates/api/src/runs.rs').read_text()
ACTIVITY_SQL = (ROOT/'crates/api/src/retention/run_activity.sql').read_text()
def sql(text, database=DB):
result = subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-q','-v','ON_ERROR_STOP=1','-U','lazyboy','-d',database], input=text, text=True, capture_output=True, cwd=ROOT)
if result.returncode:
raise AssertionError(result.stderr)
return result.stdout.strip()
def query(text):
result = subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-q','-t','-A','-U','lazyboy','-d',DB], input=text, text=True, capture_output=True, cwd=ROOT)
if result.returncode:
raise AssertionError(result.stderr)
return result.stdout.strip()
def check(condition, label):
if query(f"SELECT ({condition});") != 't':
raise AssertionError(f'run-activity assertion failed: {label}')
def flatten(text):
return re.sub(r'\s+',' ',text).strip()
def trail(run, after=None, limit=60):
"""The handler's paging query: newest window first read, then forward by id."""
cursor = 'NULL' if after is None else str(after)
statement = """SELECT id FROM ( SELECT id FROM run_activity WHERE run_id='%s' AND (%s::bigint IS NULL OR id>%s) ORDER BY id DESC LIMIT %s ) recent ORDER BY id ASC""" % (run, cursor, cursor, limit)
return query(statement)
def row_ids(run):
return query(f"SELECT id FROM run_activity WHERE run_id='{run}' ORDER BY id;").split()
def retry(run):
"""The handler's retry UPDATE, verbatim apart from the bind parameters."""
statement = """
UPDATE runs SET status='queued', retry_count=0, error=NULL, completed_at=NULL,
lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id='%s' AND space_id='s' AND user_id='u'
AND status IN ('failed','cancelled')
AND NOT EXISTS (
SELECT 1 FROM runs a
WHERE a.bot_id=runs.bot_id AND a.id<>runs.id
AND a.status IN ('leased','running','waiting_input','waiting_takeover')
AND (a.lease_expires_at IS NULL OR a.lease_expires_at >= now())
)
RETURNING thread_id""" % run
return query(statement) or '<null>'
# The bubble is only as honest as what the run loop writes. Pin the shapes so a
# silent rename shows up here instead of as a blank panel.
monitor = flatten(MONITOR_RS)
for fragment in [
'.route("/api/runs/{id}/activity", get(activity))',
'.route("/api/runs/{id}/retry", post(retry))',
'INSERT INTO run_activity (run_id,kind,payload)',
'SELECT id, kind, payload, created_at FROM run_activity WHERE run_id=$1 AND ($2::bigint IS NULL OR id>$2) ORDER BY id DESC LIMIT $3',
"(checkpoint->>'turn')::bigint AS turn",
"(checkpoint->>'turnLimit')::bigint AS turn_limit",
"AND status IN ('failed','cancelled')",
'a.bot_id=runs.bot_id AND a.id<>runs.id',
"a.status IN ('leased','running','waiting_input','waiting_takeover')",
'"run is not retryable"',
]:
assert flatten(fragment) in monitor, f'monitor SQL changed, update tests/run-activity.test.py: {fragment}'
runs_source = flatten(RUNS_RS)
for fragment in [
'"kind": "error"',
'"code": failure.code',
'"Worker interrupted after tool execution',
"'step', $2::text, 'stepAt', now(), 'turn', $3::bigint, 'turnLimit', $4::bigint",
'"event": "started"',
'"event": "failed"',
'"status": tool_status(',
'"elapsedMs": model_elapsed',
]:
assert flatten(fragment) in runs_source, f'run loop instrumentation changed, update tests/run-activity.test.py: {fragment}'
# template0, not template1: hosts whose Postgres collation version drifted refuse to
# copy template1, and C collation keeps this test's ordering deterministic anyway.
sql(f"CREATE DATABASE {DB} TEMPLATE template0 LC_COLLATE 'C' LC_CTYPE 'C';",'postgres')
try:
for migration in sorted((ROOT/'migrations').glob('*.sql')):
sql(migration.read_text())
# 015 has to survive being applied twice, like every other migration.
sql((ROOT/'migrations/015_run_activity.sql').read_text())
check("""(SELECT count(*) FROM pg_indexes WHERE tablename='run_activity' AND indexname IN ('run_activity_run_idx','run_activity_retention_idx'))=2""",
'the trail needs its run index and its retention index')
sql("""
INSERT INTO users(id,name) VALUES ('u','test');
INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test');
INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key) VALUES ('c','s','u','bot','c','c');
INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES
('b_live','s','u','live','c'),('b_idle','s','u','idle','c'),
('b_busy','s','u','busy','c'),('b_other','s','u','other','c');
INSERT INTO threads(id,space_id,bot_id,user_id) VALUES
('t_live','s','b_live','u'),('t_idle','s','b_idle','u'),
('t_busy','s','b_busy','u'),('t_other','s','b_other','u');
INSERT INTO runs(id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint) VALUES
('r_live','s','b_live','t_live','u','running','整理報價','{"step":"browser: click #12","turn":3,"turnLimit":40}'),
('r_goal','s','b_live','t_live','u','running','/goal 每天彙整日報','{"step":"shell: npm test","turn":7,"turnLimit":null}'),
('r_failed','s','b_idle','t_idle','u','failed','整理報價','{"step":"browser: click #12","turn":7}'),
('r_cancelled','s','b_idle','t_idle','u','cancelled','整理報價','{"keep":true}'),
('r_busy_running','s','b_busy','t_busy','u','running','還沒結束','{}'),
('r_busy_failed','s','b_busy','t_busy','u','failed','整理報價','{}'),
('r_other','s','b_other','t_other','u','running','別人的工作正在跑','{}');
UPDATE runs SET error='HTTP status 401 Unauthorized: invalid api key' WHERE id='r_failed';
UPDATE runs SET retry_count=3, lease_owner='stale-worker', lease_expires_at=now()+interval '5 minutes' WHERE id='r_failed';
""")
# The header the bubble reads: turn, limit and step must come back typed.
check("""(SELECT (checkpoint->>'turn')::bigint FROM runs WHERE id='r_live')=3
AND (SELECT (checkpoint->>'turnLimit')::bigint FROM runs WHERE id='r_live')=40""",
'turn and turn limit must be readable as numbers')
check("""(SELECT (checkpoint->>'turnLimit')::bigint IS NULL FROM runs WHERE id='r_goal')""",
'a goal run has no limit instead of a broken one')
# A trail, oldest first, with the payload the panel renders.
sql("""
INSERT INTO run_activity(run_id,kind,payload) VALUES
('r_live','run','{"event":"started","task":"整理報價"}'),
('r_live','model','{"turn":1,"elapsedMs":6400,"toolCalls":1,"text":"先打開網頁"}'),
('r_live','tool','{"turn":1,"name":"browser","step":"browser: click #12","status":"ok","elapsedMs":400}'),
('r_live','retry','{"turn":2,"attempt":1,"error":"429 rate limit","gaveUp":false}'),
('r_live','tool','{"turn":2,"name":"browser","step":"browser: type #13","status":"timed_out","elapsedMs":150000}');
INSERT INTO run_activity(run_id,kind,payload) VALUES ('r_failed','run','{"event":"failed","error":"HTTP status 401"}');
""")
assert len(row_ids('r_live')) == 5, 'the trail collected every turn'
check("""(SELECT payload->>'step' FROM run_activity WHERE kind='tool' ORDER BY id LIMIT 1)='browser: click #12'""",
'a tool line carries the step it is on')
# Paging: the first read is the newest window (oldest first so it renders in
# order), later reads only carry what is newer than the row already shown.
live = row_ids('r_live')
assert len(live) == 5, f'expected five trail rows, got {live}'
assert trail('r_live', None, 60).split() == live, 'a full window is the whole trail, oldest first'
assert trail('r_live', None, 2).split() == live[3:], f'the first read must be the newest window, got {trail("r_live", None, 2).split()}'
assert trail('r_live', live[2], 60).split() == live[3:], 'a cursor must never serve a row twice'
assert trail('r_live', live[4], 60) == '', 'a tail read on a quiet run stays empty'
assert trail('r_nothing', None, 60) == '', 'a run with no trail shows an empty panel, not an error'
sql("""
INSERT INTO run_activity(run_id,kind,payload) VALUES
('r_live','tool','{"turn":3,"name":"shell","step":"shell: npm run build","status":"ok"}'),
('r_live','notice','{"turn":3,"text":"這輪不需要電腦"}');
""")
fresh = row_ids('r_live')[5:]
assert len(fresh) == 2, f'two new rows expected, got {fresh}'
assert trail('r_live', live[4], 60).split() == fresh, 'new rows extend the panel in arrival order'
assert trail('r_live', live[4], 1) == fresh[1], 'a small window keeps the freshest line, not the oldest'
assert len(set(trail('r_live', None, 60).split())) == 7, 'the window never repeats a row'
# Manual retry: back to queued, counters reset, checkpoint kept for the resume.
assert retry('r_failed') == 't_idle', 'a failed run must be re-queueable from the chat'
check("""(SELECT status FROM runs WHERE id='r_failed')='queued'""", 'a retry goes back to the queue')
check("""(SELECT retry_count FROM runs WHERE id='r_failed')=0""", 'a human retry is not the automatic retry')
check("""(SELECT error FROM runs WHERE id='r_failed') IS NULL""", 'the stale failure must stop being shown')
check("""(SELECT lease_owner FROM runs WHERE id='r_failed') IS NULL""", 'a re-queued run must not keep the dead worker lease')
check("""(SELECT checkpoint->>'step' FROM runs WHERE id='r_failed')='browser: click #12'""",
'the checkpoint survives so the run continues where it stopped')
assert retry('r_failed') == '<null>', 'a queued run cannot be re-queued twice'
assert retry('r_cancelled') == 't_idle', 'a cancelled run can be restarted too'
# Only the bot that is actually idle may be restarted, and only for itself.
assert retry('r_busy_running') == '<null>', 'a live run is never hijacked by a retry'
assert retry('r_busy_failed') == '<null>', 'a bot with work in flight cannot be double-booked'
sql("UPDATE runs SET status='cancelled' WHERE id='r_busy_running'")
assert retry('r_busy_failed') == 't_busy', 'once its other run is gone the retry works'
assert retry('r_other') == '<null>', 'another bot is none of this retry\'s business'
# Diagnostics are expendable: old trail lines expire, fresh ones stay.
sql("""
INSERT INTO run_activity(run_id,kind,payload,created_at)
SELECT 'r_live','notice','{"text":"old"}',now()-interval '30 days' FROM generate_series(1,3) n;
UPDATE run_activity SET created_at=now()-interval '30 days' WHERE payload->>'text'='old';
""")
stale = query("SELECT count(*) FROM run_activity WHERE created_at < now()-interval '7 days';")
sql(ACTIVITY_SQL.replace('$1','7').replace('$2','1000')+';')
removed = int(stale) - int(query("SELECT count(*) FROM run_activity WHERE created_at < now()-interval '7 days';"))
assert removed == int(stale), f'retention must remove every expired row ({stale}), removed {removed}'
check("""(SELECT count(*) FROM run_activity WHERE run_id='r_live')=7""", 'fresh trail rows are never collected')
sql(ACTIVITY_SQL.replace('$1','7').replace('$2','1000')+';')
check("""(SELECT count(*) FROM run_activity WHERE run_id='r_live')=7""", 'repeat retention batches stay safe')
# And the trail dies with its run, never as an orphan holding the table open.
sql("DELETE FROM runs WHERE id='r_live';")
check("""(SELECT count(*) FROM run_activity WHERE run_id='r_live')=0""", 'deleting a run must delete its trail')
print('PASS: trail paging, typed turn/limit header, guarded manual retry that keeps the checkpoint, expiring diagnostics, cascading cleanup')
finally:
sql(f'DROP DATABASE {DB};','postgres')