Ctrl K
ComunidadGuías…

scripts/build-workflows.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");

const ROOT_DIR = path.resolve(__dirname, "..");
const WORKSPACE_DIR = path.resolve(ROOT_DIR, "..");
const WORKFLOWS_DIR = process.env.MIKE_WORKFLOWS_DIR
  ? path.resolve(process.env.MIKE_WORKFLOWS_DIR)
  : path.join(WORKSPACE_DIR, "mike-workflows");
const WORKFLOW_COLLECTIONS = [
  { directory: "assistant-workflows", type: "assistant" },
  { directory: "tabular-review-workflows", type: "tabular" },
];
const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts");
const WORKFLOW_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function readText(filePath) {
  return fs.readFileSync(filePath, "utf8");
}

function readJson(filePath) {
  try {
    return JSON.parse(readText(filePath));
  } catch (error) {
    throw new Error(
      `${relative(filePath)} is not valid JSON: ${error.message}`,
    );
  }
}

function relative(filePath) {
  return path.relative(WORKSPACE_DIR, filePath);
}

function fail(message) {
  throw new Error(message);
}

// The commit of the mike-workflows checkout the generated files were built
// from. Stamped into both outputs so a reviewer (and the CI drift check) can
// re-run the generator against the exact same source tree.
function resolveSourceCommit() {
  let commit;
  try {
    commit = execFileSync("git", ["-C", WORKFLOWS_DIR, "rev-parse", "HEAD"], {
      encoding: "utf8",
    }).trim();
  } catch (error) {
    fail(
      `Could not resolve the ${relative(WORKFLOWS_DIR)} HEAD commit: ${error.message}`,
    );
  }
  if (!/^[0-9a-f]{40}$/.test(commit)) {
    fail(
      `Unexpected git rev-parse output for ${relative(WORKFLOWS_DIR)}: ${commit}`,
    );
  }
  return commit;
}

function parseScalar(value, label) {
  const trimmed = value.trim();
  if (trimmed === "null") return null;
  if (trimmed === "true") return true;
  if (trimmed === "false") return false;
  if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
  if (
    (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
    (trimmed.startsWith("'") && trimmed.endsWith("'"))
  ) {
    return trimmed.slice(1, -1);
  }
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
    try {
      return JSON.parse(trimmed);
    } catch (error) {
      fail(`${label} is not valid inline JSON: ${error.message}`);
    }
  }
  return trimmed;
}

