logs + team parity: pretty step/container renderers; quota + audit on team creation
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 2m58s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m53s

Two bundled changes:

── LiveRunLogs prettification ──────────────────────────────────
The Steps + Container tabs were plain mono lines with a single
color per event. Now they get structured layout:

Steps:
- Color-hashed actor pill (stable palette so [Distiller] and
  [Novelty Analyst] each get their own hue across the session).
- Phase pill (plan=cyan, work=green, synth=amber, aggregate=purple).
- Token count pill formatted 1.2k / 14.3k / etc.
- Gated-action warning pill in amber when > 0.
- Left-border color strip keyed to the actor for at-a-glance
  visual grouping.
- Long outputs collapse to their first 300 chars with a '+ N more'
  toggle to expand the full text.
- 'done' events get a green (or red for error) border strip +
  pill instead of blending into the stream.

Container:
- Splits '[actor] action (outcome) · msg' into colored spans —
  actor pill (deterministic color), action in dim, outcome pill
  green/red/dim by state.
- Non-line events (info/error/done) get their own left-border
  strip so bash echoes and stack traces don't drown in the daemon
  chatter.
- Timestamps switch to HH:MM:SS.mmm — dense but scannable.

Small palette (LOG constants) keeps the color budget bounded — no
new UI vocabulary, just cleaner reads of what was already there.

── Team-wizard governance parity ───────────────────────────────
build_team_with_lifecycle now matches POST /api/claws' governance:
- enforce_new_agent quota check per member (previously bypassed
  workspace agent quotas entirely for team/auto-provision paths).
- audit::append('agent.created', ..., {source: 'team_wizard'}) per
  member so team-created claws appear in the same audit trail as
  individually-created ones. Adding a 'source' key distinguishes
  provenance without changing consumers.

