Skip to content
CADCAMFUN
Docs

Documentation

Workflows

Versioned YAML checks and service steps run against the exact project commit that triggered them.

What a run evaluates

Workflow files are committed under .cadcamfun/workflows/ with a .yml or .yaml extension. The sidecar loads them from a selected committed ref. Editing an uncommitted drawing or workflow does not change the files seen by an existing run.

@cadcamfun/workflow parses the YAML, validates dependencies, matches triggers, and executes registered functions. It does not execute arbitrary shell commands, container images, or downloaded actions. Each uses identifier must exist in the runner's current registry before any step starts.

Write a workflow

Validate every manually dispatched project
name: Project checks
on:
  manual: true
jobs:
  validate:
    steps:
      - id: structure
        uses: project/validate
      - uses: project/require-gcode
      - uses: cam/sequence

A workflow has a name, on triggers, and jobs keyed by identifier. A job contains at least one step and can declare needs. A step has uses, optional id and name, optional with arguments, if, and continueOnError.

Identifiers start with a letter and contain at most 64 letters, digits, underscores, or hyphens. Step identifiers are unique across the entire workflow. Parsing rejects invalid YAML, schema failures, unknown job dependencies, cyclic dependencies, and duplicate step identifiers.

Built-in checks

  • project/validate parses committed JSON files and checks recognized drawing/component/toolpath entity IDs against their file names and verifies their names. It returns resource counts.
  • project/require-gcode fails if a committed toolpath has empty or missing G-code. It does not require that the project contain at least one toolpath.
  • cam/verify builds replay requests from committed toolpath G-code and Toolpath.data.request. It fails on error-severity replay findings, or also warnings when with.severity is warning. Optional arguments are toolpath and tolerance.
  • cam/gcode regenerates from the committed data.request and compares its trimmed NC with committed text. It accepts an optional toolpath selector.
  • cam/sequence checks stored requests for conflicting tool-number assignments and rest-machining requests whose preceding tool is not larger than the active tool.

The CAM steps read the stored generator request format. Missing NC, missing generation requests, or unsupported verification requests are reported in skipped outputs where applicable; they do not automatically fail the step. Inspect the verified/matched count and skipped reasons. A green workflow containing only skipped CAM work is not proof of a verified program.

Service-backed steps

The live sidecar registers vcs/commit, notify/push, and, after the assistant service exists, ai/prompt. Registration is explicit: a runner created without these services fails a workflow naming an unavailable step.

vcs/commit requires a message and accepts a files record of text under .cadcamfun/ with YAML, Markdown, JSON, or text extensions. It acts as the run's account and obeys protected-branch policy. The extra-file argument cannot directly replace CAD entity paths. The current implementation delegates to project commit without checking for a clean working copy: it also snapshots current database edits on the checked-out branch, which can differ from the ref being checked. Run this writing step only when that current branch and working state are intended to be committed.

notify/push creates an application notification for the run's actor, with optional title, body, and success/failure outcome. It is a persisted notification step; it does not by itself establish delivery through an external push provider.

ai/prompt requires a prompt and optionally accepts system text and a model. It can substitute committed file content and returns answer text and model identity. Missing credentials or a provider error fail this step; a model answer is advisory output, separate from deterministic geometry checks.

The pure built-in steps are registered in builtinSteps (apps/sidecar/src/services/workflow-steps.ts): project/validate, project/require-gcode, cam/verify, cam/gcode, and cam/sequence. They need only the committed files and @cadcamfun/core, so they are available even when the assistant is not running.

Execution, outputs, and conditions

Jobs run in dependency levels, with independent jobs in a level running concurrently. Steps within a job run sequentially. Jobs whose dependencies did not succeed are skipped. The default step condition is success; always runs after either result and failure runs after a prior unhandled step failure in that job.

continueOnError: true records a failing step event without setting the job's failure flag. Otherwise a failed step skips later success-only steps and marks the job failed. The overall result is success only when every job succeeds.

Earlier named-step outputs can be referenced in with values using the expression shown below. A value consisting of exactly one expression retains its type; substitution inside longer strings converts the output to text. Jobs should declare dependencies when they consume another job's outputs.

