AI School Pakistan
← Applied AI Builder0 of 18 complete
LessonCourse overview

Module 2 · Structured Outputs and Tools · 45 min

Build a Reliable Multi-Step Workflow

Lesson 6 of 18

Is lesson ke baad tum

Build AI workflows as durable state machines: validate locally, isolate the model adapter, checkpoint every stage, retry only safe transient failures, test recovery, and keep approval and external action under independent human control.

In this lesson

Run a local draft pipeline, then make one stage fail and inspect what was saved. A state is a named point in the process, such as validated or awaiting_approval. Saving each transition helps you see which work completed before a failure.

This Node lab uses synthetic product facts and a deterministic draft function. It needs no model account and sends nothing. It teaches checkpoints and stopping conditions; a local JSON file is not a production queue or a tested recovery system.

Design the States Before the Happy Path

Trace this path: received → validated → drafted → awaiting_approval. A failed content check instead ends at rejected_by_checks; an exception records failed plus the last completed state. Each run gets a unique ID and workflow version.

Approval and publication are later events. The program has neither an approval executor nor a send function. A successful draft must remain awaiting approval. Input validation, authorization failures, and unsupported claims require correction; only classified transient failures should enter a bounded retry policy.

Worked Example: A Checkpointed Draft Pipeline

Create workflow.mjs:

