Compare commits

..

1 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
28 changed files with 184 additions and 313 deletions
+1 -2
View File
@@ -41,8 +41,7 @@ roles:
Before starting any work, ensure a clean worktree:
1. `git checkout main && git pull` to get the latest code
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
- If bounced back from reviewer or tester, reuse the existing branch and rebase onto latest main:
`git checkout main && git pull && git checkout <branch> && git rebase main`
- If bounced back from reviewer or tester, reuse the existing branch instead
Then implement TDD:
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
-9
View File
@@ -17,15 +17,6 @@
"indentWidth": 2,
"lineWidth": 100
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
+1 -1
View File
@@ -539,7 +539,7 @@ function collectOrderedSteps(
}
function formatYaml(value: unknown): string {
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
return stringify(value).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
+1 -1
View File
@@ -7,6 +7,6 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
case "json":
return JSON.stringify(data);
case "yaml":
return stringify(data, { aliasDuplicateObjects: false }).trimEnd();
return stringify(data).trimEnd();
}
}
+13 -45
View File
@@ -7,44 +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 } from "./session.js";
import type { BuiltinSessionState } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
const sessions = new Map<string, BuiltinSessionState>();
const FRONTMATTER_FENCE = "---";
/**
* Strip any text before the first `---` fence.
* LLMs sometimes emit preamble text before the frontmatter block.
*/
function stripPreamble(text: string): string {
if (text.startsWith(FRONTMATTER_FENCE)) {
return text;
}
const idx = text.indexOf(`\n${FRONTMATTER_FENCE}\n`);
if (idx !== -1) {
log("6GWRP3QX", `stripped ${idx + 1} chars of preamble before frontmatter`);
return text.slice(idx + 1);
}
return text;
}
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}`);
@@ -63,7 +36,7 @@ async function runBuiltinWithMessages(
storageRoot: string,
provider: ReturnType<typeof resolveModel>,
messages: ChatMessage[],
session: SessionRecord,
session: BuiltinSessionState,
store: Store,
maxTurns: number,
): Promise<AgentRunResult> {
@@ -72,27 +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");
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,
);
return { output: stripPreamble(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> {
@@ -101,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 };
}
@@ -59,18 +59,6 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
}
systemParts.push(rolePrompt);
systemParts.push(
"",
"## Workflow",
"",
"You have tools available (read_file, write_file, run_command). " +
"Use them to complete your task — read files, run commands, make changes as needed. " +
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
"Do NOT output the frontmatter until you have completed all necessary work. " +
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
);
const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
const roleVisitIndices: number[] = [];
@@ -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;
@@ -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">