Use an earlier step output
with:
  body: "Checked drawings: ${{ steps.structure.outputs.drawings }}"

Triggers and dispatch

on.push supports branch and changed-path glob filters; on.pullRequest filters the base branch; on.manual enables explicit dispatch. A single asterisk matches within a path segment and a double asterisk can span segments.

The HTTP commit/push integration currently supplies an empty changed-path list to the automatic push trigger. Consequently a workflow with a push path filter does not match those events. Use branch-only push filters or manual dispatch for this integration until changed-path data is supplied.

Read committed definitions at GET /api/projects/:id/actions/workflows, optionally with ref. Read one at /workflows/:name. A malformed file is returned with its content and a problem so it can be repaired. To edit a definition, include its safe workflow path in a project commit.

POST /api/projects/:id/actions/dispatch accepts an optional ref and dispatches every matching manual workflow at that ref; it is not a selector for one workflow name. Read saved results from GET /api/projects/:id/actions/runs.

Run persistence and events

The runner records workflow, event, ref, commit SHA, status, timestamps, and accumulated events in WorkflowRun. Job events mark starts and ends; step events include index, identifier, status, optional outputs, and failure text. Invalid YAML is retained as a failed run with an invalid-file event.

The current HTTP trigger path waits for workflows to settle and then publishes run-started and run-finished live events together. It does not stream each running step through that HTTP request. The core runner exposes an onEvent callback for integrations that need step progress.

Interpret results precisely

A workflow reports on its captured commit and registered step implementations. It does not certify later working-copy edits or remove an already recorded commit on failure. Inspect skipped checks, warning policy, and relevant toolpath evidence before using a successful run as a release gate.

Implementation is split between packages/workflow/src for schema/trigger/execution and sidecar workflow-runner.ts, workflow-steps.ts, and workflow-service-steps.ts for committed project data, persisted runs, and application services.

Run an isolated workflow engine example

A package consumer supplies the registered functions and their typed context. This example parses a manual workflow, executes one registered check, and returns its job result without opening SQLite or invoking AI.

Parse, register, and run
import { Effect } from "effect"
import { parseWorkflow, runWorkflow } from "@cadcamfun/workflow"

const source = [
  "name: Isolated check", "on:", "  manual: true", "jobs:",
  "  inspect:", "    steps:", "      - id: checked", "        uses: inspection/check"
].join("\n")
const result = await Effect.runPromise(Effect.gen(function* () {
  const workflow = yield* parseWorkflow(source)
  return yield* runWorkflow(workflow, {
    ctx: { drawingCount: 2 },
    registry: { "inspection/check": ({ ctx }) => Effect.succeed({ drawings: ctx.drawingCount }) }
  })
}))
console.log(result) // { status: "success", jobs: { inspect: "success" } }

Run within a workspace depending on Effect and @cadcamfun/workflow, such as apps/sidecar. The custom step above is local to the supplied registry; naming it in a desktop workflow does not install it into the production sidecar.

Source: packages/workflow/src/schema.ts and run.ts.

Match filters and consume outputs safely

A push trigger requires its branch filter to match and, when a paths list exists, at least one supplied changed path to match. An omitted filter is unrestricted; an explicitly empty list matches nothing. A manual event requires on.manual to be present. Pull-request matching uses the base branch, rather than the head proposal name.

Output substitution traverses nested arrays and records. An exact missing output expression evaluates to undefined; an absent output embedded in a longer string becomes empty text. Do not rely on a skipped or failed step to have produced an output. Declare needs for cross-job consumers so dependency scheduling establishes the data order.

Step if is one of three literal modes, not a general expression language. Workflow variables, shell interpolation, matrix builds, arbitrary JavaScript conditions, and third-party action loading are not implemented by this engine. The separate ai/prompt handler substitutes committed files.<path> references in its prompt; ordinary steps do not receive that extra substitution.

Source: packages/workflow/src/trigger.ts, run.ts, and apps/sidecar/src/services/workflow-service-steps.ts.

An omitted branch or path filter matches every value. A manual dispatch matches only workflows that declare on.manual, and a pull-request event matches the workflow's branches globs against the pull request's base branch (packages/workflow/src/trigger.ts).

Diagnose workflow results

