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
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/validateparses 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-gcodefails if a committed toolpath has empty or missing G-code. It does not require that the project contain at least one toolpath.cam/verifybuilds replay requests from committed toolpath G-code andToolpath.data.request. It fails on error-severity replay findings, or also warnings whenwith.severityiswarning. Optional arguments aretoolpathandtolerance.cam/gcoderegenerates from the committeddata.requestand compares its trimmed NC with committed text. It accepts an optionaltoolpathselector.cam/sequencechecks 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.
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.
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
| Finding | Meaning and next check |
|---|---|
Workflow file is returned with problem | Repair YAML/schema or dependency errors and commit the fixed file. File listing preserves invalid content for editing. |
| Unknown step before execution | Compare every uses with registered sidecar step IDs. A package example's local registry is not the production registry. |
| No run after push | Confirm 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: 0 | Read skipped. Missing generator requests or unsupported toolpaths can leave the check without geometric evidence. |
| Dependent job skipped | Inspect the result of each needs job; dependency failure prevents the job from starting, including its always-conditioned steps. |
| Run remains running after process termination | Inspect 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.
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 }}" needs succeeded; otherwise it is reported as skipped.| Key | Rules |
|---|---|
name | Required, 1–128 characters. |
on.push | Optional branches and paths glob lists. A push matches when the branch matches and at least one changed path matches. |
on.pullRequest | Optional branches globs, matched against the pull request's base branch when it is opened. |
on.manual | Any 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. |
needs | Job IDs that must succeed first. Unknown IDs and cycles are rejected when the file is parsed. |
steps[].uses | A lowercase step identifier with at least one slash, such as cam/verify. |
steps[].id | Optional; unique across the whole workflow. Required for later steps to read its outputs. |
steps[].with | Free-form parameters for the step. Strings are interpolated. |
steps[].if | success (default: run only if nothing failed so far in the job), failure (run only after a failure), or always. |
steps[].continueOnError | When 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.
| Step | with | Outputs | Fails when |
|---|---|---|---|
project/validate | none | issues (0), drawings, workflows | An 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-gcode | none | toolpaths | Any toolpath in the commit has no G-code. |
cam/verify | toolpath?, tolerance?, severity? (error default, or warning) | verified, toolpaths, skipped | Replay 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/gcode | toolpath? | matched, toolpaths, skipped | Regenerating from the stored request does not reproduce the committed G-code (ignoring the header stamp and surrounding whitespace). |
cam/sequence | toolpath? | toolpaths, tools | One tool number names two different tools, or a rest-machining operation's restToolDiameter is not larger than its toolDiameter. |
vcs/commit | message (required), files map | commit, files | The 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/push | title?, body?, outcome? (success default, or failure) | notified | Never fails the run. Creates a WORKFLOW_PASSED or WORKFLOW_FAILED notification for the dispatching account, linked to the project history. |
ai/prompt | prompt (required), system?, model? | text, model | No 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 with | Output value | Result |
|---|---|---|
"${{ steps.verify.outputs.verified }}" | 3 | 3 (number) |
"Verified ${{ steps.verify.outputs.verified }} programs" | 3 | "Verified 3 programs" |
"${{ steps.missing.outputs.x }}" | none | undefined |
Run lifecycle and errors
- 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.
- Each file is parsed. YAML errors (
yaml), schema errors (schema), unknownneeds(needs), cycles (cycle), and duplicate step IDs (duplicate-step-id) are reported as an invalid run with the reason. - Every
usesis checked against the registry before anything runs. An unknown step fails the run withUnknown step(s): ..., so a typo never half-applies a change. - Jobs are ordered into levels. Each level runs concurrently; a job whose
needsdid not all succeed is skipped. - Progress events (
job-start,stepwith status and outputs or message,job-end) stream to the desktop and the companion, and the run is persisted with its events. - 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.
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 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"}} | Observation | Rule |
|---|---|
measure and lint run in the first level | Jobs without needs form level 0 and run concurrently |
volume arrives as the number 7842.92, label as a string | A string that is exactly one expression keeps the output's type; an embedded expression is stringified |
| Step 1 fails but the job continues normally | continueOnError: true records the failure without marking the job failed |
| Step 2 fails and marks the job failed | Default behaviour |
| Step 3 runs, step 4 is skipped | if: failure runs only after a failure; the default if: success is skipped after one |
publish is skipped, never started | A job whose needs did not all succeed emits only job-end with skipped |
Overall failure | The run succeeds only if every job is success; a skipped job counts as not successful |
Trigger matching, tested
| Event | Matches? | Why |
|---|---|---|
push to main, changed parts/a/b.json | yes | Branch matches; path matches parts/**/*.json |
push to main, changed README.md | no | No changed path matches |
push to release/6.1, changed parts/x.json | no | parts/**/*.json compiles to ^parts\/.*\/[^/]*\.json$, which needs a directory between parts/ and the file |
push to feature/x | no | Branch does not match |
| manual | yes | manual is declared |
pull request into main | no | No 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
| code | Input problem | Message |
|---|---|---|
yaml | Malformed YAML such as name: [x | Invalid YAML: SyntaxError: YAML Parse error: Unexpected token |
schema | uses: Shell (not a lower-case area/step id) | SchemaError(Expected a string matching the RegExp …) with the path ["jobs"]["a"]["steps"][0] |
needs | needs: [b] with no job b | Job a needs unknown job b |
cycle | a needs b and b needs a | Workflow jobs form a cycle: a, b |
duplicate-step-id | Two steps with id: s, even in different jobs | Step 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.
| Field | Rule |
|---|---|
name | 1–128 characters |
| Job and step ids | Letter first, then letters, digits, _ or -; at most 64 characters |
uses | Lower-case segments separated by /, at least two segments |
steps | At least one per job |
if | success (default), always, failure |