.brain (h5) handling was already consistent between the two paths —
both use the lazy on-first-access load_brain hook seeded from
agents.system_prompt. No change there.
This commit is contained in:
Omar Sobh
2026-07-17 18:20:56 -07:00
parent 956be2cf4f
commit ac8c689f50
2 changed files with 304 additions and 50 deletions
+19
View File
@@ -96,6 +96,11 @@ pub(crate) async fn build_team_with_lifecycle(
let mut claw_ids: Vec<Uuid> = Vec::with_capacity(members.len()); let mut claw_ids: Vec<Uuid> = Vec::with_capacity(members.len());
for m in members { for m in members {
// Parity with the individual `POST /api/claws` handler — each
// claw counts against the workspace's agent quota + emits an
// audit row. Without these the auto-provision + team-wizard
// paths silently bypassed both governance rails.
crate::quota::enforce_new_agent(state, workspace_id).await?;
let agent = Agent { let agent = Agent {
id: AgentId::new(), id: AgentId::new(),
workspace_id, workspace_id,
@@ -109,6 +114,20 @@ pub(crate) async fn build_team_with_lifecycle(
status: AgentStatus::Online, status: AgentStatus::Online,
}; };
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?; cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
cm_db::repo::audit::append(
&state.pool,
workspace_id,
cm_db::repo::audit::Actor::User(user_id),
"agent.created",
"agent",
&agent.id.to_string(),
serde_json::json!({
"name": agent.name,
"job_title": agent.job_title,
"source": "team_wizard",
}),
)
.await?;
let claw_id = agent.id.as_uuid(); let claw_id = agent.id.as_uuid();
provisioner provisioner
.provision_claw(claw_id, &m.model) .provision_claw(claw_id, &m.model)
+285 -50
View File
@@ -136,10 +136,113 @@ function useRunEvents(runId: string | null, onStep?: (p: StepPulse) => void) {
return { events, status }; return { events, status };
} }
function summarizeStep(raw: string): string { const STAGE_DOT: Record<string, string> = {
// The SSE `step` payload is a serialized orchestrator StepRecord: ok: "🟢",
// {node_id, role, phase, output, gated, tokens}. Render as waiting: "🔵",
// [role] phase · <first-line-of-output> · Nt warn: "🟡",
fail: "🔴",
skip: "◯",
};
// ── pretty log-line primitives ────────────────────────────────────────
/** Palette tuned to read cleanly on the dark terminal bg. Kept small on
* purpose — every added color raises the ambient noise floor. */
const LOG = {
ts: "#5a5a62",
dim: "#8a8a92",
fg: "#cfcfd5",
fgHi: "#eaeaee",
ok: "#5fd08a",
err: "#ff8a7a",
warn: "#ffb44a",
info: "#5ec8d8",
purple: "#c98af0",
cyan: "#5ec8d8",
green: "#5fd08a",
amber: "#ffb44a",
};
/** Colored dot + pill wrapper used everywhere. */
function Pill({
color,
children,
bg,
}: {
color: string;
bg?: string;
children: React.ReactNode;
}) {
return (
<span
style={{
display: "inline-block",
padding: "1px 6px",
borderRadius: 999,
border: `1px solid ${color}40`,
background: bg ?? `${color}12`,
color,
fontFamily: mono,
fontSize: 10,
lineHeight: "14px",
letterSpacing: ".02em",
}}
>
{children}
</span>
);
}
/** Deterministic actor color hash so [Distiller] and [Novelty Analyst]
* each get a stable, distinct hue across the whole session. */
function actorColor(actor: string): string {
if (!actor) return LOG.dim;
const palette = [
"#5ec8d8",
"#c98af0",
"#5fd08a",
"#ffb44a",
"#f38ba8",
"#89b4fa",
"#f9e2af",
"#a6e3a1",
];
let h = 0;
for (let i = 0; i < actor.length; i++) h = (h * 31 + actor.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
/** hh:mm:ss with `.mmm` fractionals — dense but scannable. */
function fmtTs(ts: number): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
/** Compact "1234" → "1.2k" for token counts. */
function fmtCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
const PHASE_COLOR: Record<string, string> = {
plan: LOG.cyan,
work: LOG.green,
synth: LOG.amber,
aggregate: LOG.purple,
};
/** Parse a raw StepRecord JSON into ready-to-render fields; falls back
* to raw text for anything the orchestrator hasn't formatted as JSON. */
function parseStep(raw: string): {
role?: string;
phase?: string;
node_id?: string;
tokens?: number;
gated?: number;
output?: string;
} {
try { try {
const j = JSON.parse(raw) as { const j = JSON.parse(raw) as {
node_id?: string; node_id?: string;
@@ -149,36 +252,182 @@ function summarizeStep(raw: string): string {
tokens?: number; tokens?: number;
gated?: unknown[]; gated?: unknown[];
}; };
const parts: string[] = [];
if (j.role) parts.push(`[${j.role}]`);
const phase = const phase =
typeof j.phase === "string" typeof j.phase === "string"
? j.phase ? j.phase
: j.phase && typeof j.phase === "object" && "kind" in j.phase : j.phase && typeof j.phase === "object" && "kind" in j.phase
? (j.phase.kind as string) ? (j.phase.kind as string)
: undefined; : undefined;
if (phase) parts.push(phase); return {
if (j.node_id && !j.role) parts.push(j.node_id); role: j.role,
if (typeof j.tokens === "number" && j.tokens > 0) parts.push(`${j.tokens}t`); phase,
if (Array.isArray(j.gated) && j.gated.length > 0) { node_id: j.node_id,
parts.push(`${j.gated.length} gated`); tokens: typeof j.tokens === "number" ? j.tokens : undefined,
} gated: Array.isArray(j.gated) ? j.gated.length : undefined,
const summary = parts.join(" "); output: j.output,
const output = (j.output ?? "").split("\n")[0]?.slice(0, 220); };
return output ? `${summary} · ${output}` : summary || raw;
} catch { } catch {
/* not JSON */ return { output: raw };
} }
return raw.length > 240 ? raw.slice(0, 240) + "…" : raw;
} }
const STAGE_DOT: Record<string, string> = { /** Pretty step line — timestamp · #idx · actor pill · phase pill ·
ok: "🟢", * meta pills (tokens, gated) · expandable output. Long outputs
waiting: "🔵", * collapse to their first line with a "+ N more" affordance. */
warn: "🟡", function StepLogLine({
fail: "🔴", index,
skip: "◯", ts,
}; raw,
isDone,
doneError,
}: {
index: number;
ts: number;
raw: string;
isDone: boolean;
doneError?: string | null;
}) {
const [expanded, setExpanded] = useState(false);
if (isDone) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "2px 0",
borderLeft: `2px solid ${doneError ? LOG.err : LOG.ok}`,
paddingLeft: 8,
marginTop: 6,
color: doneError ? LOG.err : LOG.fg,
}}
>
<span style={{ color: LOG.ts }}>{fmtTs(ts)}</span>
<Pill color={doneError ? LOG.err : LOG.ok}>done</Pill>
<span>{doneError ? `error: ${doneError}` : "run completed"}</span>
</div>
);
}
const p = parseStep(raw);
const outputLines = (p.output ?? "").split("\n");
const firstLine = outputLines[0] ?? "";
const remaining = outputLines.length - 1 + (firstLine.length > 300 ? 1 : 0);
const shortLine = firstLine.length > 300 ? firstLine.slice(0, 300) + "…" : firstLine;
const phaseColor = p.phase ? (PHASE_COLOR[p.phase] ?? LOG.dim) : LOG.dim;
const roleColor = actorColor(p.role ?? p.node_id ?? "");
const canExpand = (p.output ?? "").length > 300 || outputLines.length > 1;
return (
<div
style={{
padding: "3px 0 3px 8px",
borderLeft: `2px solid ${roleColor}66`,
}}
>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
<span style={{ color: LOG.ts, fontVariantNumeric: "tabular-nums" }}>
{fmtTs(ts)}
</span>
<span style={{ color: LOG.dim, fontVariantNumeric: "tabular-nums" }}>
#{index}
</span>
{p.role ? <Pill color={roleColor}>{p.role}</Pill> : null}
{p.phase ? <Pill color={phaseColor}>{p.phase}</Pill> : null}
{typeof p.tokens === "number" && p.tokens > 0 ? (
<Pill color={LOG.dim}>{fmtCount(p.tokens)}t</Pill>
) : null}
{p.gated && p.gated > 0 ? <Pill color={LOG.warn}>{p.gated} gated</Pill> : null}
{canExpand ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
padding: "1px 8px",
borderRadius: 999,
background: "transparent",
border: `1px solid ${LOG.dim}40`,
color: LOG.dim,
fontFamily: mono,
fontSize: 10,
cursor: "pointer",
}}
>
{expanded ? "collapse" : `+ ${remaining} more`}
</button>
) : null}
</div>
{shortLine ? (
<div style={{ marginTop: 2, color: LOG.fg, whiteSpace: "pre-wrap" }}>
{expanded ? (p.output ?? "") : shortLine}
</div>
) : null}
</div>
);
}
/** Split `[actor] action (outcome) · message` into colored spans. Falls
* back to a plain line for non-matching text (bash echoes, stack
* traces, etc). */
function ContainerLogLine({
ts,
kind,
text,
}: {
ts: number;
kind: "info" | "line" | "error" | "done";
text: string;
}) {
if (kind !== "line") {
const color = kind === "error" ? LOG.err : LOG.dim;
return (
<div
style={{
padding: "2px 0 2px 8px",
borderLeft: `2px solid ${color}80`,
color,
}}
>
<span style={{ color: LOG.ts, marginRight: 6 }}>{fmtTs(ts)}</span>
<Pill color={color}>{kind}</Pill>{" "}
<span>{text}</span>
</div>
);
}
// Match: `[actor] action (outcome) · msg` OR `[actor] action · msg`
const m = text.match(/^\[([^\]]+)\]\s+([a-z_]+)(?:\s*\(([a-z_]+)\))?\s*·\s*(.*)$/);
if (!m) {
return (
<div style={{ padding: "2px 0 2px 8px", borderLeft: `2px solid ${LOG.dim}30`, color: LOG.fg }}>
<span style={{ color: LOG.ts, marginRight: 6 }}>{fmtTs(ts)}</span>
{text}
</div>
);
}
const [, actor, action, outcome, msg] = m;
const roleColor = actorColor(actor);
const outcomeColor =
outcome === "success" ? LOG.ok : outcome === "failure" ? LOG.err : LOG.dim;
return (
<div
style={{
padding: "2px 0 2px 8px",
borderLeft: `2px solid ${outcome === "failure" ? LOG.err : roleColor}80`,
}}
>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
<span style={{ color: LOG.ts, fontVariantNumeric: "tabular-nums" }}>
{fmtTs(ts)}
</span>
<Pill color={roleColor}>{actor}</Pill>
<span style={{ color: LOG.dim }}>{action}</span>
{outcome ? <Pill color={outcomeColor}>{outcome}</Pill> : null}
</div>
{msg ? (
<div style={{ color: LOG.fg, whiteSpace: "pre-wrap" }}>{msg}</div>
) : null}
</div>
);
}
/** Fires whenever a new topology-run step SSE event lands. Consumers use /** Fires whenever a new topology-run step SSE event lands. Consumers use
* it to drive live UI signals — e.g. an agent-card glow keyed to the * it to drive live UI signals — e.g. an agent-card glow keyed to the
@@ -463,18 +712,14 @@ export function LiveRunLogs({
</div> </div>
) : null} ) : null}
{events.map((e, i) => ( {events.map((e, i) => (
<div <StepLogLine
key={`${i}-${e.ts}`} key={`${i}-${e.ts}`}
style={{ index={e.kind === "step" ? e.index : -1}
color: e.kind === "done" && e.error ? "#ff8a7a" : "#cfcfd5", ts={e.ts}
}} raw={e.raw}
> isDone={e.kind === "done"}
<span style={{ color: "#5a5a62" }}> doneError={e.kind === "done" ? (e.error ?? null) : null}
{new Date(e.ts).toLocaleTimeString()}{" "} />
{e.kind === "step" ? `#${e.index}` : "done"}
</span>{" "}
{summarizeStep(e.raw)}
</div>
))} ))}
</> </>
) : ( ) : (
@@ -491,22 +736,12 @@ export function LiveRunLogs({
</div> </div>
) : null} ) : null}
{containerLines.map((l, i) => ( {containerLines.map((l, i) => (
<div <ContainerLogLine
key={`${i}-${l.ts}`} key={`${i}-${l.ts}`}
style={{ ts={l.ts}
color: kind={l.kind}
l.kind === "error" text={l.text}
? "#ff8a7a" />
: l.kind === "info" || l.kind === "done"
? "#8a8a92"
: "#cfcfd5",
}}
>
<span style={{ color: "#5a5a62" }}>
{new Date(l.ts).toLocaleTimeString()}
</span>{" "}
{l.text}
</div>
))} ))}
</> </>
)} )}