Compare commits

..

14 Commits

Author SHA1 Message Date
xingyue b65a006d45 feat(dashboard): show system prompt per role in workflow detail
- Add systemPrompt to WorkflowRoleDescriptor (protocol)
- Propagate systemPrompt through buildDescriptor and validateWorkflowDescriptor
- Display system prompt as collapsible <details> in RoleCard
2026-05-15 09:27:03 +08:00
xingyue 15edc99c72 chore: add pre-push hook (check + test) and fix lint-log-tags for macOS 2026-05-15 09:11:39 +08:00
xingyue 153178c545 fix: biome format issues (12 errors) 2026-05-15 09:10:39 +08:00
xiaomo fac215bd21 Merge pull request 'chore(dashboard): remove unused _parentRequired param' (#267) from chore/remove-parentRequired-param into main 2026-05-15 00:30:51 +00:00
xiaomo 764b73209e Merge pull request 'feat(dashboard): workflow detail 独立子页面' (#264) from feat/workflow-detail-layout into main 2026-05-15 00:23:45 +00:00
xiaomo e7987c4cd7 Merge pull request 'fix(cli): race condition in thread rm + flaky test' (#266) from fix/265-flaky-thread-rm into main 2026-05-15 00:21:16 +00:00
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
xiaoju 2a1b7b0aeb refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter
- LLM: AgentFn<{prompt}> + createAgentAdapter, chatCompletionText unchanged
- Hermes: AgentFn<{prompt}> + createAgentAdapter, config validation in extract
- Cursor: AgentFn<{prompt, workspace}> + createAgentAdapter, workspace
  extraction moved to extract fn, AgentFn itself only receives resolved options

All public API signatures preserved. createTextAdapter/TextProducerFn retained.

Closes #261, Phase 2 of #252
2026-05-14 10:22:37 +00:00
xiaomo 07f52594d1 Merge pull request 'feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge' (#256) from feat/252-agent-fn into main 2026-05-14 08:53:30 +00:00
xiaoju 4582274ba4 feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge
Add AgentFn<Opt = void> as the formal agent boundary type:
- Input: ThreadContext (fixed), Output: string (fixed)
- Opt: agent-specific structured options (e.g. { workspace } for Cursor)

Add createAgentAdapter<Opt>(agent, extract) → AdapterFn bridge in
workflow-util-agent, plus createSimpleAgentAdapter for Opt = void.

Also fixes: workflow-cas composite flag + cursor tsconfig reference.

Refs #252
2026-05-14 08:41:22 +00:00
36 changed files with 415 additions and 220 deletions
+7 -3
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
# pre-push hook: typecheck + biome + lint-log-tags
set -euo pipefail
echo "🔍 pre-push: running checks..."
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
bun run check
echo "✅ pre-push: all checks passed"
echo "🧪 Running tests..."
bun run test
echo "✅ All checks passed!"
+1
View File
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
@@ -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) {
+1 -1
View File
@@ -3,8 +3,8 @@ import { printCliError, printCliLine } from "./cli-output.js";
import { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchConnect } from "./commands/connect/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
@@ -48,11 +48,13 @@ async function handleGatewayMessage(
const headers = new Headers(req.headers);
let resp: Response;
try {
resp = await params.appFetch(new Request(localUrl, {
method: req.method,
headers,
body: req.body === null ? undefined : req.body,
}));
resp = await params.appFetch(
new Request(localUrl, {
method: req.method,
headers,
body: req.body === null ? undefined : req.body,
}),
);
} catch (e) {
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
const errBody: WsResponse = {
@@ -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,21 +1,25 @@
import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const baseConfig = {
command: "/usr/local/bin/cursor-agent",
model: null as string | null,
timeout: 0,
workspace: null as string | null,
};
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
...baseConfig,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
...baseConfig,
command: "cursor-agent",
model: null,
timeout: 0,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
...baseConfig,
timeout: -1,
});
expect(r.ok).toBe(false);
});
test("rejects non-absolute workspace when set", () => {
const r = validateCursorAgentConfig({
...baseConfig,
workspace: "relative/path",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
});
describe("createCursorAgent", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
...baseConfig,
});
expect(typeof agent).toBe("function");
});
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
...baseConfig,
timeout: -1,
});
expect(typeof agent).toBe("function");
+40 -22
View File
@@ -1,8 +1,8 @@
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger, type LogFn } from "@uncaged/workflow-util";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -33,25 +33,15 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig) {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace = await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
type CursorAgentOpt = { prompt: string; workspace: string };
function createCursorAgentFn(
config: CursorAgentConfig,
modelFlag: string,
timeoutMs: number | null,
logger: LogFn,
): AgentFn<CursorAgentOpt> {
return async (ctx, { prompt, workspace }) => {
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
@@ -75,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
throwCursorSpawnError(run.error);
}
return run.value;
});
};
}
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createAgentAdapter(
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace =
config.workspace !== null
? config.workspace
: await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
return { prompt, workspace };
},
);
}
@@ -3,4 +3,9 @@ export type CursorAgentConfig = {
command: string;
model: string | null;
timeout: number;
/**
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
* from the thread via runtime extraction.
*/
workspace: string | null;
};
@@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
if (config.workspace !== null && !isAbsolute(config.workspace)) {
return err("workspace must be an absolute filesystem path when set");
}
return ok(undefined);
}
+5 -1
View File
@@ -6,5 +6,9 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
"references": [
{ "path": "../workflow-cas" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" }
]
}
+17 -10
View File
@@ -1,7 +1,7 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
throw new Error("hermes: unknown spawn error");
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return async (ctx, { prompt }) => {
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
});
}
@@ -1,5 +1,12 @@
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
import {
type AdapterFn,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt, _runtime) => {
type LlmAgentOpt = { prompt: string };
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
return async (ctx, { prompt }) => {
const result = await chatCompletionText({
provider,
messages: [
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
});
};
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
prompt,
}));
}
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"composite": true
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
+1
View File
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: Record<string, unknown>;
};
+7 -2
View File
@@ -12,7 +12,8 @@ import { useHashRoute } from "./use-hash-route.ts";
export function App() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } = useHashRoute();
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const [showRun, setShowRun] = useState(false);
if (!authed) {
@@ -51,7 +52,11 @@ export function App() {
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail client={client} workflowName={workflowName} onBack={() => setWorkflowName(null)} />
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
</div>
</main>
@@ -96,42 +96,45 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const handleGraphNodeClick = useCallback((nodeId: string) => {
// Only allow clicks on lit (non-default) nodes
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
const handleGraphNodeClick = useCallback(
(nodeId: string) => {
// Only allow clicks on lit (non-default) nodes
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
}, [nodeStates, indicesByRole]);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
},
[nodeStates, indicesByRole],
);
useEffect(() => {
return () => {
@@ -285,7 +288,11 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
</div>
);
}
return <div key={key} data-record-index={i}><RecordCard record={r} highlighted={false} /></div>;
return (
<div key={key} data-record-index={i}>
<RecordCard record={r} highlighted={false} />
</div>
);
})}
<div ref={recordsEndRef} aria-hidden />
</div>
@@ -76,7 +76,14 @@ function flattenSchema(
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
const subRows = flattenProperty(
pName,
pDef,
depth + 1,
childPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
);
rows.push(...subRows);
}
}
@@ -121,7 +128,14 @@ function flattenProperty(
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}-`));
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
if (prop.type === "array") {
@@ -134,7 +148,14 @@ function flattenProperty(
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`));
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
return rows;
@@ -142,13 +163,7 @@ function flattenProperty(
// ── Components ──────────────────────────────────────────────────────
function RoleCard({
roleName,
role,
}: {
roleName: string;
role: WorkflowRoleDescriptor;
}) {
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
return (
<div
@@ -156,10 +171,7 @@ function RoleCard({
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)" }}
>
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
{roleName}
</h4>
{role.description !== "" && (
@@ -167,6 +179,28 @@ function RoleCard({
{role.description}
</p>
)}
{role.systemPrompt !== "" && (
<details className="mb-3">
<summary
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
style={{ color: "var(--color-text-muted)" }}
>
System Prompt
</summary>
<pre
className="mt-1 text-xs p-2 rounded overflow-x-auto whitespace-pre-wrap break-words"
style={{
color: "var(--color-text)",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: "300px",
overflowY: "auto",
}}
>
{role.systemPrompt}
</pre>
</details>
)}
{rows.length > 0 && (
<div>
<p
@@ -178,9 +212,24 @@ function RoleCard({
<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>
<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>
@@ -200,8 +249,12 @@ function RoleCard({
>
{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>
<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>
@@ -274,12 +327,8 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
{status === "loading" && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{detail !== null && (
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
@@ -327,7 +376,10 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
<p
className="text-sm whitespace-pre-wrap mb-3"
style={{ color: "var(--color-text)" }}
>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
@@ -10,7 +10,13 @@ const FEEDBACK_RADIUS = 16;
* Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
* The path goes: source → arc → vertical up → arc → target
*/
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
function feedbackPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
side: "right" | "left",
): string {
const d = side === "right" ? 1 : -1;
const offsetX =
side === "right"
@@ -88,12 +94,7 @@ export function ConditionEdge(props: EdgeProps) {
return (
<>
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5 }}
/>
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5 }} />
{label !== "" && (
<EdgeLabelRenderer>
<div
@@ -45,11 +45,41 @@ export function RoleNode(props: NodeProps) {
}}
title={data.description}
>
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Left} id="left-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Right} id="right-in" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Left} id="left-out" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Right} id="right-out" style={handleStyle} isConnectable={false} />
<Handle
type="target"
position={Position.Top}
id="top-in"
style={handleStyle}
isConnectable={false}
/>
<Handle
type="target"
position={Position.Left}
id="left-in"
style={handleStyle}
isConnectable={false}
/>
<Handle
type="target"
position={Position.Right}
id="right-in"
style={handleStyle}
isConnectable={false}
/>
<Handle
type="source"
position={Position.Left}
id="left-out"
style={handleStyle}
isConnectable={false}
/>
<Handle
type="source"
position={Position.Right}
id="right-out"
style={handleStyle}
isConnectable={false}
/>
<div className="flex items-center gap-1.5 font-mono">
{icon !== null && (
<span
@@ -67,7 +97,13 @@ export function RoleNode(props: NodeProps) {
{data.description}
</div>
)}
<Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
<Handle
type="source"
position={Position.Bottom}
id="bottom-out"
style={handleStyle}
isConnectable={false}
/>
</div>
);
}
@@ -50,7 +50,13 @@ export function TerminalNode(props: NodeProps) {
isConnectable={false}
/>
) : (
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
<Handle
type="target"
position={Position.Top}
id="top-in"
style={handleStyle}
isConnectable={false}
/>
)}
{isStart ? "▶" : "■"}
</div>
@@ -216,7 +216,11 @@ function computeLayout(input: LayoutInput): LayoutResult {
id: edgeKey(e),
source: e.from,
target: e.to,
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
sourceHandle: isFeedback
? feedbackSide === "left"
? "left-out"
: "right-out"
: "bottom-out",
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
type: "condition",
data: {
@@ -28,7 +28,11 @@ export function WorkflowList({ client, onSelect }: Props) {
type="button"
onClick={() => onSelect(w.name)}
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)", color: "var(--color-text)" }}
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<div className="flex items-center gap-2">
<span className="font-medium">{w.name}</span>
@@ -40,10 +44,7 @@ export function WorkflowList({ client, onSelect }: Props) {
{w.hash !== null ? w.hash : "—"}
</code>
{w.timestamp !== null ? (
<span
className="text-xs mt-1 block"
style={{ color: "var(--color-text-muted)" }}
>
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
Updated {new Date(w.timestamp).toLocaleString()}
</span>
) : null}
@@ -80,17 +80,20 @@ export function useHashRoute(): {
);
const setClient = useCallback(
(a: string | null) => navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
(a: string | null) =>
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
(id: string | null) =>
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
[navigate, route.client],
);
const setWorkflowName = useCallback(
(name: string | null) => navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
(name: string | null) =>
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
[navigate, route.client],
);
+6 -1
View File
@@ -305,7 +305,12 @@ app.all("/api/clients/:client/*", async (c) => {
headers: forwardRecord,
body: bodyStr,
};
const proxyResp = await fetchThroughClientSocket(c.env, client, c.env.GATEWAY_SECRET, wsRequest);
const proxyResp = await fetchThroughClientSocket(
c.env,
client,
c.env.GATEWAY_SECRET,
wsRequest,
);
if (proxyResp.status !== 503) {
return new Response(proxyResp.body, {
status: proxyResp.status,
+1
View File
@@ -13,6 +13,7 @@ export type {
AdapterFn,
AdvanceOutcome,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
+10
View File
@@ -24,6 +24,7 @@ export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: WorkflowRoleSchema;
};
@@ -151,6 +152,15 @@ export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promis
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
/**
* Core agent function. Input is always {@link ThreadContext}, output is always string.
* `Opt` captures agent-specific structured options.
* Agents with no extra options use `AgentFn` (Opt defaults to void).
*/
export type AgentFn<Opt = void> = Opt extends void
? (ctx: ThreadContext) => Promise<string>
: (ctx: ThreadContext, options: Opt) => Promise<string>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
@@ -35,11 +35,12 @@ export function buildDescriptor<M extends RoleMeta>(
): WorkflowDescriptor {
const roles: WorkflowDescriptor["roles"] = {};
for (const [key, roleDef] of Object.entries(def.roles) as Array<
[string, { description: string; schema: z.ZodType }]
[string, { description: string; systemPrompt: string; schema: z.ZodType }]
>) {
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
roles[key] = {
description: roleDef.description,
systemPrompt: roleDef.systemPrompt,
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
@@ -88,8 +88,10 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
}
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
roles[roleName] = {
description: roleDesc,
systemPrompt,
schema: schema as WorkflowRoleSchema,
};
}
+1
View File
@@ -5,6 +5,7 @@ export type {
AdapterBinding,
AdapterFn,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
+1
View File
@@ -7,6 +7,7 @@ export type {
AdapterFn,
AdvanceOutcome,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
@@ -8,15 +8,6 @@ import { createWorkflow } from "@uncaged/workflow-runtime";
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
const llmProvider = {
baseUrl: optionalEnv(
"WORKFLOW_LLM_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
};
const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
@@ -24,7 +15,6 @@ const adapter = createCursorAgent({
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: 0,
workspace: null,
llmProvider,
});
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
@@ -0,0 +1,48 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
AgentFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type ExtractOptionsFn<Opt> = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<Opt>;
/**
* Bridges {@link AgentFn} to {@link AdapterFn}.
*
* 1. extract(ctx, prompt, runtime) → Opt
* 2. agent(ctx, options) → raw string
* 3. Store raw string in CAS
* 4. runtime.extract(schema, contentHash) → typed meta T
*/
export function createAgentAdapter<Opt>(
agent: AgentFn<Opt>,
extract: ExtractOptionsFn<Opt>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const options = await extract(ctx, prompt, runtime);
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
ctx,
options,
);
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread: null };
};
};
}
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
}
@@ -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 };
};
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
export { createTextAdapter } from "./create-text-adapter.js";
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
+1 -1
View File
@@ -10,7 +10,7 @@ while IFS= read -r match; do
file="${match%%:*}"
rest="${match#*:}"
line="${rest%%:*}"
tag=$(echo "$rest" | grep -oP '\.log\(\s*"\K[A-Za-z0-9]+')
tag=$(echo "$rest" | sed -n 's/.*\.log( *"\([A-Za-z0-9]*\)".*/\1/p')
if echo "$tag" | grep -qiE '[ILOU]'; then
echo "${file}:${line} tag \"${tag}\" contains invalid Crockford Base32 char (I/L/O/U)"
BAD=1