FindingMeaning and next check
Workflow file is returned with problemRepair YAML/schema or dependency errors and commit the fixed file. File listing preserves invalid content for editing.
Unknown step before executionCompare every uses with registered sidecar step IDs. A package example's local registry is not the production registry.
No run after pushConfirm the workflow exists in the committed ref, the branch matches, and no unsupported HTTP changed-path filter is excluding the event.
Run is successful but verified: 0Read skipped. Missing generator requests or unsupported toolpaths can leave the check without geometric evidence.
Dependent job skippedInspect the result of each needs job; dependency failure prevents the job from starting, including its always-conditioned steps.
Run remains running after process terminationInspect its timestamps/events and the exact commit. The runner persists start and final state separately and does not automatically resume an interrupted run.

Saved run history returns the latest 50 runs by default at the service boundary. Live event notifications are best-effort side channels: inspect persisted rows for the durable run result instead of treating notification delivery as the execution record.

Source: apps/sidecar/src/services/workflow-runner.ts, workflow-steps.ts, and http/vcs.ts.

Workflow file format

A workflow is a YAML file at .cadcamfun/workflows/<name>.yml in the project repository, versioned with the model it checks. Its shape mirrors GitHub Actions (on, jobs, needs, steps, uses, with), but uses always names a registered CADCAMFUN step. A workflow cannot run a shell command or a container image.

.cadcamfun/workflows/release-check.yml
name: Release check
on:
  push:
    branches: [main, "release/*"]
    paths: ["toolpaths/**"]
  pullRequest:
    branches: [main]
  manual: {}
jobs:
  validate:
    steps:
      - uses: project/validate
      - uses: project/require-gcode
  verify:
    needs: [validate]
    steps:
      - id: verify
        uses: cam/verify
        with: { severity: warning }
  gcode:
    needs: [validate]
    steps:
      - uses: cam/gcode
      - uses: cam/sequence
  report:
    needs: [verify, gcode]
    steps:
      - id: review
        uses: ai/prompt
        with:
          prompt: "Summarise risks in this program: ${{ files.toolpaths/op1.json }}"
        continueOnError: true
      - uses: notify/push
        if: always
        with:
          title: "Release check finished"
          body: "${{ steps.review.outputs.text }}"
validateverifygcodereport (needs both) level 0level 1 (concurrent)level 2
Jobs run level by level. Jobs in the same level run concurrently, and a job runs only when every job in its needs succeeded; otherwise it is reported as skipped.
KeyRules
nameRequired, 1–128 characters.
on.pushOptional branches and paths glob lists. A push matches when the branch matches and at least one changed path matches.
on.pullRequestOptional branches globs, matched against the pull request's base branch when it is opened.
on.manualAny value (for example {}) enables dispatch from the desktop or the mobile companion.
jobs.<id>Job IDs start with a letter and may contain letters, digits, _, and - (at most 64 characters). Each job needs at least one step.
needsJob IDs that must succeed first. Unknown IDs and cycles are rejected when the file is parsed.
steps[].usesA lowercase step identifier with at least one slash, such as cam/verify.
steps[].idOptional; unique across the whole workflow. Required for later steps to read its outputs.
steps[].withFree-form parameters for the step. Strings are interpolated.
steps[].ifsuccess (default: run only if nothing failed so far in the job), failure (run only after a failure), or always.
steps[].continueOnErrorWhen true, a failure of this step does not mark the job as failed.

