Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"generate:workflow-schema": "tsx scripts/generate-workflow-schema.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
Expand Down
19 changes: 19 additions & 0 deletions backend/scripts/generate-workflow-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Regenerates schemas/workflow.schema.json from the zod schema in
// src/lib/workflowFormat.ts — the single source of truth for the
// .mikeworkflow.json format.
//
// Run from backend: npm run generate:workflow-schema
//
// The drift test (workflowFormat.test.ts) fails CI whenever the committed
// file differs from what this script would write, so a format change is
// always a two-file commit: the zod schema and the regenerated JSON.

import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { buildWorkflowPackJsonSchema } from "../src/lib/workflowFormat";

const outPath = resolve(__dirname, "../../schemas/workflow.schema.json");
const json = `${JSON.stringify(buildWorkflowPackJsonSchema(), null, 2)}\n`;

writeFileSync(outPath, json);
console.log(`wrote ${outPath}`);
182 changes: 182 additions & 0 deletions backend/src/lib/workflowFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Single source of truth for the .mikeworkflow.json interchange format.
//
// The zod schema below is THE definition of the format. Everything else is
// derived from it:
// - `importWorkflow` (routes/workflows.ts) validates uploads with it, so
// the API can never accept a file the published schema rejects.
// - `schemas/workflow.schema.json` (the schema we publish for external
// tooling) is GENERATED from it via `npm run generate:workflow-schema`
// in backend. Never edit that file by hand.
// - A drift test (workflowFormat.test.ts) fails CI if the generated file
// and this schema ever disagree, so the two cannot drift apart silently.
//
// If you change the format: edit this file, run the generator, and commit
// both files together. Breaking changes must bump WORKFLOW_PACK_FORMAT_VERSION.

import { z } from "zod/v4";

export const WORKFLOW_PACK_FORMAT_VERSION = 1;

const columnConfigSchema = z
.looseObject({
name: z.string().describe("Column heading shown in the UI."),
prompt: z
.string()
.describe("The prompt sent to the LLM for each cell in this column."),
type: z
.enum(["text", "flag", "yesno"])
.optional()
.describe(
"Optional cell rendering hint. 'flag' renders a coloured badge; 'yesno' renders Yes/No; 'text' (default) renders plain text.",
),
})
.describe(
"One column definition in a 'tabular' workflow's review table. Extra keys are allowed so newer exports keep importing into older deployments.",
);

export const workflowPackSchema = z.strictObject({
formatVersion: z
.literal(WORKFLOW_PACK_FORMAT_VERSION)
.describe(
"Schema version. Always 1 for files produced by the current export endpoint. Future breaking changes will increment this value.",
),
exportedAt: z.iso
.datetime()
.optional()
.describe(
"ISO 8601 timestamp of when the file was exported. Informational only — not used during import.",
),
workflow: z.strictObject({
title: z
.string()
.min(1)
.max(255)
.describe("Human-readable name shown in the workflow picker."),
type: z
.enum(["assistant", "tabular"])
.describe(
"Determines where the workflow appears. 'assistant' workflows appear in the chat sidebar; 'tabular' workflows appear in the tabular review column picker.",
),
prompt_md: z
.string()
.nullable()
.optional()
.describe(
"The full workflow prompt in Markdown. For 'assistant' workflows, this is injected into the system prompt when the workflow is activated. For 'tabular' workflows, this describes the analysis task for each cell.",
),
columns_config: z
.array(columnConfigSchema)
.nullable()
.optional()
.describe(
"Column definitions for 'tabular' workflows. Each entry defines one column in the review table. Null for 'assistant' workflows.",
),
practice: z
.string()
.nullable()
.optional()
.describe(
"Optional legal practice area tag (e.g. 'corporate', 'ip', 'employment'). Used for filtering in the workflow picker.",
),
language: z
.string()
.nullable()
.optional()
.describe(
"Optional drafting/analysis language (e.g. 'English', 'French'). Defaults to 'English' on import when omitted.",
),
jurisdictions: z
.array(z.string())
.nullable()
.optional()
.describe(
"Optional governing-law jurisdiction tags (e.g. ['England and Wales', 'Singapore']). Defaults to ['General'] on import when omitted.",
),
}),
});

export type WorkflowPack = z.infer<typeof workflowPackSchema>;

// Turns zod validation issues into the single human-readable `detail` string
// the import endpoint returns. Kept here so route code never needs to know
// zod's issue format.
export function describeWorkflowPackIssues(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.length ? issue.path.join(".") : "(root)";
return `${path}: ${issue.message}`;
})
.join("; ");
}