javascript95 lines
import { mkdir, rename, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const runsDir = "runs";
await mkdir(runsDir, { recursive: true });

const fixture = {
  product_id: "DEMO-42",
  product_name: "Reusable notebook cover",
  approved_facts: [
    "Available in blue and charcoal",
    "Fits the demo A5 notebook insert"
  ],
  audience: "existing demo-store customers",
  max_characters: 240
};

function validateInput(input) {
  const errors = [];
  if (typeof input.product_id !== "string" || !/^DEMO-\d+$/.test(input.product_id)) errors.push("invalid product_id");
  if (typeof input.product_name !== "string" || !input.product_name.trim() || input.product_name.length > 100) errors.push("missing product_name");
  if (!Array.isArray(input.approved_facts) || input.approved_facts.length < 1 || input.approved_facts.length > 10) errors.push("approved_facts required");
  if (!Number.isInteger(input.max_characters) || input.max_characters < 1 || input.max_characters > 500) errors.push("invalid max_characters");
  if (Array.isArray(input.approved_facts) && input.approved_facts.some(
    (fact) => typeof fact !== "string" || !fact.trim() || fact.length > 500
  )) errors.push("approved_facts must contain short non-empty strings");
  if (errors.length) throw new Error(errors.join("; "));
}

async function save(run) {
  run.updated_at = new Date().toISOString();
  const target = `${runsDir}/${run.run_id}.json`;
  const temporary = `${target}.${randomUUID()}.tmp`;
  await writeFile(temporary, JSON.stringify(run, null, 2), "utf8");
  await rename(temporary, target);
}

// Replace only this adapter when connecting a model.
async function draftAdapter(input) {
  return `${input.product_name}: ${input.approved_facts.join(". ")}.`;
}

function checkDraft(input, draft) {
  const errors = [];
  if (typeof draft !== "string" || !draft.trim()) return ["empty or invalid draft"];
  if (draft.length > input.max_characters) errors.push("draft exceeds character limit");
  for (const fact of input.approved_facts) {
    if (!draft.includes(fact)) errors.push(`approved fact missing: ${fact}`);
  }
  const bannedClaims = ["guaranteed", "best in Pakistan", "limited stock"];
  for (const claim of bannedClaims) {
    if (draft.toLowerCase().includes(claim.toLowerCase())) errors.push(`unsupported claim: ${claim}`);
  }
  return errors;
}

async function start(input) {
  const run = {
    run_id: randomUUID(),
    workflow_version: "1.0.0",
    state: "received",
    created_at: new Date().toISOString(),
    input,
    history: [{ state: "received", at: new Date().toISOString() }]
  };
  await save(run);

  try {
  validateInput(input);
  run.state = "validated";
  run.history.push({ state: "validated", at: new Date().toISOString() });
  await save(run);

  run.draft = await draftAdapter(input);
  run.state = "drafted";
  run.history.push({ state: "drafted", at: new Date().toISOString() });
  await save(run);

  run.check_errors = checkDraft(input, run.draft);
  run.state = run.check_errors.length ? "rejected_by_checks" : "awaiting_approval";
  run.history.push({ state: run.state, at: new Date().toISOString() });
  await save(run);
  } catch {
    run.failed_after = run.state;
    run.failure_code = "STAGE_ERROR";
    run.state = "failed";
    run.history.push({ state: run.state, at: new Date().toISOString() });
    await save(run);
  }
  return run;
}

const result = await start(fixture);
console.log({ run_id: result.run_id, state: result.state, draft: result.draft });
console.log("No customer message was sent. Human approval is still required.");

Run node workflow.mjs, open the generated run file, and trace every state. The preserved input, draft, checks, and state history make the run reviewable. The program never modifies the input object; local files themselves are still editable and are not an immutable audit store. In a real system, do not keep sensitive input in a repository or unencrypted local folder; this lab uses synthetic facts only.

Before a real approval feature, define its precondition: only the exact version in awaiting_approval may be reviewed. Record the verified reviewer, decision, and artifact identity; a draft change invalidates approval. Approval and execution are different events.

Replace the Adapter Without Rewriting the Workflow

A model adapter should accept the validated object and return a narrow structured result. Its responsibilities are API request, timeout, bounded retry for transient failures, response parsing, and safe request metadata. It should not decide who is authorized, change approved facts, approve its own draft, or publish.

Use an interface such as:

javascript4 lines
async function modelDraftAdapter(input, context) {
  // context: run_id, deadline, model configuration, attempt number
  // return: { text, response_id, usage, model }
}

Save the response ID and usage beside the drafted checkpoint. If the worker crashes after the remote response but before saving, the run may be ambiguous. A robust queue records leases and attempt IDs, and the worker checks for an existing completed stage before repeating it. For high-value or side-effecting steps, use provider idempotency support where available plus your own durable business record.

Reliability Patterns That Matter

  • Timeouts: set a deadline for each remote stage and a larger workflow deadline. Do not let abandoned requests occupy workers indefinitely.
  • Bounded retries: retry only classified transient errors with jitter; keep attempt count and last safe error.
  • Checkpointing: persist after state changes, not only at the end.
  • Idempotency: a stage sees the same run and stage key and returns the prior result instead of repeating work.
  • Dead-letter path: after attempts are exhausted, move the run to review with enough safe metadata to diagnose it.
  • Concurrency control: prevent two workers from approving or publishing the same version.
  • Versioning: store workflow, prompt, schema, tool, and model configuration versions.
  • Observability: count runs by state, failure category, latency, and review outcome; never treat a single average as the whole distribution.
  • Human gate: show the exact final artifact, source facts, check results, recipient or destination, and material cost/action.

Failure Injection, Not Hope

The supplied program records a safe failure category rather than dumping exception text or private input into logs. Its run file contains synthetic input, so put runs/ in .gitignore before running it.

If a process is killed between checkpoints, inspect the last saved state. This program does not resume automatically, and re-running creates a new run ID. Do not claim exactly-once behavior from these files: production recovery needs durable stage identities, atomic transitions, concurrency control, and an approved resume policy. Renaming a temporary file reduces partial-file exposure; it does not provide database transactions or guaranteed disk durability.

Hands-On Exercise: Diagnose Saved Workflow States

Use the supplied file for one small failure experiment:

  1. Run it unchanged. Open the run JSON. Expect history received, validated, drafted, awaiting_approval, with an empty check_errors list and no send event.
  2. Change max_characters from 240 to 10 and run again. Predict the result before opening the new file. Ten is a valid input limit, so the pipeline reaches drafted and then rejected_by_checks with draft exceeds character limit.
  3. Restore 240. In draftAdapter, temporarily replace its return with throw new Error("simulated timeout"). Run again. Expect failed, failed_after: "validated", and failure_code: "STAGE_ERROR". This label does not prove that a real remote timeout occurred.
  4. Restore the adapter. Write a three-row comparison of run ID, final state, and the first action you would take: review draft, fix the limit/content mismatch, or inspect the failed adapter. Do not edit saved state to pretend a stage completed.

Optional later extension: add approval and resume commands with explicit supported states, version checks, named reviewers, and duplicate-transition tests. Keep sending disabled. Those commands are outside this 45-minute core exercise.

Completion Rubric

  • The normal run stops at awaiting_approval and preserves its input, draft, checks, and history.
  • The ten-character run reaches rejected_by_checks, not approval.
  • The simulated exception records failed_after: "validated" and a safe failure code.
  • Your comparison distinguishes a recorded checkpoint from implemented recovery, approval, or publication.

Sources

Practice quiz

Check yourself

4 questions · practise what you just learned

0/4
  1. 1 / 4 · diagnose

    In workflow.mjs, you change max_characters from 240 to 10 and leave the adapter unchanged. Which saved result should you expect?

Apna kaam check karo.

Practice complete ki? Result ko example aur lesson ke checks se compare karo.

This is your own completion record, not a graded assessment.

Kuch samajh nahi aaya? Ask a question →