Globs use * to match within one path segment and ** to match across segments. An omitted filter list matches everything. toolpaths/*.json matches toolpaths/a.json but not toolpaths/x/a.json; toolpaths/** matches both.

Step reference

Pure steps read only the files of the commit being checked, never the live database, so a run reports on exactly the tree that was pushed. Service-backed steps are registered once their service exists. Until then, naming one is a hard error, not a silent no-op.

StepwithOutputsFails when
project/validatenoneissues (0), drawings, workflowsAn entity JSON file is invalid, a drawings/, components/, or toolpaths/ file's id does not match its file name, or name is missing.
project/require-gcodenonetoolpathsAny toolpath in the commit has no G-code.
cam/verifytoolpath?, tolerance?, severity? (error default, or warning)verified, toolpaths, skippedReplay finds an error (or a warning, with severity: warning): rapids into stock, gouges, shank or fixture collisions, material left. Programs without G-code or a stored generation request are listed in skipped, never counted as passing.
cam/gcodetoolpath?matched, toolpaths, skippedRegenerating from the stored request does not reproduce the committed G-code (ignoring the header stamp and surrounding whitespace).
cam/sequencetoolpath?toolpaths, toolsOne tool number names two different tools, or a rest-machining operation's restToolDiameter is not larger than its toolDiameter.
vcs/commitmessage (required), files mapcommit, filesThe working copy is dirty, a path is outside .cadcamfun/, or a file is not .yml, .yaml, .md, .json, or .txt. An AI actor is refused on main.
notify/pushtitle?, body?, outcome? (success default, or failure)notifiedNever fails the run. Creates a WORKFLOW_PASSED or WORKFLOW_FAILED notification for the dispatching account, linked to the project history.
ai/promptprompt (required), system?, model?text, modelNo model is available or the call fails. Only this step fails; the geometry verdict is unaffected.

Malformed with values are read defensively: a non-string where a string is expected, or a non-finite number, is ignored rather than failing the run. Check outputs to confirm a filter such as toolpath actually applied.

Outputs and interpolation

A step with an id stores its outputs run-wide, so a job can read outputs of the jobs it needs. Inside any string in with, ${{ steps.<id>.outputs.<key> }} is replaced recursively through objects and arrays. When a string consists of exactly one expression, the value keeps its type (a number stays a number). Embedded in other text, the value is converted to a string, and a missing value becomes an empty string. ai/prompt additionally expands ${{ files.<path> }} to the text of that file in the commit (empty if the file is absent).

Expression in withOutput valueResult
"${{ steps.verify.outputs.verified }}"33 (number)
"Verified ${{ steps.verify.outputs.verified }} programs"3"Verified 3 programs"
"${{ steps.missing.outputs.x }}"noneundefined

Run lifecycle and errors

  1. The trigger event (push, pull request, or manual) selects workflows in the tree of the ref, read from the commit rather than the working copy.
  2. Each file is parsed. YAML errors (yaml), schema errors (schema), unknown needs (needs), cycles (cycle), and duplicate step IDs (duplicate-step-id) are reported as an invalid run with the reason.
  3. Every uses is checked against the registry before anything runs. An unknown step fails the run with Unknown step(s): ..., so a typo never half-applies a change.
  4. Jobs are ordered into levels. Each level runs concurrently; a job whose needs did not all succeed is skipped.
  5. Progress events (job-start, step with status and outputs or message, job-end) stream to the desktop and the companion, and the run is persisted with its events.
  6. The run succeeds only if every job succeeded. Skipped jobs count as not successful.

Live events workflow.started (with the triggering event) and workflow.finished (success, failure, or cancelled) are published for each run.

Evidence, not decoration

Of the built-in steps, only cam/verify replays the program against the stock and tools. The other checks confirm consistency of what was committed. Read skipped lists: an unverifiable toolpath is not a verified one.

A full run, traced event by event

This workflow exercises every rule of the engine at once: outputs passed between jobs, a whole-value and an embedded interpolation, continueOnError, if: failure, a job skipped because its dependency failed, and an independent job. It was run with runWorkflow and four test step handlers: demo/measure returns { volume: 7842.92, part }, demo/check prints what it received, demo/fail fails with StepError("tolerance exceeded") and demo/notify returns nothing.

.cadcamfun/workflows/release-check.yml
name: Release check
on:
  push: { branches: [main, "release/**"], paths: ["parts/**/*.json"] }
  manual: {}
jobs:
  measure:
    steps:
      - id: size
        uses: demo/measure
        with: { part: bracket }
  verify:
    needs: [measure]
    steps:
      - uses: demo/check
        with: { volume: "${{ steps.size.outputs.volume }}", label: "vol=${{ steps.size.outputs.volume }} mm3" }
      - uses: demo/fail
        continueOnError: true
      - uses: demo/fail
      - uses: demo/notify
        if: failure
      - uses: demo/notify
  publish:
    needs: [verify]
    steps:
      - uses: demo/notify
  lint:
    steps:
      - uses: demo/notify
