logs + team parity: pretty step/container renderers; quota + audit on team creation
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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user