Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoju c0cefc48fb feat(cli-workflow): implement multi-strategy workflow resolution for issue #428
- Add 4-strategy resolution priority: CAS hash → file path → local discovery → global registry
- Add helper functions: isFilePath, workflowFileExists, findWorkflowInDir, findWorkflowInParents
- Refactor resolveWorkflowCasRef to support direct hash, explicit paths, and parent traversal
- Add comprehensive test suite with 24 tests covering all strategies and edge cases
- Support .workflow/ and .workflows/ directories with .yaml/.yml extensions
- All 60 tests pass across 5 test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:05:22 +00:00
xiaoju 211f38bc8d fix(claude-code): include edge prompt in agent prompt as Current Instruction
buildClaudeCodePrompt was dropping ctx.edgePrompt entirely — the graph
transition instruction (e.g. 'Implement the plan') never reached the agent.
Now appended as '## Current Instruction' at the end of the prompt.
2026-05-23 09:46:17 +00:00
28 changed files with 730 additions and 294 deletions
-9
View File
@@ -17,15 +17,6 @@
"indentWidth": 2,
"lineWidth": 100
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
@@ -0,0 +1,367 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
import { cmdThreadStart } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
const payload: WorkflowPayload = {
name,
description: "Test workflow",
roles: {},
conditions: {},
graph: {},
};
return await uwf.store.put(uwf.schemas.workflow, payload);
}
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
const payload: WorkflowPayload = {
name,
description: version !== null ? `Test workflow (${version})` : "Test workflow",
roles: {},
conditions: {},
graph: {},
};
const yaml = stringify(payload);
return yaml;
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let storageRoot: string;
let projectRoot: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wf-resolve-test-"));
storageRoot = join(tmpDir, "storage");
projectRoot = join(tmpDir, "project");
await mkdir(storageRoot, { recursive: true });
await mkdir(projectRoot, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── Strategy 1: CAS Hash Resolution ───────────────────────────────────────────
describe("Strategy 1: CAS Hash Resolution", () => {
test("should resolve valid 13-char Crockford Base32 hash", async () => {
const uwf = await makeUwfStore(storageRoot);
const hash = await storeWorkflow(uwf, "test-workflow");
const result = await cmdThreadStart(storageRoot, hash, "test prompt", projectRoot);
expect(result.workflow).toBe(hash);
expect(result.thread).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
test("should fail on invalid hash format (non-Crockford characters)", async () => {
await makeUwfStore(storageRoot);
await expect(
cmdThreadStart(storageRoot, "123456789ABCD", "prompt", projectRoot),
).rejects.toThrow();
});
test("should fail on valid-format hash not present in CAS", async () => {
await makeUwfStore(storageRoot);
const fakeHash = "0000000000000"; // valid format, doesn't exist
await expect(cmdThreadStart(storageRoot, fakeHash, "prompt", projectRoot)).rejects.toThrow();
});
test("should reject 40-char hex hash (legacy format not supported)", async () => {
await makeUwfStore(storageRoot);
const hexHash = "a".repeat(40);
await expect(cmdThreadStart(storageRoot, hexHash, "prompt", projectRoot)).rejects.toThrow();
});
});
// ── Strategy 2: File Path Resolution ──────────────────────────────────────────
describe("Strategy 2: File Path Resolution", () => {
test("should load workflow from absolute file path", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "test-workflow.yaml");
await writeFile(yamlPath, await createWorkflowYaml("test-workflow"));
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("test-workflow");
}
});
test("should load workflow from relative file path", async () => {
await makeUwfStore(storageRoot);
const yamlPath = "test-workflow.yaml";
await writeFile(join(projectRoot, yamlPath), await createWorkflowYaml("test-workflow"));
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should fail when file path does not exist", async () => {
await makeUwfStore(storageRoot);
await expect(
cmdThreadStart(storageRoot, "./nonexistent.yaml", "prompt", projectRoot),
).rejects.toThrow();
});
test("should fail on invalid YAML syntax in file", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "bad-syntax.yaml");
await writeFile(yamlPath, "invalid: yaml: : :");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
test("should enforce filename matches workflow name", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "solve-issue.yaml");
await writeFile(yamlPath, await createWorkflowYaml("wrong-name"));
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
});
// ── Strategy 3: Local Discovery (Parent Traversal) ────────────────────────────
describe("Strategy 3: Local Discovery", () => {
test("should find workflow in current directory .workflow/", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
}
});
test("should find workflow in parent directory .workflow/", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const subdir = join(projectRoot, "packages", "cli-workflow", "src");
await mkdir(subdir, { recursive: true });
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", subdir);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should stop at filesystem root when traversing", async () => {
await makeUwfStore(storageRoot);
const deepPath = join(tmpDir, "deep", "path", "that", "does", "not", "have", "workflow");
await mkdir(deepPath, { recursive: true });
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", deepPath)).rejects.toThrow();
});
test("should prefer .workflow/ over .workflows/ directory", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
const workflowsDir = join(projectRoot, ".workflows");
await mkdir(workflowDir, { recursive: true });
await mkdir(workflowsDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "1"),
);
await writeFile(
join(workflowsDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "2"),
);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (1)");
}
});
test("should support .yml extension in local discovery", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
describe("Strategy 4: Global Registry Resolution", () => {
test("should resolve workflow from global registry when not found locally", async () => {
const uwf = await makeUwfStore(storageRoot);
const hash = await storeWorkflow(uwf, "deploy-pipeline");
const registry = await loadWorkflowRegistry(storageRoot);
registry["deploy-pipeline"] = hash;
await saveWorkflowRegistry(storageRoot, registry);
const isolatedRoot = join(tmpDir, "isolated");
await mkdir(isolatedRoot, { recursive: true });
const result = await cmdThreadStart(storageRoot, "deploy-pipeline", "prompt", isolatedRoot);
expect(result.workflow).toBe(hash);
});
test("should fail when workflow not found in any strategy", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", tmpDir)).rejects.toThrow();
});
});
// ── Strategy Priority Order ───────────────────────────────────────────────────
describe("Resolution Priority", () => {
test("should use explicit file path over local discovery", async () => {
await makeUwfStore(storageRoot);
// Setup: Create workflow in .workflow/ AND as explicit file
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "discovery"),
);
const explicitPath = join(projectRoot, "custom-solve-issue.yaml");
await writeFile(explicitPath, await createWorkflowYaml("custom-solve-issue", "explicit"));
// Execute with explicit path
const result = await cmdThreadStart(storageRoot, explicitPath, "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (explicit)");
}
});
test("should use local discovery over global registry", async () => {
const uwf = await makeUwfStore(storageRoot);
// Setup: Register globally
const globalHash = await storeWorkflow(uwf, "solve-issue");
const registry = await loadWorkflowRegistry(storageRoot);
registry["solve-issue"] = globalHash;
await saveWorkflowRegistry(storageRoot, registry);
// Setup: Create local .workflow/
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
const localYaml = await createWorkflowYaml("solve-issue", "local");
await writeFile(join(workflowDir, "solve-issue.yaml"), localYaml);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf2 = await makeUwfStore(storageRoot);
const node = uwf2.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)");
}
});
});
// ── Edge Cases ────────────────────────────────────────────────────────────────
describe("Edge Cases", () => {
test("should treat '13-char-string.yaml' as file path, not CAS hash", async () => {
await makeUwfStore(storageRoot);
const fileName = "0123456789ABC.yaml"; // 13 chars + .yaml
await writeFile(join(projectRoot, fileName), await createWorkflowYaml("0123456789ABC"));
const result = await cmdThreadStart(storageRoot, fileName, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should handle workflow names containing slashes as file paths", async () => {
await makeUwfStore(storageRoot);
const filePath = "subdir/solve-issue.yaml";
const fullPath = join(projectRoot, filePath);
await mkdir(join(projectRoot, "subdir"), { recursive: true });
await writeFile(fullPath, await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, filePath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should handle absolute paths correctly", async () => {
await makeUwfStore(storageRoot);
const absPath = join(tmpDir, "abs-workflow.yaml");
await writeFile(absPath, await createWorkflowYaml("abs-workflow"));
const result = await cmdThreadStart(storageRoot, absPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should fail on empty workflow ID", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, "", "prompt", projectRoot)).rejects.toThrow();
});
test("should fail on whitespace-only workflow ID", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, " ", "prompt", projectRoot)).rejects.toThrow();
});
});
@@ -137,6 +137,75 @@ function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Discover uwf-* agent binaries in PATH.
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
*/
async function _discoverAgents(): Promise<string[]> {
try {
// Use which -a to find all uwf-* binaries in PATH
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
stdout: "pipe",
stderr: "pipe",
});
const text = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) {
// Try alternative approach: search PATH directories manually
const pathEnv = process.env.PATH || "";
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
const agents = new Set<string>();
for (const dir of pathDirs) {
try {
if (!existsSync(dir)) continue;
const { readdirSync, statSync } = await import("node:fs");
const entries = readdirSync(dir);
for (const entry of entries) {
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
// Check if executable (owner, group, or other has execute bit)
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
agents.add(entry);
}
} catch {
// Skip if can't stat
}
}
} catch {
// Skip inaccessible directories
}
}
return Array.from(agents).sort();
}
// Parse which output - each line is a path to a binary
const paths = text
.trim()
.split("\n")
.filter((line) => line.length > 0);
const agents = new Set<string>();
for (const path of paths) {
const basename = path.split("/").pop();
if (basename?.startsWith("uwf-") && basename !== "uwf") {
agents.add(basename);
}
}
return Array.from(agents).sort();
} catch {
// If all fails, return empty array
return [];
}
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
+110 -11
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
@@ -30,12 +31,10 @@ import { parse, stringify } from "yaml";
import {
appendThreadHistory,
createUwfStore,
discoverProjectWorkflows,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveProjectWorkflowFile,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
@@ -82,6 +81,83 @@ function fail(message: string): never {
process.exit(1);
}
/**
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
*/
function isFilePath(input: string): boolean {
return (
input.includes("/") || input.includes("\\") || input.endsWith(".yaml") || input.endsWith(".yml")
);
}
/**
* Check if a workflow file exists at the given path.
*/
async function workflowFileExists(dir: string, name: string, ext: string): Promise<string | null> {
const candidate = resolvePath(dir, `${name}${ext}`);
try {
await access(candidate);
return candidate;
} catch {
return null;
}
}
/**
* Search for a workflow file in a given directory (checks both .workflow/ and .workflows/).
*/
async function findWorkflowInDir(dir: string, name: string): Promise<string | null> {
// Check .workflow/ directory first (preferred)
for (const ext of [".yaml", ".yml"]) {
const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
if (result !== null) {
return result;
}
}
// Check .workflows/ directory as fallback (legacy)
for (const ext of [".yaml", ".yml"]) {
const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
if (result !== null) {
return result;
}
}
return null;
}
/**
* Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
* Returns the absolute path if found, otherwise null.
* Stops at filesystem root or .git directory.
*/
async function findWorkflowInParents(startDir: string, name: string): Promise<string | null> {
let currentDir = resolvePath(startDir);
const root = resolvePath("/");
while (true) {
const found = await findWorkflowInDir(currentDir, name);
if (found !== null) {
return found;
}
// Stop at filesystem root
if (currentDir === root) {
break;
}
// Move to parent directory
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root
break;
}
currentDir = parentDir;
}
return null;
}
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
let text: string;
try {
@@ -123,18 +199,41 @@ async function resolveWorkflowCasRef(
workflowId: string,
projectRoot: string,
): Promise<CasRef> {
// Project-local resolution: check .workflows/<workflowId>.yaml first
const localEntries = await discoverProjectWorkflows(projectRoot);
const localFile = resolveProjectWorkflowFile(localEntries, workflowId);
if (localFile !== null) {
return materializeLocalWorkflow(uwf, localFile);
// Validate input
const trimmed = workflowId.trim();
if (trimmed === "") {
fail("workflow ID cannot be empty");
}
// Global registry fallback
// Strategy 1: Direct CAS hash
if (isCasRef(trimmed)) {
const node = uwf.store.get(trimmed);
if (node === null) {
fail(`CAS node not found: ${trimmed}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${trimmed} is not a Workflow (type ${node.type})`);
}
return trimmed;
}
// Strategy 2: Explicit file path (relative or absolute)
if (isFilePath(trimmed)) {
const absolutePath = isAbsolute(trimmed) ? trimmed : resolvePath(projectRoot, trimmed);
return materializeLocalWorkflow(uwf, absolutePath);
}
// Strategy 3: Local discovery (parent directory traversal)
const localPath = await findWorkflowInParents(projectRoot, trimmed);
if (localPath !== null) {
return materializeLocalWorkflow(uwf, localPath);
}
// Strategy 4: Global registry fallback
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, workflowId);
const hash = resolveWorkflowHash(registry, trimmed);
if (!isCasRef(hash)) {
fail(`workflow not found: ${workflowId}`);
fail(`workflow not found: ${trimmed}`);
}
const node = uwf.store.get(hash);
if (node === null) {
+13 -31
View File
@@ -7,26 +7,17 @@ import {
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-agent-kit";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import { generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
import { buildBuiltinMessages } from "./prompt.js";
import { initSessionDir, removeSession } from "./session.js";
import type { BuiltinSessionState } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
const sessions = new Map<string, BuiltinSessionState>();
type SessionRecord = {
sessionId: string;
model: string;
startedAtMs: number;
messages: ChatMessage[];
};
const sessions = new Map<string, SessionRecord>();
function getSession(sessionId: string): SessionRecord {
function getSession(sessionId: string): BuiltinSessionState {
const session = sessions.get(sessionId);
if (session === undefined) {
throw new Error(`builtin session not found: ${sessionId}`);
@@ -45,7 +36,7 @@ async function runBuiltinWithMessages(
storageRoot: string,
provider: ReturnType<typeof resolveModel>,
messages: ChatMessage[],
session: SessionRecord,
session: BuiltinSessionState,
store: Store,
maxTurns: number,
): Promise<AgentRunResult> {
@@ -54,31 +45,22 @@ async function runBuiltinWithMessages(
messages,
toolCtx: buildToolContext(storageRoot),
maxTurns,
storageRoot,
sessionId: session.sessionId,
existingTurns: session.turns,
});
session.messages = loopResult.messages;
session.turns = loopResult.turns;
if (loopResult.turnCount === 0) {
log("5RWTK9NB", "no turns produced, returning empty output");
await removeSession(storageRoot, session.sessionId);
return { output: "", detailHash: "", sessionId: session.sessionId };
}
// Read jsonl → persist turns to CAS → store detail
const { detailHash } = await storeBuiltinDetail(
const { detailHash, output } = await storeBuiltinDetail(
store,
storageRoot,
session.sessionId,
session.model,
session.startedAtMs,
session.turns,
);
// Clean up session jsonl
await removeSession(storageRoot, session.sessionId);
return { output: loopResult.finalText, detailHash, sessionId: session.sessionId };
const finalOutput = output !== "" ? output : loopResult.finalText;
return { output: finalOutput, detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -87,14 +69,14 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
const provider = resolveModel(config, config.defaultModel);
const sessionId = generateUlid(Date.now());
await initSessionDir(storageRoot);
const messages = buildBuiltinMessages(ctx);
const session: SessionRecord = {
const session: BuiltinSessionState = {
sessionId,
model: provider.model,
startedAtMs: Date.now(),
messages,
turns: [],
};
sessions.set(sessionId, session);
+78 -12
View File
@@ -1,15 +1,72 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
import { readSessionTurns } from "./session.js";
import type { BuiltinDetailPayload } from "./types.js";
import type {
BuiltinDetailPayload,
BuiltinLoopTurn,
BuiltinToolCall,
BuiltinTurnPayload,
BuiltinTurnRole,
} from "./types.js";
function mapToolCalls(calls: NonNullable<BuiltinLoopTurn["toolCalls"]>): BuiltinToolCall[] {
return calls.map((call) => ({
name: call.name,
args: call.args,
}));
}
function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload {
return {
index,
role: "assistant",
content: turn.assistantContent ?? "",
toolCalls:
turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null,
reasoning: null,
};
}
function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] {
if (turn.toolResults === null || turn.toolResults.length === 0) {
return [];
}
const payloads: BuiltinTurnPayload[] = [];
let index = startIndex;
for (const result of turn.toolResults) {
payloads.push({
index,
role: "tool" as BuiltinTurnRole,
content: result.content,
toolCalls: null,
reasoning: null,
});
index += 1;
}
return payloads;
}
/** Last assistant message with non-empty text. */
export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string {
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i];
if (turn === undefined) {
continue;
}
const text = turn.assistantContent;
if (text !== null && text.trim() !== "") {
return text;
}
}
return "";
}
type BuiltinSchemaHashes = {
turn: string;
detail: string;
};
export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, BUILTIN_TURN_SCHEMA),
@@ -18,22 +75,30 @@ export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchem
return { turn, detail };
}
/** Read session jsonl, persist each turn to CAS, return detail hash. */
export async function storeBuiltinDetail(
store: Store,
storageRoot: string,
sessionId: string,
model: string,
startedAtMs: number,
turns: BuiltinLoopTurn[],
nowMs: number = Date.now(),
): Promise<{ detailHash: string; turnCount: number }> {
): Promise<{ detailHash: string; output: string }> {
const schemas = await registerBuiltinSchemas(store);
const turns = await readSessionTurns(storageRoot, sessionId);
const turnHashes: string[] = [];
for (const turn of turns) {
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
let turnIndex = 0;
for (const loopTurn of turns) {
const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex);
const assistantHash = await store.put(schemas.turn, assistant);
turnHashes.push(assistantHash);
turnIndex += 1;
const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex);
for (const toolPayload of toolPayloads) {
const toolHash = await store.put(schemas.turn, toolPayload);
turnHashes.push(toolHash);
turnIndex += 1;
}
}
const duration = Math.max(0, nowMs - startedAtMs);
@@ -45,5 +110,6 @@ export async function storeBuiltinDetail(
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
return { detailHash, turnCount: turnHashes.length };
const output = extractFinalAssistantText(turns);
return { detailHash, output };
}
+2 -4
View File
@@ -1,16 +1,14 @@
export { createBuiltinAgent } from "./agent.js";
export { registerBuiltinSchemas, storeBuiltinDetail } from "./detail.js";
export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
export { chatCompletionWithTools } from "./llm/index.js";
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
export { buildBuiltinMessages } from "./prompt.js";
export { appendSessionTurn, initSessionDir, readSessionTurns, removeSession } from "./session.js";
export type { BuiltinTool, ToolContext } from "./tools/index.js";
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
export type {
BuiltinDetailPayload,
BuiltinLoopTurn,
BuiltinToolCallRecord,
BuiltinToolResultRecord,
BuiltinSessionState,
BuiltinTurnPayload,
} from "./types.js";
+31 -59
View File
@@ -2,14 +2,13 @@ import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
import { appendSessionTurn } from "./session.js";
import {
builtinToolsToOpenAi,
executeBuiltinTool,
getBuiltinTools,
type ToolContext,
} from "./tools/index.js";
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
import type { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
@@ -21,61 +20,31 @@ export type RunBuiltinLoopOptions = {
messages: ChatMessage[];
toolCtx: ToolContext;
maxTurns: number;
storageRoot: string;
sessionId: string;
existingTurns: BuiltinLoopTurn[];
};
export type RunBuiltinLoopResult = {
finalText: string;
messages: ChatMessage[];
turnCount: number;
turns: BuiltinLoopTurn[];
};
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] {
return calls.map((call) => ({
id: call.id,
name: call.name,
args: call.arguments,
}));
}
async function appendTurn(
storageRoot: string,
sessionId: string,
payload: BuiltinTurnPayload,
): Promise<void> {
await appendSessionTurn(storageRoot, sessionId, payload);
}
async function executeTurnTools(
calls: Array<{ id: string; name: string; arguments: string }>,
toolCtx: ToolContext,
messages: ChatMessage[],
storageRoot: string,
sessionId: string,
): Promise<number> {
let turnCount = 0;
for (const call of calls) {
const result = await executeBuiltinTool(call.name, call.arguments, toolCtx);
messages.push({ role: "tool", tool_call_id: call.id, content: result });
await appendTurn(storageRoot, sessionId, {
role: "tool",
content: result,
toolCalls: null,
reasoning: null,
});
turnCount += 1;
}
return turnCount;
}
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
export async function runBuiltinLoop(
options: RunBuiltinLoopOptions,
): Promise<RunBuiltinLoopResult> {
const messages = [...options.messages];
const turns = [...options.existingTurns];
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
let finalText = "";
let turnCount = 0;
for (let turn = 0; turn < options.maxTurns; turn++) {
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
@@ -90,33 +59,36 @@ export async function runBuiltinLoop(
if (response.toolCalls === null || response.toolCalls.length === 0) {
finalText = response.content ?? "";
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
turns.push({
assistantContent: response.content,
toolCalls: null,
reasoning: null,
toolResults: null,
});
turnCount += 1;
break;
}
// Assistant turn with tool calls
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
toolCalls: mapToolCallsForPayload(response.toolCalls),
reasoning: null,
});
turnCount += 1;
const toolCallRecords = mapToolCalls(response.toolCalls);
const toolResults: BuiltinToolResultRecord[] = [];
// Execute tools
turnCount += await executeTurnTools(
response.toolCalls,
options.toolCtx,
messages,
options.storageRoot,
options.sessionId,
);
for (const call of response.toolCalls) {
const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx);
toolResults.push({
toolCallId: call.id,
name: call.name,
content: result,
});
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
turns.push({
assistantContent: response.content,
toolCalls: toolCallRecords,
toolResults,
});
}
if (finalText === "" && messages.length > 0) {
@@ -134,5 +106,5 @@ export async function runBuiltinLoop(
}
}
return { finalText, messages, turnCount };
return { finalText, messages, turns };
}
@@ -13,8 +13,9 @@ const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
title: "builtin-turn",
type: "object",
required: ["role", "content"],
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
@@ -1,59 +0,0 @@
import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createLogger } from "@uncaged/workflow-util";
import type { BuiltinTurnPayload } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
function sessionsDir(storageRoot: string): string {
return join(storageRoot, "sessions");
}
function sessionFile(storageRoot: string, sessionId: string): string {
return join(sessionsDir(storageRoot), `${sessionId}.jsonl`);
}
/** Ensure sessions directory exists. */
export async function initSessionDir(storageRoot: string): Promise<void> {
await mkdir(sessionsDir(storageRoot), { recursive: true });
}
/** Append a turn to the session jsonl file. */
export async function appendSessionTurn(
storageRoot: string,
sessionId: string,
turn: BuiltinTurnPayload,
): Promise<void> {
const line = `${JSON.stringify(turn)}\n`;
await appendFile(sessionFile(storageRoot, sessionId), line, "utf-8");
log("3XQVN8KR", `session ${sessionId} appended ${turn.role} turn`);
}
/** Read all turns from session jsonl. Returns empty array if file does not exist. */
export async function readSessionTurns(
storageRoot: string,
sessionId: string,
): Promise<BuiltinTurnPayload[]> {
try {
const content = await readFile(sessionFile(storageRoot, sessionId), "utf-8");
const lines = content
.trim()
.split("\n")
.filter((l) => l.length > 0);
return lines.map((l) => JSON.parse(l) as BuiltinTurnPayload);
} catch {
return [];
}
}
/** Remove session jsonl file (called after detail is persisted to step CAS). */
export async function removeSession(storageRoot: string, sessionId: string): Promise<void> {
try {
await rm(sessionFile(storageRoot, sessionId));
log("7FWDP2MJ", `session ${sessionId} removed`);
} catch {
// already gone — fine
}
}
@@ -34,6 +34,7 @@ export type BuiltinToolCall = {
};
export type BuiltinTurnPayload = {
index: number;
role: BuiltinTurnRole;
content: string;
toolCalls: BuiltinToolCall[] | null;
@@ -49,6 +49,7 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
if (historyBlock !== "") {
parts.push("", historyBlock);
}
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
return parts.join("\n");
}
@@ -132,6 +133,8 @@ async function processClaudeOutput(stdout: string, store: Store): Promise<AgentR
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildClaudeCodePrompt(ctx);
log("K7R2M4N8", `prompt for role=${ctx.role} (length=${fullPrompt.length}):\n${fullPrompt}`);
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
if (!ctx.isFirstVisit) {
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
@@ -54,8 +54,7 @@ describe("HermesAcpClient", () => {
{ timeout: 2 * 60 * 1000 },
);
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
it(
"prompt() collects structured messages including tool calls",
async () => {
await client.connect(process.cwd());
@@ -21,8 +21,7 @@ describe("HermesAcpClient cross-process resume", () => {
clients.length = 0;
});
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
it(
"resume() after close — second prompt returns non-empty text",
async () => {
// --- Client A: first run ---
@@ -63,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
let condName: string | null = null;
if (t.condition) {
if (expressionToName.has(t.condition)) {
condName = expressionToName.get(t.condition) ?? null;
condName = expressionToName.get(t.condition)!;
} else {
condName = `cond${condIdx++}`;
expressionToName.set(t.condition, condName);
@@ -4,7 +4,6 @@ import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
<label
data-slot="label"
className={cn(
@@ -15,7 +15,6 @@ interface State<T, A> {
readonly onlyView: boolean;
}
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
type UseV = <T>(sub: SubModel<T, any>) => T;
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
@@ -28,9 +27,7 @@ export function generate<T>(val: T) {
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
if (Object.is(val, next)) return;
val = next;
for (const call of listener) {
call();
}
listener.forEach((call) => call());
}
const listen = (call: VoidFunction) => {
listener.add(call);
@@ -41,26 +38,21 @@ export function generate<T>(val: T) {
}
class SubModel<T, A> {
public readonly name: string;
private readonly make: () => T;
private readonly create: Create<T, A>;
private readonly onlyView: boolean;
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
this.name = name;
this.make = _make;
this.create = _create;
this.onlyView = _onlyView;
}
constructor(
public readonly name: string,
_make: () => T,
_create: Create<T, A>,
_onlyView = false,
) {}
public gen(model: Model): State<T, A> {
const { get, set, use, listen } = generate(this.make());
const actions = this.create(set, get, model);
return { get, set, use, listen, actions, onlyView: this.onlyView };
const { make, create, onlyView } = this;
const { get, set, use, listen } = generate(make());
const actions = create(set, get, model);
return { get, set, use, listen, actions, onlyView };
}
use(): [T, A] {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { query } = useContext(Context);
const { use, actions } = query(this);
return [use(), actions];
@@ -75,27 +67,20 @@ class SubModel<T, A> {
}
}
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
type Snapshot = [name: string, data: any];
class Model {
private ustack: Snapshot[][] = [];
private rstack: Snapshot[][] = [];
private transaction = 0;
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
private backup = new Map<string, any>();
public flow = {} as ReactFlowInstance<AnyWorkNode>;
private stackListeners = new Set<() => void>();
public readonly stackState: readonly [boolean, boolean] = [false, false];
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
private readonly store: Map<string, State<any, any>>;
public readonly use: Use;
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
constructor(store: Map<string, State<any, any>>, use: Use) {
this.store = store;
this.use = use;
}
constructor(
private readonly store: Map<string, State<any, any>>,
public readonly use: Use,
) {}
public reset() {
this.ustack = [];
@@ -113,9 +98,7 @@ class Model {
private triggerStackState() {
// @ts-expect-error
this.stackState = [this.canUndo(), this.canRedo()];
for (const call of this.stackListeners) {
call();
}
this.stackListeners.forEach((call) => call());
}
private getStackState = () => this.stackState;
@@ -125,11 +108,10 @@ class Model {
}
public log() {
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
const snapshots: Record<string, any> = {};
for (const [name, state] of this.store) {
this.store.forEach((state, name) => {
snapshots[name] = state.get();
}
});
}
public undo() {
@@ -137,13 +119,11 @@ class Model {
const item = ustack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
item.forEach(([name, data]) => {
const { get, set } = store.get(name)!;
step.push([name, get()]);
set(data);
}
});
rstack.push(step);
this.triggerStackState();
}
@@ -153,13 +133,11 @@ class Model {
const item = rstack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
item.forEach(([name, data]) => {
const { get, set } = store.get(name)!;
step.push([name, get()]);
set(data);
}
});
ustack.push(step);
this.triggerStackState();
}
@@ -175,10 +153,10 @@ class Model {
public startTransaction() {
if (this.transaction === 0) {
this.backup.clear();
for (const [name, state] of this.store) {
if (state.onlyView) continue;
this.store.forEach((state, name) => {
if (state.onlyView) return;
this.backup.set(name, state.get());
}
});
}
this.transaction += 1;
return this.endTransaction;
@@ -189,12 +167,12 @@ class Model {
this.transaction -= 1;
if (this.transaction === 0) {
const changes: Snapshot[] = [];
for (const [name, state] of this.store) {
if (state.onlyView) continue;
this.store.forEach((state, name) => {
if (state.onlyView) return;
const before = this.backup.get(name);
if (Object.is(before, state.get())) continue;
if (Object.is(before, state.get())) return;
changes.push([name, before]);
}
});
this.backup.clear();
if (changes.length === 0) return;
this.ustack.push(changes);
@@ -205,10 +183,8 @@ class Model {
}
function build() {
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
const store = new Map<string, State<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
const mem: Record<string, any> = {};
function use<T, A>(m: SubModel<T, A>): [T, A] {
const state = query(m);
@@ -255,16 +231,10 @@ function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
return new SubModel<T, A>(name, make, create);
}
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
const defaultCreate: Create<any, Setter<any>> = (set) => set;
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
function defineView<T>(
name: string,
make: () => T,
create?: Create<T, unknown>,
): SubModel<T, unknown> {
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
function defineView<T>(name: string, make: () => T, create?: any): any {
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
}
@@ -272,12 +242,9 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, model, use } = useContext(Context);
if (!mem[id]) {
mem[id] = init(use, model);
}
return mem[id] as T;
const fn = mem[id] || (mem[id] = init(use, model));
return fn as T;
},
};
}
@@ -286,25 +253,17 @@ function compute<T>(calc: (use: UseV) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, query } = useContext(Context);
let state: ReturnType<typeof generate<T>> = mem[id];
if (state) return state.use();
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
const deps = new Set<SubModel<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
let usev = (m: SubModel<any, any>) => {
deps.add(m);
return query(m).get();
};
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
mem[id] = state = generate<T>(calc(usev));
if (deps.size) {
usev = (m) => query(m).get();
const update = () => state.set(calc(usev));
for (const m of deps) {
query(m).listen(update);
}
deps.forEach((m) => query(m).listen(update));
}
return state.use();
},
@@ -137,7 +137,6 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
<span
className={cn(
@@ -25,7 +25,6 @@ function Flow() {
const readonly = useReadonly();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
<ReactFlowProvider>
<ReactFlow<AnyWorkNode, Edge>
@@ -12,13 +12,11 @@ interface PrivateEvents {
export const InternalField = Symbol("InternalField");
export class Injection extends Eventer<PrivateEvents> {
public readonly emitPublic: Eventer<PublicEvents>["emit"];
private inital_steps: WorkFlowSteps | undefined;
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
constructor(
public readonly emitPublic: Eventer<PublicEvents>["emit"],
private inital_steps?: WorkFlowSteps,
) {
super();
this.emitPublic = emitPublic;
this.inital_steps = inital_steps;
}
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
@@ -60,8 +60,8 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
// 2. BFS 分层(排除 end 节点,稍后单独处理)
while (queue.length > 0) {
const current = queue.shift() ?? "";
const currentLayer = layers.get(current) ?? 0;
const current = queue.shift()!;
const currentLayer = layers.get(current)!;
for (const target of outgoing.get(current) ?? []) {
// 跳过 end 节点,稍后处理
@@ -31,7 +31,6 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
model.startTransaction();
editNode(state.node.id, (node) => {
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
node.data = data as any;
});
requestAnimationFrame(model.endTransaction);
@@ -23,7 +23,6 @@ export const handlers = define.memoize((use, model) => {
if (!to || !fromHandle || !fromNode) return;
const { clientX, clientY } = event as MouseEvent;
use(addNodeViewModel)[1].start({
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
fromNode: fromNode as any as AnyWorkNode,
fromHandle: fromHandle,
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
@@ -67,7 +67,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
});
}
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
const firstStepId = nameToId.get(steps[0].role.name)!;
edges.push({
id: `e-start-${firstStepId}`,
source: "start",
@@ -78,8 +78,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
});
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
const sourceId = nameToId.get(step.role.name)!;
const _sourceOrder = idToOrder.get(sourceId)!;
const hasMultipleTransitions = step.transitions.length > 1;
const sorted = hasMultipleTransitions
@@ -169,7 +169,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
const queue = [startId];
visited.add(startId);
while (queue.length > 0) {
const current = queue.shift() ?? "";
const current = queue.shift()!;
for (const next of adj.get(current) ?? []) {
if (!visited.has(next)) {
visited.add(next);
@@ -1,11 +1,9 @@
type Maper<T> = {
interface Maper<T> {
[key: string]: T;
};
}
type Listen<T> = (data: T) => void;
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
export class Eventer<M extends Maper<any>> {
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
private lisenters = {} as { [K in keyof M]: Set<Function> };
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
@@ -30,8 +28,6 @@ export class Eventer<M extends Maper<any>> {
const set = this.lisenters[key];
if (set === undefined) return;
// Todo: maybe implement stoping bubble
for (const call of set) {
call(data);
}
set.forEach((call) => call(data));
}
}
+1 -1
View File
@@ -33,7 +33,7 @@
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--font-heading: var(--font-sans);
--font-sans: "Geist Variable", sans-serif;
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -55,7 +55,7 @@ export function DetailPage(): ReactNode {
);
}
const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
const basePath = `/workflow/${encodeURIComponent(name!)}`;
return (
<div className="flex h-full flex-col">