// Builds the exact JSON value published as schemas/workflow.schema.json.
// The zod schema converts to draft-07; the envelope ($id, title, examples)
// is metadata for external consumers and lives here so the generator and the
// drift test share one definition.
export function buildWorkflowPackJsonSchema(): Record<string, unknown> {
const converted = z.toJSONSchema(workflowPackSchema, {
target: "draft-7",
}) as Record<string, unknown>;

return {
...converted,
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://github.com/willchen96/mike/schemas/workflow.schema.json",
title: "Mike Workflow Pack",
description:
"Schema for .mikeworkflow.json files exported from and imported into Mike. GENERATED from backend/src/lib/workflowFormat.ts by `npm run generate:workflow-schema` — do not edit by hand.",
examples: [
{
formatVersion: 1,
exportedAt: "2026-05-24T12:00:00.000Z",
workflow: {
title: "NDA Quick Review",
type: "assistant",
prompt_md:
"Review the provided NDA and identify:\n1. Key definitions and their scope\n2. Exclusions from confidential information\n3. Duration of confidentiality obligations\n4. Any unusual or unfair clauses\n\nProvide a structured summary with a risk rating (Low / Medium / High).",
columns_config: null,
practice: "corporate",
language: "English",
jurisdictions: ["General"],
},
},
{
formatVersion: 1,
exportedAt: "2026-05-24T12:00:00.000Z",
workflow: {
title: "Contract Risk Matrix",
type: "tabular",
prompt_md: null,
columns_config: [
{
name: "Governing Law",
prompt:
"What jurisdiction's law governs this agreement? Return only the jurisdiction name.",
type: "text",
},
{
name: "Liability Cap",
prompt:
"Is there a liability cap? If yes, state the amount or formula. If no, say 'None'.",
type: "text",
},
{
name: "Auto-Renewal",
prompt: "Does this contract auto-renew? Answer Yes or No.",
type: "yesno",
},
{
name: "Red Flag",
prompt:
"Does this contract contain any clauses that are unusual, unfair, or potentially unenforceable? If yes, flag as RED and briefly explain. If no, flag as GREEN.",
type: "flag",
},
],
practice: "corporate",
language: "English",
jurisdictions: ["England and Wales"],
},
},
],
};
}
152 changes: 152 additions & 0 deletions backend/src/routes/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
type SystemWorkflow,
} from "../lib/systemWorkflows";
import { findMissingUserEmails } from "../lib/userLookup";
import {
WORKFLOW_PACK_FORMAT_VERSION,
describeWorkflowPackIssues,
workflowPackSchema,
} from "../lib/workflowFormat";

export const workflowsRouter = Router();

Expand Down Expand Up @@ -771,6 +776,153 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r
res.status(204).send();
}));

// ---------------------------------------------------------------------------
// Import / export (.mikeworkflow.json)
// ---------------------------------------------------------------------------

export async function exportWorkflow(
db: Db,
params: { workflowId: string; userId: string },
): Promise<
| { ok: true; payload: Record<string, unknown>; filename: string }
| { ok: false }
> {
const { workflowId, userId } = params;

const { data: wf } = await db
.from("workflows")
.select(
"title, type, prompt_md, columns_config, practice, language, jurisdictions",
)
.eq("id", workflowId)
.eq("user_id", userId)
.single();

if (!wf) return { ok: false };

const payload = {
formatVersion: WORKFLOW_PACK_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
workflow: {
title: wf.title,
type: wf.type,
prompt_md: wf.prompt_md ?? null,
columns_config: wf.columns_config ?? null,
practice: wf.practice ?? null,
language: wf.language ?? null,
jurisdictions: wf.jurisdictions ?? null,
},
};

// Produce a safe filename from the workflow title.
const safeName = String(wf.title ?? "workflow")
.replace(/[^a-zA-Z0-9 _-]/g, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, 80) || "workflow";

return { ok: true, payload, filename: `${safeName}.mikeworkflow.json` };
}

export type ImportWorkflowResult =
| { ok: true; workflow: Record<string, unknown> }
| { ok: false; kind: "validation"; detail: string }
| { ok: false; kind: "db_error"; detail: string };

export async function importWorkflow(
db: Db,
params: { userId: string; body: Record<string, unknown> },
): Promise<ImportWorkflowResult> {
const { userId, body } = params;

// Validate against the same schema we publish as
// schemas/workflow.schema.json — one definition of the format, so the API
// can never accept a file the published schema rejects (or vice versa).
const parsed = workflowPackSchema.safeParse(body);
if (!parsed.success) {
return {
ok: false,
kind: "validation",
detail: `Invalid workflow file: ${describeWorkflowPackIssues(parsed.error)}`,
};
}
const wf = parsed.data.workflow;
const title = wf.title.trim();
if (!title)
return { ok: false, kind: "validation", detail: "workflow.title is required." };

const { data, error } = await db
.from("workflows")
.insert({
user_id: userId,
title,
type: wf.type,
prompt_md: wf.prompt_md ?? null,
columns_config: wf.columns_config ?? null,
practice: wf.practice ?? null,
// Imported files may predate these fields — normalize to the defaults.
language:
normalizeOptionalString(wf.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
jurisdictions:
normalizeJurisdictions(wf.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
})
.select("*")
.single();

if (error || !data) {
return {
ok: false,
kind: "db_error",
detail: error?.message ?? "Failed to import workflow.",
};
}

return { ok: true, workflow: withDatabaseWorkflow(data as WorkflowRecord) };
}

// GET /workflows/:workflowId/export
// Returns the workflow as a downloadable .mikeworkflow.json file.
// Only the owner can export — the exported file contains the full prompt
// content which may be proprietary.
workflowsRouter.get("/:workflowId/export", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();

const result = await exportWorkflow(db, { workflowId, userId });
if (!result.ok)
return void res.status(404).json({ detail: "Workflow not found" });

res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
`attachment; filename="${result.filename}"`,
);
res.json(result.payload);
}));

// POST /workflows/import
// Accepts a .mikeworkflow.json payload (the body, not a file upload) and
// creates a new workflow owned by the authenticated user. The imported
// workflow always gets a fresh ID — it is never merged with an existing one.
workflowsRouter.post("/import", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();

const result = await importWorkflow(db, {
userId,
body: req.body as Record<string, unknown>,
});
if (!result.ok) {
if (result.kind === "validation")
return void res.status(400).json({ detail: result.detail });
return void res.status(500).json({ detail: result.detail });
}

res.status(201).json(result.workflow);
}));

workflowsRouter.use(
(err: unknown, _req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
Expand Down
Loading