Merge pull request 'feat(cli-workflow): implement multi-strategy workflow resolution' (#438) from fix/428-multi-strategy-workflow-resolution into main
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user