function parseSimpleYaml(source, label) {
  const lines = source.replace(/\r\n/g, "\n").split("\n");
  const result = {};

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line.trim() || line.trim().startsWith("#")) continue;
    if (line.startsWith(" ")) {
      fail(`${label}:${i + 1} has unsupported indentation`);
    }

    const match = line.match(/^(\$?[A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
    if (!match) fail(`${label}:${i + 1} is not valid frontmatter`);
    const key = match[1];
    const rawValue = match[2].trim();

    if (rawValue) {
      if ([">", ">-", "|", "|-"].includes(rawValue)) {
        const parts = [];
        i++;
        for (; i < lines.length; i++) {
          const child = lines[i];
          if (!child.trim()) {
            parts.push("");
            continue;
          }
          if (!child.startsWith("  ")) {
            i--;
            break;
          }
          parts.push(child.slice(2));
        }
        result[key] = rawValue.startsWith("|")
          ? parts.join("\n").trimEnd()
          : parts.join(" ").replace(/\s+/g, " ").trim();
        continue;
      }
      result[key] = parseScalar(rawValue, `${label}.${key}`);
      continue;
    }

    const scalarItems = [];
    const objectItems = [];
    const properties = {};
    let mode = null;
    i++;
    for (; i < lines.length; i++) {
      const child = lines[i];
      if (!child.trim()) continue;
      if (!child.startsWith("  ")) {
        i--;
        break;
      }

      const listMatch = child.match(/^  -(?:\s+(.*))?$/);
      if (listMatch) {
        const itemText = listMatch[1]?.trim() ?? "";
        if (itemText.includes(":")) {
          mode ??= "objects";
          if (mode !== "objects") {
            fail(`${label}.${key} mixes scalar and object list items`);
          }
          const object = {};
          if (itemText) {
            const itemMatch = itemText.match(
              /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/,
            );
            if (!itemMatch) {
              fail(`${label}:${i + 1} is not a valid object list item`);
            }
            object[itemMatch[1]] = parseScalar(
              itemMatch[2],
              `${label}.${key}.${itemMatch[1]}`,
            );
          }
          objectItems.push(object);
          continue;
        }

        mode ??= "scalars";
        if (mode !== "scalars") {
          fail(`${label}.${key} mixes object and scalar list items`);
        }
        scalarItems.push(parseScalar(itemText, `${label}.${key}`));
        continue;
      }

      const childPropMatch = child.match(/^  ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
      if (childPropMatch) {
        mode ??= "properties";
        if (mode !== "properties") {
          fail(`${label}.${key} mixes mapping and list values`);
        }
        properties[childPropMatch[1]] = parseScalar(
          childPropMatch[2],
          `${label}.${key}.${childPropMatch[1]}`,
        );
        continue;
      }

      const propMatch = child.match(/^    ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
      if (!propMatch || mode !== "objects" || objectItems.length === 0) {
        fail(`${label}:${i + 1} has unsupported frontmatter structure`);
      }
      objectItems[objectItems.length - 1][propMatch[1]] = parseScalar(
        propMatch[2],
        `${label}.${key}.${propMatch[1]}`,
      );
    }

    result[key] =
      mode === "objects"
        ? objectItems
        : mode === "properties"
          ? properties
          : scalarItems;
  }

  return result;
}

function readSkillFile(filePath) {
  const text = readText(filePath).replace(/\r\n/g, "\n");
  if (!text.startsWith("---\n")) {
    fail(`${relative(filePath)} must start with YAML frontmatter`);
  }
  const close = text.indexOf("\n---", 4);
  if (close === -1) {
    fail(`${relative(filePath)} is missing closing YAML frontmatter marker`);
  }
  const afterClose = text.slice(close + 4);
  if (afterClose && !afterClose.startsWith("\n")) {
    fail(`${relative(filePath)} has invalid frontmatter closing marker`);
  }
  return {
    metadata: parseSimpleYaml(text.slice(4, close), relative(filePath)),
    body: afterClose.replace(/^\n/, "").trimEnd(),
    fullText: text.trimEnd(),
  };
}

function parseTableColumnsYaml(filePath) {
  const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n");
  const result = { columns_config: [] };
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];
    if (!line.trim() || line.trim().startsWith("#")) {
      i++;
      continue;
    }

    const schemaMatch = line.match(/^\$schema:\s*(.+)$/);
    if (schemaMatch) {
      result.$schema = parseScalar(
        schemaMatch[1],
        `${relative(filePath)}.$schema`,
      );
      i++;
      continue;
    }

    if (line !== "columns:") {
      fail(`${relative(filePath)}:${i + 1} is not valid table columns YAML`);
    }
    i++;
    break;
  }

  let current = null;
  for (; i < lines.length; i++) {
    const line = lines[i];
    if (!line.trim()) continue;

    const itemMatch = line.match(/^  - index:\s*(.+)$/);
    if (itemMatch) {
      current = {
        index: parseScalar(
          itemMatch[1],
          `${relative(filePath)}.columns_config.index`,
        ),
      };
      result.columns_config.push(current);
      continue;
    }

    if (!current) {
      fail(`${relative(filePath)}:${i + 1} column entry must start with index`);
    }

    const propMatch = line.match(/^    ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
    if (!propMatch) {
      fail(`${relative(filePath)}:${i + 1} is not a valid column property`);
    }
    const key = propMatch[1];
    const rawValue = propMatch[2].trim();

    if (key === "tags" && rawValue === "") {
      const tags = [];
      i++;
      for (; i < lines.length; i++) {
        const tagMatch = lines[i].match(/^      -\s*(.+)$/);
        if (!tagMatch) {
          i--;
          break;
        }
        tags.push(parseScalar(tagMatch[1], `${relative(filePath)}.${key}`));
      }
      current.tags = tags;
      continue;
    }

    if (
      rawValue === ">-" ||
      rawValue === ">" ||
      rawValue === "|-" ||
      rawValue === "|"
    ) {
      const parts = [];
      i++;
      for (; i < lines.length; i++) {
        if (!lines[i].startsWith("      ")) {
          i--;
          break;
        }
        parts.push(lines[i].slice(6));
      }
      current[key] = rawValue.startsWith("|")
        ? parts.join("\n")
        : parts.join(" ").replace(/\s+/g, " ").trim();
      continue;
    }

    current[key] = parseScalar(rawValue, `${relative(filePath)}.${key}`);
  }

  return result;
}

function assertString(value, label) {
  if (typeof value !== "string" || value.trim() === "") {
    fail(`${label} must be a non-empty string`);
  }
}

function assertOptionalString(value, label) {
  if (value === undefined || value === null) return;
  if (typeof value !== "string") fail(`${label} must be a string`);
}

function assertOptionalStringArray(value, label) {
  if (value === undefined || value === null) return;
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
    fail(`${label} must be an array of strings`);
  }
}

function normalizeContributors(value, label) {
  if (!Array.isArray(value) || value.length === 0) {
    fail(`${label} must be a non-empty array`);
  }
  return value.map((contributor, index) => {
    const contributorLabel = `${label}[${index}]`;
    if (
      !contributor ||
      typeof contributor !== "object" ||
      Array.isArray(contributor)
    ) {
      fail(`${contributorLabel} must be an object`);
    }
    assertString(contributor.name, `${contributorLabel}.name`);
    assertOptionalString(
      contributor.organisation,
      `${contributorLabel}.organisation`,
    );
    assertOptionalString(contributor.role, `${contributorLabel}.role`);
    assertOptionalString(contributor.linkedin, `${contributorLabel}.linkedin`);
    return {
      name: contributor.name.trim(),
      organisation: contributor.organisation?.trim() || null,
      role: contributor.role?.trim() || null,
      linkedin: contributor.linkedin?.trim() || null,
    };
  });
}

function assertColumnConfig(columns, label) {
  if (!Array.isArray(columns) || columns.length === 0) {
    fail(`${label}.columns_config must be a non-empty array`);
  }

  columns.forEach((column, index) => {
    const columnLabel = `${label}.columns_config[${index}]`;
    if (!column || typeof column !== "object" || Array.isArray(column)) {
      fail(`${columnLabel} must be an object`);
    }
    if (!Number.isInteger(column.index)) {
      fail(`${columnLabel}.index must be an integer`);
    }
    assertString(column.name, `${columnLabel}.name`);
    assertString(column.prompt, `${columnLabel}.prompt`);
    assertOptionalString(column.format, `${columnLabel}.format`);
    assertOptionalStringArray(column.tags, `${columnLabel}.tags`);
  });
}

function readPackFile(category, packPath) {
  const label = relative(packPath);
  const pack = parseSimpleYaml(readText(packPath), label);
  assertString(pack.id, `${label}.id`);
  assertString(pack.title, `${label}.title`);
  assertString(pack.description, `${label}.description`);
  assertString(pack.version, `${label}.version`);
  if (!Array.isArray(pack.workflows) || pack.workflows.length === 0) {
    fail(`${label}.workflows must be a non-empty list`);
  }
  pack.workflows.forEach((workflowName, index) => {
    assertString(workflowName, `${label}.workflows[${index}]`);
  });
  return {
    key: `${category}:${pack.id}`,
    title: pack.title,
    description: pack.description,
    version: pack.version,
    workflow_names: pack.workflows,
  };
}

function readWorkflow(category, workflowDir, pack) {
  const slug = path.basename(workflowDir);
  const metadataPath = path.join(workflowDir, "metadata.json");
  if (fs.existsSync(metadataPath)) {
    fail(
      `${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`,
    );
  }
  const skillPath = path.join(workflowDir, "SKILL.md");
  if (!fs.existsSync(skillPath)) {
    fail(`${relative(skillPath)} is required`);
  }
  const {
    metadata: frontmatter,
    body,
    fullText: sourceSkillMd,
  } = readSkillFile(skillPath);
  let skillMd = body.trimStart();
  const label = `${relative(skillPath)} frontmatter`;
  const metadata = frontmatter.metadata;
  const id = `builtin-${slug}`;
  const assetsDir = path.join(workflowDir, "assets");
  const assets = fs.existsSync(assetsDir)
    ? fs
        .readdirSync(assetsDir, { withFileTypes: true })
        .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
        .sort((a, b) => a.name.localeCompare(b.name, "en"))
        .map((entry) => {
          const assetPath = path.join(assetsDir, entry.name);
          return {
            filename: entry.name,
            file_type: path.extname(entry.name).slice(1).toLowerCase() || "bin",
            size_bytes: fs.statSync(assetPath).size,
            content_base64: fs.readFileSync(assetPath).toString("base64"),
          };
        })
    : [];

  assertString(frontmatter.name, `${label}.name`);
  if (frontmatter.name !== slug) {
    fail(`${label}.name must match the folder name "${slug}"`);
  }
  if (!WORKFLOW_NAME_PATTERN.test(frontmatter.name)) {
    fail(
      `${label}.name must contain only lowercase letters, numbers, and hyphens`,
    );
  }
  assertString(frontmatter.description, `${label}.description`);
  assertString(frontmatter.license, `${label}.license`);
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
    fail(`${label}.metadata must be a mapping`);
  }
  assertString(metadata.author, `${label}.metadata.author`);
  assertString(metadata.language, `${label}.language`);
  assertString(metadata.version, `${label}.version`);
  assertString(
    metadata["mike-display-name"],
    `${label}.metadata.mike-display-name`,
  );
  if (metadata["mike-type"] !== category) {
    fail(`${label}.metadata.mike-type must be "${category}"`);
  }
  // metadata.mike-availability is deprecated: backend ingestion derives the
  // default/add-on split from its deployment policy, so the flag is accepted
  // for backwards compatibility but never emitted. Warn (don't fail) on
  // unexpected values so existing content keeps building.
  const availability = metadata["mike-availability"];
  if (
    availability !== undefined &&
    !["system", "add-on"].includes(availability)
  ) {
    console.warn(
      `Warning: ${label}.metadata.mike-availability has unexpected value ${JSON.stringify(availability)}; the key is deprecated and ignored`,
    );
  }
  assertString(metadata.practice, `${label}.metadata.practice`);
  assertString(metadata.jurisdictions, `${label}.metadata.jurisdictions`);

  const normalizedMetadata = {
    name: frontmatter.name,
    title: metadata["mike-display-name"],
    description: frontmatter.description,
    type: metadata["mike-type"],
    contributors: [
      {
        name: metadata.author.trim(),
        organisation: null,
        role: null,
        linkedin: null,
      },
    ],
    language: metadata.language,
    version: metadata.version,
    practice: metadata.practice,
    jurisdictions: metadata.jurisdictions
      .split(",")
      .map((item) => item.trim())
      .filter(Boolean),
  };

  if (skillMd && !skillMd.startsWith("# ")) {
    skillMd = `# ${normalizedMetadata.title}\n\n${skillMd}`;
  }

  if (category === "assistant") {
    if (!skillMd.trim()) {
      fail(
        `${relative(skillPath)} must include instructions after frontmatter`,
      );
    }
    const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
    if (fs.existsSync(tableColumnsPath)) {
      fail(
        `${relative(tableColumnsPath)} is only supported for tabular workflows`,
      );
    }
    return {
      id,
      metadata: normalizedMetadata,
      skill_md: skillMd,
      source_skill_md: sourceSkillMd,
      columns_config: null,
      assets,
      pack,
    };
  }

  const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
  if (!fs.existsSync(tableColumnsPath)) {
    fail(`${relative(tableColumnsPath)} is required for tabular workflows`);
  }
  const tableConfig = parseTableColumnsYaml(tableColumnsPath);
  const tableConfigLabel = relative(tableColumnsPath);
  const expectedSchemaPath = path.join(
    WORKFLOWS_DIR,
    "workflow-schema/table-columns.schema.yaml",
  );
  const actualSchemaPath = path.resolve(workflowDir, tableConfig.$schema ?? "");
  if (actualSchemaPath !== expectedSchemaPath) {
    fail(
      `${tableConfigLabel}.$schema must point to workflow-schema/table-columns.schema.yaml`,
    );
  }
  assertColumnConfig(tableConfig.columns_config, tableConfigLabel);

  return {
    id,
    metadata: normalizedMetadata,
    skill_md: skillMd || null,
    source_skill_md: sourceSkillMd,
    columns_config: tableConfig.columns_config,
    assets,
    pack,
  };
}