Events and result, as emitted
levels [["measure","lint"],["verify"],["publish"]]
{"type":"job-start","job":"measure"}
{"type":"step","job":"measure","index":0,"uses":"demo/measure","status":"success","outputs":{"volume":7842.92,"part":"bracket"}}
{"type":"job-end","job":"measure","status":"success"}
{"type":"job-start","job":"lint"}
{"type":"step","job":"lint","index":0,"uses":"demo/notify","status":"success","outputs":{}}
{"type":"job-end","job":"lint","status":"success"}
{"type":"job-start","job":"verify"}
  check got {"volume":7842.92,"label":"vol=7842.92 mm3"}
{"type":"step","job":"verify","index":0,"uses":"demo/check","status":"success","outputs":{"ok":true}}
{"type":"step","job":"verify","index":1,"uses":"demo/fail","status":"failure","message":"Cause([Fail(StepError: tolerance exceeded)])"}
{"type":"step","job":"verify","index":2,"uses":"demo/fail","status":"failure","message":"Cause([Fail(StepError: tolerance exceeded)])"}
{"type":"step","job":"verify","index":3,"uses":"demo/notify","status":"success","outputs":{}}
{"type":"step","job":"verify","index":4,"uses":"demo/notify","status":"skipped"}
{"type":"job-end","job":"verify","status":"failure"}
{"type":"job-end","job":"publish","status":"skipped"}
result {"status":"failure","jobs":{"measure":"success","lint":"success","verify":"failure","publish":"skipped"}}
ObservationRule
measure and lint run in the first levelJobs without needs form level 0 and run concurrently
volume arrives as the number 7842.92, label as a stringA string that is exactly one expression keeps the output's type; an embedded expression is stringified
Step 1 fails but the job continues normallycontinueOnError: true records the failure without marking the job failed
Step 2 fails and marks the job failedDefault behaviour
Step 3 runs, step 4 is skippedif: failure runs only after a failure; the default if: success is skipped after one
publish is skipped, never startedA job whose needs did not all succeed emits only job-end with skipped
Overall failureThe run succeeds only if every job is success; a skipped job counts as not successful

Trigger matching, tested

EventMatches?Why
push to main, changed parts/a/b.jsonyesBranch matches; path matches parts/**/*.json
push to main, changed README.mdnoNo changed path matches
push to release/6.1, changed parts/x.jsonnoparts/**/*.json compiles to ^parts\/.*\/[^/]*\.json$, which needs a directory between parts/ and the file
push to feature/xnoBranch does not match
manualyesmanual is declared
pull request into mainnoNo pullRequest trigger declared

** needs a segment on each side here

Because ** becomes .* between two literal slashes, parts/**/*.json does not match files directly in parts/. List both patterns, parts/*.json and parts/**/*.json, to cover both.

* matches within one path segment; ** matches across segments. Omitting branches or paths means any value matches.

Every parse error, reproduced

codeInput problemMessage
yamlMalformed YAML such as name: [xInvalid YAML: SyntaxError: YAML Parse error: Unexpected token
schemauses: Shell (not a lower-case area/step id)SchemaError(Expected a string matching the RegExp …) with the path ["jobs"]["a"]["steps"][0]
needsneeds: [b] with no job bJob a needs unknown job b
cyclea needs b and b needs aWorkflow jobs form a cycle: a, b
duplicate-step-idTwo steps with id: s, even in different jobsStep ids must be unique across the workflow

At run time, any uses without a registered handler fails the whole run before the first step, listing every unknown id: Unknown step(s): demo/measure, demo/check, …. A typo therefore never half-applies a workflow.

FieldRule
name1–128 characters
Job and step idsLetter first, then letters, digits, _ or -; at most 64 characters
usesLower-case segments separated by /, at least two segments
stepsAt least one per job
ifsuccess (default), always, failure

In 3D

GitHub Actions job graph Drag to orbit · scroll to zoom · right-drag to pan
Every job in .github/workflows, by needs: depth to the right. Jobs marked ×6 fan out over the six-target platform matrix; release.yml chains trusted → build → manifest → publish-platform → publish-index.