mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:46:25 +00:00
Merge pull request #32 from 0xWheatyz/claude/mobile-app-feature-parity-run9dz
Fix untrusted-workspace wedge on headless runs + mobile Activity screen
This commit is contained in:
@@ -6,6 +6,24 @@ the image workflows publish (plus `latest` from every push to `main`).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Activity screen in the mobile app** (Settings → Manage → Activity): the
|
||||
control-command queue with status filters, per-row worker attribution
|
||||
(`on <worker>` / `unclaimed`), expandable result/error text, a Sweep CI action, and
|
||||
a 5s auto-refresh — the screen that answers "why is my login/spawn/sync stuck" from
|
||||
the phone.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Untrusted-workspace wedge on headless runs.** Phase 4's tmux-path deletion also
|
||||
removed the only call to `claude_config.ensure_onboarded`, so agent working dirs —
|
||||
every fresh worktree — were never pre-trusted in `~/.claude.json` and headless
|
||||
`claude -p` runs wedged or refused on the trust dialog with nobody at a TTY. Spawn
|
||||
and resume now re-seed onboarding + per-directory trust before every launch (resume
|
||||
included, so a cross-worker resume landing in a container that has never seen the
|
||||
working dir is covered). 2 regression tests.
|
||||
|
||||
### Added — mobile app feature parity
|
||||
|
||||
The iOS app (`app/`) catches up with everything the backend and web dashboard gained
|
||||
|
||||
@@ -41,6 +41,7 @@ import { PermissionsScreen } from "./src/screens/manage/PermissionsScreen";
|
||||
import { RepositoriesScreen } from "./src/screens/manage/RepositoriesScreen";
|
||||
import { GitServersScreen } from "./src/screens/manage/GitServersScreen";
|
||||
import { ApprovalsScreen } from "./src/screens/manage/ApprovalsScreen";
|
||||
import { ActivityScreen } from "./src/screens/manage/ActivityScreen";
|
||||
import { SharedContextScreen } from "./src/screens/manage/SharedContextScreen";
|
||||
import { UsersScreen } from "./src/screens/manage/UsersScreen";
|
||||
import { AccountScreen } from "./src/screens/manage/AccountScreen";
|
||||
@@ -78,6 +79,7 @@ function Router() {
|
||||
repositories: RepositoriesScreen,
|
||||
gitServers: GitServersScreen,
|
||||
approvals: ApprovalsScreen,
|
||||
activity: ActivityScreen,
|
||||
shared: SharedContextScreen,
|
||||
users: UsersScreen,
|
||||
account: AccountScreen,
|
||||
|
||||
@@ -46,6 +46,7 @@ The full admin surface — everything the web dashboard can do, under
|
||||
| Plugins | Marketplace plugins pinned to their repo |
|
||||
| Permissions | Default permission mode + allow/deny/ask rules over the read-only env baseline |
|
||||
| Claude login | Drive the worker's `claude /login` (authorize in browser, paste the code back) |
|
||||
| Activity | The control-command queue: status filters, worker attribution, result/error detail, 5s auto-refresh |
|
||||
| Repositories | Register repos (git-server or manual mode, optional mise-init bootstrap), sync, delete |
|
||||
| Git servers | Forge hosts: encrypted tokens, generated deploy keys (public half copyable) |
|
||||
| Approvals | Record operator approve / reject verdicts per project + branch |
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { text } from "../../theme/tokens";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Chip } from "../../components/Chip";
|
||||
import { ErrorNotice, ManageShell } from "../../components/ManageShell";
|
||||
import { Card, Divider, Mono, SectionLabel } from "../../components/primitives";
|
||||
import { useAppState } from "../../state/AppState";
|
||||
import { useResource } from "../../state/useResource";
|
||||
import { statusLabel, statusTone, timeAgo } from "../../api/format";
|
||||
import type { Command } from "../../api/client";
|
||||
|
||||
/**
|
||||
* Activity — the control-command queue: every enqueued action and its status
|
||||
* (queued → running → done/failed), the audit log of what the operator triggered.
|
||||
* This is the screen that answers "why is my login/spawn/sync stuck": a row stuck
|
||||
* `queued` means no worker is claiming; a `failed` row carries the worker's error
|
||||
* verbatim. Auto-refreshes while open so a command can be watched to completion.
|
||||
*/
|
||||
|
||||
const FILTERS = ["all", "queued", "running", "failed", "done"];
|
||||
|
||||
export function ActivityScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { client } = useAppState();
|
||||
const { data, error, loading, reload } = useResource<Command[]>("/commands?limit=100");
|
||||
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
const [sweepNote, setSweepNote] = useState<string | null>(null);
|
||||
const [sweepError, setSweepError] = useState<string | null>(null);
|
||||
|
||||
// The whole point of this screen is watching a command land — poll while open.
|
||||
useEffect(() => {
|
||||
const id = setInterval(reload, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [reload]);
|
||||
|
||||
const commands = (data ?? []).filter((c) =>
|
||||
filter === "all" ? true : c.status === filter,
|
||||
);
|
||||
|
||||
async function sweepCi() {
|
||||
if (!client) return;
|
||||
setSweepError(null);
|
||||
try {
|
||||
await client.api("/poll-ci", { method: "POST" });
|
||||
setSweepNote("CI sweep queued.");
|
||||
setTimeout(() => setSweepNote(null), 4000);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setSweepError(e instanceof Error ? e.message : "Couldn't queue the sweep.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageShell
|
||||
title="Activity"
|
||||
subtitle="Control commands the worker drains from the queue. A row stuck on queued means no worker is claiming work."
|
||||
>
|
||||
<View style={styles.topRow}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={f === "all" ? "All" : statusLabel(f)}
|
||||
selected={filter === f}
|
||||
onPress={() => setFilter(f)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<Button size="sm" variant="secondary" onPress={sweepCi}>
|
||||
Sweep CI
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{sweepNote ? (
|
||||
<Text style={[text.caption, { color: colors.positive, marginBottom: 10 }]}>
|
||||
{sweepNote}
|
||||
</Text>
|
||||
) : null}
|
||||
<ErrorNotice message={sweepError ?? error} />
|
||||
|
||||
{loading && data === null ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>Loading…</Text>
|
||||
) : commands.length === 0 ? (
|
||||
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||
{filter === "all" ? "No commands yet." : `No ${filter} commands.`}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<SectionLabel style={{ marginBottom: 8 }}>
|
||||
{`${commands.length} command${commands.length === 1 ? "" : "s"} · refreshes every 5s`}
|
||||
</SectionLabel>
|
||||
<Card>
|
||||
{commands.map((c, i) => {
|
||||
const open = openId === c.id;
|
||||
const detail = c.error || (c.result ? JSON.stringify(c.result) : null);
|
||||
return (
|
||||
<View key={c.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<Pressable
|
||||
style={styles.row}
|
||||
onPress={() => setOpenId(open ? null : c.id)}
|
||||
>
|
||||
<View style={styles.titleRow}>
|
||||
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
|
||||
{c.type}
|
||||
</Mono>
|
||||
<Badge tone={statusTone(c.status)}>{statusLabel(c.status)}</Badge>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginLeft: "auto" }]}>
|
||||
{timeAgo(c.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
{c.project_id ?? "—"}
|
||||
{c.agent_name ? ` · ${c.agent_name}` : ""}
|
||||
{c.claimed_by ? ` · on ${c.claimed_by}` : " · unclaimed"}
|
||||
</Text>
|
||||
{detail ? (
|
||||
<Mono
|
||||
numberOfLines={open ? undefined : 1}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
marginTop: 4,
|
||||
color: c.error ? colors.danger : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{detail}
|
||||
</Mono>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</ManageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 14,
|
||||
},
|
||||
filters: { flexDirection: "row", gap: 8, paddingRight: 10 },
|
||||
row: { paddingVertical: 12, paddingHorizontal: 16 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" },
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const CLAUDE_ROWS: Row[] = [
|
||||
];
|
||||
|
||||
const SERVER_ROWS: Row[] = [
|
||||
{ screen: "activity", title: "Activity", subtitle: "The command queue — see why an action is stuck" },
|
||||
{ screen: "repositories", title: "Repositories", subtitle: "Register + sync project repos" },
|
||||
{ screen: "gitServers", title: "Git servers", subtitle: "Forge hosts, tokens, deploy keys" },
|
||||
{ screen: "approvals", title: "Approvals", subtitle: "Approve or reject protected branches" },
|
||||
|
||||
@@ -55,6 +55,7 @@ export type Screen =
|
||||
| "repositories"
|
||||
| "gitServers"
|
||||
| "approvals"
|
||||
| "activity"
|
||||
| "shared"
|
||||
| "users"
|
||||
| "account"
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..config import get_settings
|
||||
from ..db import repository as repo
|
||||
from ..db.engine import connection
|
||||
from . import (
|
||||
claude_config,
|
||||
claude_gen,
|
||||
credentials,
|
||||
forge,
|
||||
@@ -160,6 +161,11 @@ def spawn(
|
||||
# half also feeds pi-harness agents (their settings.json points at the same dir).
|
||||
# Scoped to the project's owner: shared rows plus theirs, nobody else's.
|
||||
claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
|
||||
# Mark onboarding complete and trust this working dir in ~/.claude.json before
|
||||
# claude boots: a fresh worktree is a brand-new path, and an untrusted workspace
|
||||
# wedges/refuses a headless run with nobody at a TTY to accept the dialog. (The
|
||||
# old tmux launch path did this; it was lost when phase 4 deleted that path.)
|
||||
claude_config.ensure_onboarded(working_dir)
|
||||
env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init)
|
||||
|
||||
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
|
||||
@@ -286,6 +292,9 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
|
||||
working_dir = agent["working_dir"]
|
||||
settings_path = settings_gen.write_settings(working_dir)
|
||||
claude_gen.apply(working_dir, visible_to=project.get("owner_user_id"))
|
||||
# Cross-worker resume may land in a container whose ~/.claude.json has never seen
|
||||
# this working dir — re-seed trust exactly as spawn does.
|
||||
claude_config.ensure_onboarded(working_dir)
|
||||
try:
|
||||
token = None
|
||||
with connection() as conn:
|
||||
|
||||
@@ -181,3 +181,38 @@ def test_resume_refused_while_run_live(env, fake_launch):
|
||||
ok, detail = spawn.resume(agent, "answer")
|
||||
assert ok is False
|
||||
assert "live run" in detail
|
||||
|
||||
|
||||
def test_spawn_trusts_working_dir_in_claude_json(env, fake_launch):
|
||||
"""The launch must pre-trust the agent's working dir in ~/.claude.json — an
|
||||
untrusted workspace wedges a headless run on the trust dialog with nobody at a
|
||||
TTY (regression: the call was lost when phase 4 deleted the tmux launch path)."""
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
|
||||
spawn.spawn("proj", "api", task="build the thing")
|
||||
|
||||
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
||||
assert cfg["hasCompletedOnboarding"] is True
|
||||
entry = cfg["projects"][str(root)]
|
||||
assert entry["hasTrustDialogAccepted"] is True
|
||||
|
||||
|
||||
def test_resume_trusts_working_dir_in_claude_json(env, fake_launch):
|
||||
"""Cross-worker resume may run in a container that has never seen this working
|
||||
dir; resume must re-seed trust exactly as spawn does."""
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
spawn.spawn("proj", "api", task="do it")
|
||||
with get_engine().begin() as conn:
|
||||
agent = repo.get_agent_by_name(conn, "proj", "api")
|
||||
repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
|
||||
(env["tmp"] / ".claude.json").unlink() # a "fresh container": no config at all
|
||||
|
||||
ok, _ = spawn.resume(agent, "use Postgres")
|
||||
|
||||
assert ok is True
|
||||
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
||||
assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True
|
||||
|
||||
Reference in New Issue
Block a user