function loadWorkflows() {
  const workflows = [];
  const seenIds = new Set();

  for (const collection of WORKFLOW_COLLECTIONS) {
    const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
    if (!fs.existsSync(collectionDir)) continue;
    const workflowDirs = fs
      .readdirSync(collectionDir, { withFileTypes: true })
      .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
      .flatMap((entry) => {
        const entryDir = path.join(collectionDir, entry.name);
        if (fs.existsSync(path.join(entryDir, "SKILL.md"))) {
          return [{ directory: entryDir, pack: null }];
        }
        if (!fs.existsSync(path.join(entryDir, "pack.yaml"))) return [];
        const pack = readPackFile(
          collection.type,
          path.join(entryDir, "pack.yaml"),
        );
        const childDirectories = fs
          .readdirSync(entryDir, { withFileTypes: true })
          .filter(
            (child) =>
              child.isDirectory() &&
              fs.existsSync(path.join(entryDir, child.name, "SKILL.md")),
          )
          .map((child) => path.join(entryDir, child.name));
        const discoveredNames = new Set(
          childDirectories.map((directory) => path.basename(directory)),
        );
        for (const workflowName of pack.workflow_names) {
          if (!discoveredNames.has(workflowName)) {
            fail(
              `${relative(path.join(entryDir, "pack.yaml"))} lists missing workflow '${workflowName}'`,
            );
          }
        }
        // The reverse direction matters too: a workflow directory that
        // pack.yaml does not list would otherwise silently ship as part of
        // the pack.
        const listedNames = new Set(pack.workflow_names);
        for (const discoveredName of discoveredNames) {
          if (!listedNames.has(discoveredName)) {
            fail(
              `${relative(path.join(entryDir, "pack.yaml"))} does not list discovered workflow '${discoveredName}'`,
            );
          }
        }
        return childDirectories.map((directory) => ({ directory, pack }));
      })
      // Pin the collation locale: a bare localeCompare follows the build
      // machine's ICU locale, which can reorder the output between machines.
      .sort((a, b) => a.directory.localeCompare(b.directory, "en"));

    for (const workflowDir of workflowDirs) {
      const workflow = readWorkflow(
        collection.type,
        workflowDir.directory,
        workflowDir.pack,
      );
      if (seenIds.has(workflow.id)) {
        fail(`Duplicate workflow id: ${workflow.id}`);
      }
      seenIds.add(workflow.id);
      workflows.push(workflow);
    }
  }

  return workflows.sort((a, b) => a.id.localeCompare(b.id, "en"));
}

