Compare commits

...

8 Commits

Author SHA1 Message Date
xiaoju 942ff4b1a4 fix(cli): race condition in thread rm + flaky test (#265)
Two fixes:
1. cmdThreadRemove: always call both removeThreadEntry and
   removeThreadHistoryEntries regardless of resolved source,
   preventing race where thread moves from active to history
   between resolve and delete.
2. Test: add waitUntilRunningFileAbsent before thread show/rm,
   matching the pattern used by adjacent test cases.

Verified 5x consecutive runs with 0 failures.

Closes #265
2026-05-14 13:17:04 +00:00
xiaomo 71ccf8d03c Merge pull request 'chore(util-agent): remove dead createTextAdapter / TextProducerFn' (#263) from chore/252-remove-text-adapter into main 2026-05-14 12:58:22 +00:00
xingyue 510b49287a Merge pull request 'feat(dashboard): redesign workflow detail layout' (#257) from feat/workflow-detail-layout into main 2026-05-14 12:58:04 +00:00
xiaoju bb6b309efa chore(util-agent): remove dead createTextAdapter / TextProducerFn
All adapters migrated to createAgentAdapter in #261. No consumers remain.

Refs #252
2026-05-14 12:23:01 +00:00
xiaomo 56db22a908 Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main 2026-05-14 12:19:37 +00:00
xingyue d037eca4ae feat(dashboard): recursive schema rendering with nested object, array, oneOf
- Nested object: expand properties with └─ indentation
- object[]: show type as 'object[]', expand items.properties
- string[]/number[]: show type directly, no expansion
- oneOf/discriminatedUnion: variant headers with ├/└ connectors
  - Auto-detect discriminator field (const values)
  - Skip discriminator in variant field list
- Recursive to arbitrary depth

Phase 1+2 of #258
2026-05-14 18:19:01 +08:00
xingyue b9d543a465 fix: move hooks before early returns to fix Rules of Hooks crash 2026-05-14 16:53:47 +08:00
xingyue c7b426ff5a feat(dashboard): redesign workflow detail layout
Left sidebar: compact workflow graph with clickable nodes for navigation.
Right panel: workflow overview card + per-role cards with meta schema tables.

Clicking a node in the graph scrolls to the corresponding role card.
All nodes are lit in static view (workflow definition, not a thread).
2026-05-14 16:47:29 +08:00
5 changed files with 326 additions and 113 deletions
@@ -180,6 +180,9 @@ describe("cli thread commands", () => {
}
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
const shown = await cmdThreadShow(storageRoot, threadId);
expect(shown.ok).toBe(true);
if (!shown.ok) {
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
return err(`thread not found: ${threadId}`);
}
if (resolved.source === "active") {
await removeThreadEntry(resolved.bundleDir, threadId);
} else {
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
// Always clear both stores: between resolve and delete the worker may finish and
// move the thread from threads.json into history; branching only on resolved.source
// would skip history removal and leave a dangling row.
await removeThreadEntry(resolved.bundleDir, threadId);
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkflowDetail } from "../api.ts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail, listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
@@ -17,6 +17,214 @@ function versionCount(detail: WorkflowDetail): number {
return detail.history.length + 1;
}
type SchemaRow = {
key: string;
name: string;
type: string;
description: string;
depth: number;
prefix: string;
isVariantHeader: boolean;
};
function resolveType(prop: Record<string, unknown>): string {
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined) {
const itemType = String(items.type ?? "unknown");
return `${itemType}[]`;
}
return "array";
}
return String(prop.type ?? "unknown");
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
parentRequired: Set<string>,
): SchemaRow[] {
const rows: SchemaRow[] = [];
// Handle oneOf / discriminatedUnion
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) {
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
// Try to find a distinguishing literal (e.g. status: "approved")
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
let variantLabel = `Variant ${vi + 1}`;
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) {
variantLabel = `${pName}: ${String(pDef.const)}`;
break;
}
}
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue; // skip discriminator field
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
rows.push(...subRows);
}
}
return rows;
}
// Handle regular object with properties
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
for (const [name, prop] of Object.entries(props)) {
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
rows.push(...subRows);
}
return rows;
}
function flattenProperty(
name: string,
prop: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
required: Set<string>,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
if (!required.has(name)) type += "?";
const description = String(prop.description ?? "");
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
rows.push({
key: `${keyPrefix}${name}`,
name: displayName,
type,
description,
depth,
prefix: parentPrefix,
isVariantHeader: false,
});
// Recurse into nested object
if (prop.type === "object" && prop.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
}
// Recurse into array of objects
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set()));
}
}
// Recurse into oneOf
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
}
return rows;
}
function RoleCard({
roleName,
role,
}: {
roleName: string;
role: WorkflowRoleDescriptor;
}) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
return (
<div
id={`role-${roleName}`}
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h4
className="text-sm font-semibold font-mono mb-1"
style={{ color: "var(--color-text)" }}
>
{roleName}
</h4>
{role.description !== "" && (
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
{role.description}
</p>
)}
{rows.length > 0 && (
<div>
<p
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Meta Schema
</p>
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Field</th>
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Type</th>
<th className="text-left py-1 font-medium" style={{ color: "var(--color-text-muted)" }}>Description</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.key}
style={{
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
}}
>
<td
className="py-1 pr-3 font-mono whitespace-pre"
style={{
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
fontStyle: r.isVariantHeader ? "italic" : "normal",
}}
>
{r.name}
</td>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{r.type}</td>
<td className="py-1" style={{ color: "var(--color-text)" }}>{r.description || (r.isVariantHeader ? "" : "—")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
<pre
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
>
{JSON.stringify(role.schema, null, 2)}
</pre>
)}
</div>
);
}
function ExpandedWorkflowBody({
cacheEntry,
staticNodeStates,
@@ -24,6 +232,24 @@ function ExpandedWorkflowBody({
cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>;
}) {
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null;
const descriptor = detail?.descriptor ?? null;
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
// All roles are "completed" (static view, all nodes lit) — must be before early returns
const allLitStates = useMemo(() => {
const m = new Map<string, NodeState>();
m.set("__start__", "completed");
m.set("__end__", "completed");
for (const [name] of roleEntries) {
m.set(name, "completed");
}
return m;
}, [roleEntries]);
if (cacheEntry === undefined || cacheEntry.status === "loading") {
return (
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
@@ -40,71 +266,111 @@ function ExpandedWorkflowBody({
);
}
const { detail } = cacheEntry;
const descriptor = detail.descriptor;
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = versionCount(detail);
const vc = detail !== null ? versionCount(detail) : 0;
const hasGraph = descriptor !== null && edgeCount > 0;
function handleGraphNodeClick(nodeId: string) {
const el = document.getElementById(`role-${nodeId}`);
if (el === null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
return (
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
{/* Left: graph sidebar */}
{hasGraph && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">Workflow graph</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={allLitStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
)}
{/* Right: workflow info + role cards */}
<div className="flex-1 min-w-0 space-y-4">
{/* Workflow overview */}
<div
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
{detail.name}
</h3>
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
</div>
{hasGraph ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
minHeight: 500,
}}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
>
<span className="font-mono">Workflow graph</span>
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
Hash:{" "}
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</span>
</div>
<div style={{ height: 600, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={staticNodeStates}
onNodeClick={null}
/>
<span>
{vc} version{vc !== 1 ? "s" : ""}
</span>
{roleEntries.length > 0 && (
<span>
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
) : null}
{/* Role cards */}
{roleEntries.map(([name, role]) => (
<div
key={name}
style={{
transition: "box-shadow 0.3s",
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
borderRadius: 8,
}}
>
<RoleCard roleName={name} role={role} />
</div>
))}
</div>
</div>
);
}
@@ -1,54 +0,0 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type { WorkflowRuntime } from "@uncaged/workflow-runtime";
/**
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
*/
export type TextAdapterResult = {
output: string;
childThread: string | null;
};
/**
* A function that produces raw text output given the thread context and
* the system prompt for the current role.
*/
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<string | TextAdapterResult>;
/**
* Creates an {@link AdapterFn} from a text-producing function.
*
* The adapter:
* 1. Calls the producer with thread context + system prompt
* 2. Stores output in CAS
* 3. Runs the extract phase to produce typed meta
* 4. Returns `{ meta, childThread }`
*/
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt, runtime);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread };
};
};
}
@@ -1,7 +1,5 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
export { createTextAdapter } from "./create-text-adapter.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";