function formatTs(value) {
  return JSON.stringify(value, null, 4);
}

function writeGeneratedFile(workflows, sourceCommit) {
  const landingWorkflows = workflows.map((workflow) => ({
    id: workflow.id,
    metadata: workflow.metadata,
    skill_md: workflow.source_skill_md,
    columnCount: workflow.columns_config?.length ?? 0,
    columns: workflow.columns_config ?? [],
  }));

  const landingText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\n// Source: Open-Legal-Products/mike-workflows @ ${sourceCommit}\nimport type { LandingWorkflow } from "./workflow-browser";\n\nexport const LANDING_WORKFLOWS: LandingWorkflow[] = ${formatTs(landingWorkflows)};\n`;

  if (fs.existsSync(path.dirname(LANDING_OUT))) {
    fs.writeFileSync(LANDING_OUT, landingText);
    return true;
  }
  return false;
}

function main() {
  if (!fs.existsSync(WORKFLOWS_DIR)) {
    fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`);
  }
  for (const collection of WORKFLOW_COLLECTIONS) {
    const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
    if (!fs.existsSync(collectionDir)) {
      fail(`Workflow collection not found: ${relative(collectionDir)}`);
    }
  }

  const workflows = loadWorkflows();
  if (workflows.length === 0) {
    fail("No workflows found");
  }

  const sourceCommit = resolveSourceCommit();
  const generated = writeGeneratedFile(workflows, sourceCommit);
  console.log(
    `${generated ? "Generated" : "Validated"} ${workflows.length} landing workflows from mike-workflows @ ${sourceCommit}.`,
  );
}

try {
  main();
} catch (error) {
  console.error(error.message);
  process.exit(1);
}