npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fyflow-plugin-sdk

v0.11.0

Published

Types and definePlugin for fyflow-app Plugins: Node Types, Workers, gate types and Resource Groups

Readme

fyflow-plugin-sdk

Types and definePlugin for writing Plugins for fyflow-app, the visual builder and Host for fyflow-orchestration flows. A Plugin contributes Node Types (with the Workers that run their Tasks), gate types, Resource Group types and Resource Groups.

npm install --save-peer fyflow-plugin-sdk
npm install --save-dev fyflow-orchestration   # its types, for type checking; the Host brings its own at run time

The contract

A Plugin is a module whose default export is a plain object. definePlugin returns its argument unchanged; it only gives you type checking. Depend on the SDK as a peer and do not bundle it: the Host provides it at run time (see below), and a copy of your own would only report its own SDK_VERSION, not the Host's.

import { definePlugin } from "fyflow-plugin-sdk";

export default definePlugin({
  id: "acme.signer", // stable and unique across Plugins
  version: "1.0.0",
  base: import.meta.url, // what Worker `url`s resolve against
  // Set on the Host's Profiles, once for every Node Type that takes them (see Profiles and secrets).
  profile: {
    schema: {
      type: "object",
      properties: { key: { type: "string", writeOnly: true } }, // a secret
    },
    startup: ["key"], // read once, when a Worker starts
  },
  workers: {
    sign: {
      url: "./sign.worker.js",
      settingsSchema: { type: "object", properties: { field: { type: "string" } } },
    },
  },
  nodeTypes: {
    Sign: {
      worker: "sign",
      inline: true,
      profile: ["key"], // the fields of `profile` its Workers get
      groups: ["acme.signing"],
      icon: "signature", // from the app's icon set, below
      description: "Signs a field with HMAC.",
    },
  },
  groups: { "acme.signing": { type: "ConcurrentLimitGroup", config: { limit: 2 } } },
  gateTypes: {
    threshold: {
      routes: ["below", "at-or-above"],
      defaultRoute: "below",
      fn: ({ key, settings }) => (Number(key) >= Number(settings.at) ? "at-or-above" : "below"),
    },
  },
});

| Field | What it is | | --- | --- | | profile | The Plugin's Profile fields, since 0.5.0: one object schema, defaults for fields no Profile sets, the startup fields its Workers read when they start, and the per-Task fields only a Profile may set (profileOnly). See Profiles and secrets. | | workers | Worker modules by key. A Worker module's default export is a class: its constructor gets the start-up fields its Node Type takes, and run({ settings, input }, context) does one Task (see Workers). A Worker declares no configSchema since 0.5.0: its config is the Plugin's profile. Schemas are JSON Schema in fyflow's subset. A Worker's outputSchema, what run returns (accepted by every version of the SDK, as fyflow's own Worker entry), is the form a person fills in to mark a failed Task done, and the paths the editor suggests for conditions on its Node's Edges and gates before any Run. Give inputSchema and outputSchema a description on each field and an examples list: a Node's panel shows what its Worker takes and returns from them, and checks what reaches the Node against what it takes. Before any Run, the first of an outputSchema's examples is the sample the next Node's Transform is tried and checked on; without one, the editor makes a coarser value from the schema. | | nodeTypes | Node Types by name, each naming one of this Plugin's workers, with fyflow's declaration fields (inline, pool, settings defaults, retryPolicy, groups; no config since 0.5.0), since 0.2.0 how the app shows it (an optional icon and a one-line description), and since 0.5.0 the profile fields it takes. | | dataDir | true asks the Host for a data directory of the Plugin's own, since 0.6.0. See A Plugin's data directory. | | status | A function saying how the Plugin is, shown on its card on the Plugins page, since 0.6.0. See A Plugin's status. | | gateTypes | Gate types by name: their routes, default route, settings schema, and the decision fn. | | groupTypes | Resource Group types by name: a create(config, key) factory for the scheduler's limit, and a configSchema. | | groups | Resource Groups by key, of fyflow's built-in types (ConcurrentLimitGroup, RateLimitGroup, KeyedRateLimitGroup, and since 0.9.0 UnitGroup and KeyedConcurrentLimitGroup) or this Plugin's own. A Node Type may use its own Plugin's groups, and the built-in Plugin's disk, cpu and gpu groups (below). |

Do not name a setting like a member of every JavaScript object (constructor, toString, valueOf, hasOwnProperty, __proto__). The Host hands Agents a Flow Definition's shape as a JSON Schema that includes your settingsSchema, and some JSON Schema validators misread such a property: they refuse, or throw, on a Flow that is valid.

Every name a Plugin contributes (Node Type, gate type, Resource Group type, Resource Group) must be unique across the Plugins a Host loads: the Plugin the Host saw first keeps a name, and a later one that repeats it is skipped with the reason.

Where a Host finds Plugins

A Host loads Plugins from two places:

  • The Plugin directory (<dataDir>/plugins unless pluginDir, FYFLOW_PLUGIN_DIR or --plugin-dir says otherwise) holds Local Plugins: plain folders you, or an Agent, put there and edit in place. A folder is a Plugin when it holds plugin.ts or plugin.js, or a package.json whose entry is the Plugin. Its own imports resolve from the Plugin directory's node_modules the normal Node way, so npm install its other dependencies there (or bundle them). Packages under node_modules are never Plugins themselves, whatever their name.
  • The Plugin store (<dataDir>/plugins-installed) holds the Plugins installed from the app (a version a Plugin repository lists, an uploaded .tgz, a .tgz URL, or a folder on the Host copied in), each version in a folder of its own, <id>/<version>-<hash8>/. What the app may install is the Host's plugins.install (fyflow.config.json, FYFLOW_PLUGINS_INSTALL or --plugins-install): off (nothing, the default in server Mode), repositories (only versions a configured Plugin repository lists) or any (also an upload, a URL or a folder, the default in the desktop app); a person confirms every install. The Plugin repositories are plugins.repositories (FYFLOW_PLUGINS_REPOSITORIES or --plugins-repositories, comma-separated): index.json URLs, the official index unless set. Each must be https, or http only on a loopback address (localhost, 127.0.0.0/8 or [::1], for a local mirror): over plain http anyone on the network could rewrite an index's artifact URLs and the integrity it pins together, so a Host refuses to start with a non-loopback http: index, naming it. Artifact URLs in an index may be http or https: the integrity a trusted index pins checks the download. The Plugins page browses them, and installing from one previews its index entry, so none of the Plugin's code runs before a person confirms. Only the Host writes there: to change an installed Plugin, copy it into the Plugin directory, where it is a Local Plugin. Make editable copy on its card does that where plugins.install is any: it copies the current version, without its node_modules, into <pluginDir>/<id>/ (refused when that folder is there), installs its dependencies there, and Reloads.

When a Local Plugin has the id of an installed one, the Local one loads and the installed one is listed as shadowed (its code is not loaded); it can still be Updated or Uninstalled from the app, and an id only a Local Plugin has can be installed, shadowed from the start. Once the Local one goes, the installed one loads again at the next Reload. A Local Plugin and an installed one alike may be TypeScript, erasable syntax only, as neither sits under a node_modules.

The SDK is the Host's to provide: it writes node_modules/fyflow-plugin-sdk above the Plugin directory and the store, in every Mode, re-exporting its own, unless you installed one there yourself. Changes take effect when the Host is told to Reload; an install from the app Reloads by itself.

npm dependencies

A Plugin installed from the app may have npm dependencies (and optionalDependencies, such as packages built per platform). The Host installs them while it stages the Plugin, before its code first runs, into the Plugin's own node_modules:

  • Ship an npm-shrinkwrap.json. Run npm shrinkwrap (it turns your package-lock.json into one) before npm pack: npm packs a shrinkwrap, never a package-lock.json. The Host installs exactly what it lists, with npm ci; a Plugin with dependencies and no shrinkwrap is refused, saying so.
  • Install scripts never run (--ignore-scripts): a dependency that builds or downloads something when installed (a native addon compiled by node-gyp, a postinstall download) does not work. Prefer one that publishes its builds as per-platform optionalDependencies, which npm picks for the Host's machine.
  • Dependencies stay JavaScript: Node does not strip types inside node_modules.
  • devDependencies and peers are left out (--omit=dev --omit=peer). The SDK, your peer, is always the Host's: a copy of it in your node_modules is removed.
  • Or bring them along. A tarball that already holds its node_modules (bundledDependencies) is used as it is and npm does not run, so it installs on a Host with no network or no npm. Bring all of them: a node_modules is taken as the whole set.

The Host runs npm ci --ignore-scripts --omit=dev --omit=peer --cache <dataDir>/npm-cache in the staged folder (with --no-audit --no-fund --no-update-notifier, which ask the registry and change nothing installed). Its npm is plugins.npm in fyflow.config.json (or FYFLOW_NPM, or --npm: npm's package folder, its bin/npm-cli.js, or a program), else the one bundled beside the Host when it has one, else npm on the PATH; it needs npm 8.0.0 or later, and the Plugins page shows the one in use, or why there is none. Without one, a Plugin with dependencies cannot be installed; one without still can. Your npm configuration (.npmrc: a registry, a proxy, a token) and the Host's environment reach npm unchanged; only its cache is the Host's. When npm fails, the install is refused with npm's last lines and nothing changes. Each installed version has a folder and a node_modules of its own, so an Update that changes a dependency's version takes effect at its Reload, with no restart.

A Local Plugin's dependencies are yours to install: in its own folder, Install dependencies on its card (where plugins.install is any; POST /api/plugins/install/dependencies/<folder>) runs npm install --ignore-scripts --omit=peer --cache <dataDir>/npm-cache there with the same npm, which writes your package-lock.json (or follows your npm-shrinkwrap.json), and removes a copy of the SDK it leaves; then Reload. A failure shows npm's last lines. Run npm install there yourself as well, as you would for any package; a Plugin folder's own node_modules resolves before the Plugin directory's.

Packing a Plugin

To publish a Plugin, pack it with fyflow-plugin pack, the bin this package ships (since 0.10.0), from the Plugin's folder or naming it:

npx fyflow-plugin pack [<plugin folder>] [--out <folder>] [--platform <platform>]...

npx finds it once the SDK is installed in the Plugin's project (npm install --save-peer above); elsewhere, npx -p fyflow-plugin-sdk fyflow-plugin pack. It runs with your Node (22.18, 23.6 or later to load a plugin.ts) and your npm, and in order:

  1. Checks the Plugin as far as it can on its own. Its package.json has a name, a version and a fyflow-plugin-sdk peer range a Host can read (^, ~, comparisons, x and ||; no hyphen ranges or pre-releases). Its code loads as a Host loads it (plugin.ts, else plugin.js, else the package's entry) and its default export is a definition with an id, a version equal to the package's, and a base URL. A Plugin with Python Workers has a python section whose project holds pyproject.toml and uv.lock. It stops on the first problem, saying what it is.
  2. Writes npm-shrinkwrap.json when the Plugin has dependencies or optionalDependencies (and does not bundle them): from your package-lock.json, with npm shrinkwrap, which renames it: from then on npm keeps the shrinkwrap current in its place. Without a lockfile it stops: run npm install first. An npm-shrinkwrap.json already there is packed as it is (with a warning when a package-lock.json sits beside it, which npm ignores).
  3. Runs npm pack, into --out (default: the current folder). Leave Python environments and bytecode (.venv, __pycache__) out with an .npmignore or files; it warns when the tarball holds them, and stops (removing the tarball) when it lacks the pyproject.toml or uv.lock of a Plugin with Python Workers.
  4. Prints the index entry for the tarball on stdout, as JSON, with its SHA-512 integrity. Fill in url with where you publish the tarball (a GitHub Release, npm, any URL), and submit the entry to a Plugin repository:
{
  "id": "example.word-counter",
  "version": "1.0.0",
  "sdk": "^0.8.0",
  "python": { "variants": ["cpu"] },
  "url": "",
  "integrity": "sha512-…"
}

python is false for a Plugin without Python Workers, else the variants its environment comes in (cpu, then the ones python.variants names). Progress and warnings go to stderr, so fyflow-plugin pack > entry.json keeps the entry alone.

A repository's index.json lists each Plugin once, with its versions: { "schema": 1, "plugins": [{ "id", "name", "description", "homepage"?, "icon"?, "versions": [{ "version", "sdk", "python", "url", "integrity", "platforms"?, "published"? }] }] }, where published is an ISO date and time the repository sets. A version may have artifacts: [{ "platforms", "url", "integrity" }] in place of url, integrity and platforms (below). A Host ignores keys it does not know (an entry's id beside its version included), and takes the whole index as malformed when a key it reads is wrong. It lists only the versions whose sdk range admits its SDK, and installs one only when the download (256 MB at most) matches integrity and holds that id and version.

Plugins for some platforms

Most Plugins are one tarball for every platform: their code is JavaScript or TypeScript, their npm dependencies are installed on the Host's machine (npm picks a dependency's per-platform builds there), and Python Workers' wheels are resolved per platform by uv from the one uv.lock. A Plugin that ships binaries of its own (an ffmpeg, a model runtime) has two ways to say where it runs. Platforms are spelled as Node spells them: <os> (process.platform: win32, darwin, linux), which means every cpu, or <os>-<cpu> (with process.arch: x64, arm64).

  • One tarball, some platforms. Set npm's own os and cpu in its package.json ("os": ["darwin", "linux"], "cpu": ["x64"], or "os": ["!win32"]). pack writes them into the entry as platforms (["darwin", "linux"]; with cpu, each <os>-<cpu>; an exclusion is read against darwin, linux and win32, x64 and arm64). A Host on another platform lists the Plugin, greyed, as "Not available for Windows (x64)", and never installs or offers that version. Installed by upload, URL or folder on such a Host, it is installed all the same, with a warning in its preview.

  • One tarball per platform. Build each platform's tarball on that platform (in CI, a runner each) and pack it with --platform, once per tarball, naming the platforms it is built for (repeatable: --platform darwin-arm64 --platform darwin-x64). Each must be one the package's os and cpu allow (a bare <os> is refused when cpu limits them). The tarball is named for them, <name>-<version>-<platform>.tgz (several joined by _), so the platforms' tarballs sit side by side in one release, and the entry printed is one artifact:

    { "id": "example.thumbnails", "version": "1.0.0", "sdk": "^0.10.0", "python": false,
      "artifacts": [{ "platforms": ["win32-x64"], "url": "", "integrity": "sha512-…" }] }

    Merge the platforms' entries into one version, its artifacts holding each platform's artifact, every one with its url filled in. No platform may be named in two artifacts of one version, nor a bare <os> in one and an <os>-<cpu> of it in another. A Host installs the first artifact that names its platform; a Host whose platform none names lists the version as not available.

  • Or put the binaries in per-platform npm packages, as esbuild and sharp do: a package per platform, each with its os and cpu, all listed in the Plugin's optionalDependencies. npm installs the one for the Host's machine, so the Plugin stays one tarball. Choose it when the binaries are worth publishing to npm on their own, or another project uses them too; otherwise per-platform tarballs keep everything in the Plugin's own release.

A tarball is at most 256 MB, whichever way: a Plugin with large binaries for several platforms ships one artifact per platform.

The rest is the Host's to check: the Node Types, Workers, schemas, gate and Resource Group types of the definition, and whether the peer range admits the SDK the Host implements. A Host checks all of it when it installs the tarball, and refuses it with the reason, before anything is installed. Before submitting, install the tarball into a Host (upload it on the Plugins page, with plugins.install set to any) and run a Task.

Publishing to the official repository

The official Plugin repository, fyflow/fyflow-plugins, is the index every Host reads unless configured otherwise (https://fyflow.github.io/fyflow-plugins/index.json): its Plugins are listed on the Plugins page, under Browse. A version gets there by a PR adding it to plugins/<id>.json, which its CI checks. A reusable workflow in that repository does all of it from a tag in yours: it packs the Plugin, creates a GitHub Release with the tarball, and opens the PR.

What belongs there: Plugins that do real work, not examples, demos or tests. An id is permanent once a version is published under it, since Flows record the Plugins they use by id: namespace yours (acme.tagger), and choose it before the first release. A published version never changes either: to fix one, release the next.

Layout

One Plugin per repository, at its root:

acme-tagger/
  .github/workflows/release.yml
  package.json            "version": "1.0.0", peerDependencies: { "fyflow-plugin-sdk": "^0.10.0" }
  package-lock.json       or pnpm-lock.yaml
  plugin.ts               id "acme.tagger", version "1.0.0"
  tag.worker.ts

Or several, each in a folder of plugins/, each with its own package.json (a workspace at the root, or a lockfile per Plugin):

acme-plugins/
  .github/workflows/release.yml
  package.json            the workspace: no fyflow-plugin-sdk peer
  pnpm-lock.yaml
  plugins/
    tagger/   package.json, plugin.ts    id "acme.tagger"
    resizer/  package.json, plugin.ts    id "acme.resizer"

A tag names one Plugin and its version: <id>@<version> ([email protected]; the id's dots are fine in a tag). The workflow takes the folder whose definition has that id and version, so package.json, the definition and the tag must all say the same version.

The workflow reads the Plugin's fields in the index from its package.json: description, and homepage (else the GitHub repository). Under a "fyflow" key it reads name (the name people see; default: the package name) and icon (one of the icons). A Plugin built before it is packed adds build, the command that builds it, and pack, the folder that is packed, which holds its own package.json:

{ "fyflow": { "name": "Tagger", "icon": "tag", "build": "pnpm build", "pack": "dist" } }

The workflow

Add .github/workflows/release.yml:

name: Release
on:
  push:
    tags: ["*@*"] # <id>@<version>
jobs:
  release:
    permissions:
      contents: write # the GitHub Release
    uses: fyflow/fyflow-plugins/.github/workflows/release-plugin.yml@v1
    secrets:
      FYFLOW_PLUGINS_TOKEN: ${{ secrets.FYFLOW_PLUGINS_TOKEN }}
    # with:
    #   platforms: win32-x64 darwin-arm64,darwin-x64 linux-x64   # one tarball per platform group (below)

And add the secret: in your repository's Settings → Secrets and variables → Actions, FYFLOW_PLUGINS_TOKEN, a classic personal access token with the public_repo scope (a fine-grained one cannot open a PR on another owner's repository). The workflow forks fyflow-plugins with it and opens the PR. Without the secret it makes the Release, prints the entry in the run's summary, and leaves the PR to you.

Then release with git tag [email protected] && git push origin [email protected]. The run's summary links the PR; watch its checks with gh pr checks <url> --repo fyflow/fyflow-plugins --watch. Its admit check must pass, and a maintainer merges it. Once Pages has deployed, the version is in the index.

For a Plugin with one tarball per platform, set platforms: each group is packed with pack --platform on a runner of that os, and the artifacts are merged into one version (see Plugins for some platforms).

A second reusable workflow checks on every push that each Plugin of the repository packs:

name: CI
on: [push, pull_request]
jobs:
  pack:
    uses: fyflow/fyflow-plugins/.github/workflows/check-plugin.yml@v1

The workflows install your dependencies (pnpm install --frozen-lockfile with a pnpm-lock.yaml, npm ci with a package-lock.json, or the command you give as install), and pack with Node 26 (node-version). If your Plugin has npm dependencies, keep a package-lock.json beside it even when you develop with pnpm (npm install --package-lock-only writes it): pack turns it into the npm-shrinkwrap.json a Host installs from. Write it the normal way, with the SDK installed as a peer: a lockfile written with --legacy-peer-deps leaves the SDK out, and npm ci then refuses it, on a Host too.

Test it in a Host before you tag

npx fyflow-plugin pack --out dist, then in a Host with plugins.install set to any (the desktop app's default), upload dist/<name>-<version>.tgz on the Plugins page, confirm the install, and run a Task of each Node Type. The repository's CI loads the Plugin and checks its entry, but it runs no Task.

Python Plugins

There are no pure-Python Plugins: a Python Plugin is always a small JS module, plugin.ts and package.json, naming its Python project (see Python Workers), since the Host reads every definition from JS. So it packs and publishes the same way, and its author needs Node only to pack, which the workflow does for them. Ship the project, its pyproject.toml and uv.lock, and leave out the environment, bytecode and tests with a files list:

{
  "files": [
    "plugin.ts",
    "python/pyproject.toml",
    "python/uv.lock",
    "python/acme_tagger/",
    "!**/__pycache__"
  ]
}

What fyflow-plugins' CI checks on each PR (the schema, that published versions never change, the download and its integrity, the package against the entry, loading the Plugin as a Host does, and that its Node Type and gate type names are not another Plugin's) is in its README.

Peer range

Declare the SDK versions your Plugin works with as a peer dependency:

{ "peerDependencies": { "fyflow-plugin-sdk": "^0.5.0" } }

A Host implements its SDK version (SDK_VERSION, exported here) and the earlier ones it is compatible with (SDK_COMPATIBLE). It skips, with the reason, a Plugin whose range excludes them all. 0.5.0 changed what Profiles are, so it is compatible with no earlier version: a Plugin written for 0.4 or before is skipped until it is upgraded, as UPGRADING.md describes. 0.6.0 to 0.10.0 only add to 0.5.0, so a Host on 0.10 loads a Plugin declaring ^0.5.0, ^0.6.0 and so on up to ^0.10.0. From 0.5 on, raise the range only for what a Host must do at run time: a Plugin that asks for a data directory says ^0.6.0, since a Host on 0.5 would load it and give it none.

Icons

A Node Type's icon names one of the app's icons, from Lucide: activity, archive, bell, bot, box, braces, brain, calculator, calendar, camera, chart-bar, check, clock, cloud, code, cpu, credit-card, database, download, file, file-text, filter, flag, folder, git-branch, globe, hash, image, key, link, list, lock, mail, map-pin, message-square, package, pen-tool, play, plug, repeat, search, send, server, settings, shield, shopping-cart, signature, sparkles, table, tag, terminal, timer, trash, upload, user, users, wand-sparkles, webhook, zap. A name outside the set is not an error: the Node Type shows its Kind's icon, and the Plugins page shows a warning.

Workers

A Worker module's default export is a class. The Host builds each instance (one per worker of the pool: a thread, or on an inline pool an object on the Host's own thread) with the start-up fields its Node Type takes, from the Profile the pool is for, and reuses it across Tasks, so keep per-Task state out of fields. run gets the Task's envelope (the Node's settings and the Task input) and a context; what it returns is the Task's result, and throwing fails the Task with the error's message. Since 0.3.0 the SDK types all of it:

import type { TaskEnvelope, Worker, WorkerContext } from "fyflow-plugin-sdk";

type Config = { key?: string };
type Settings = { field?: string };

export default class Sign implements Worker<Settings> {
  readonly #config: Config;

  constructor(config: Config) {
    this.#config = config;
  }

  async run({ settings, input }: TaskEnvelope<Settings>, context: WorkerContext) {
    if (this.#config.key === undefined) throw new Error("No key: set `key` on this Node's Profile");
    context.sendProgress(0, "signing"); // a fraction from 0 to 1, not a percentage
    context.signal.throwIfAborted(); // aborted when the Task is Cancelled
    const value = settings.field === undefined ? input : (input as Record<string, unknown>)[settings.field];
    return { signed: await hmac(this.#config.key, value) }; // hmac: your own signing function
  }
}

WorkerClass<Config, Settings, Input, Output> types the class itself, constructor included. Check context.signal between steps, or pass it to anything that takes one (fetch, streams): a Worker that stops promptly frees its slot sooner, and what it returns after a Cancel is discarded. Use import type, so a Worker module needs nothing from the SDK at run time. The context offers what the Host shows (progress) and Cancel; fyflow's scheduler passes more, but the SDK promises only these.

A Worker's lifecycle

Since 0.5.0 the SDK also promises what happens to an instance between Tasks, for a Worker that owns something long-lived (a child process, a connection, a loaded model):

  1. Built: new Worker(config, pool), where config is its start-up fields and pool a PoolContext.
  2. setup(), if it has one: once, awaited before its first Task. Start what it owns here, not in the constructor.
  3. run, for any number of Tasks.
  4. teardown(), if it has one: once, when the pool retires the instance. That is when it has been idle past its pool's idleTimeout (the pool checks every 5 seconds, so up to 5 seconds later), when it asked to be replaced, and when its Runtime shuts down: the Host stopping, or after a Reload once the last Run on the replaced Runtime ends.
import type { PoolContext, Worker } from "fyflow-plugin-sdk";

export default class Thumbnails implements Worker {
  readonly #pool: PoolContext | undefined;
  #service: Service | undefined; // Service, start: your own

  constructor(_config: object, pool?: PoolContext) {
    this.#pool = pool;
  }

  async setup() {
    // Port 0: another copy of this Worker may be running (see below).
    this.#service = await start({ port: 0, onExit: (error) => this.#pool?.terminateWithError(error, { canRestart: true }) });
  }

  async teardown() {
    await this.#service?.stop(); // undefined when setup() did not get that far
  }

  async run() {
    return this.#service?.thumbnail();
  }
}

What to rely on, and what not to:

  • A setup() that throws fails every Task the instance is given, with its message, and its teardown() runs at once. It is not tried again on that instance: the pool builds a new one only once it retires this one, at a Reload, or once no Task has reached it for its idleTimeout, which a steady stream of Tasks never allows. So a setup() that may fail for a passing reason (a port taken, a service still starting) should retry within itself before it throws, and teardown() must cope with a setup() that never finished.
  • pool.terminateWithError(error, { canRestart: true }) asks the pool to replace the instance, say after the process it owns dies. It returns at once and never throws, so call it from a callback or a timer. The instance's teardown() runs, its Tasks still running go back in the queue to run again on a new instance, and that one is built and set up. With canRestart: false those Tasks fail instead. The pool replaces instances only so many times (3, fyflow-scheduler's maxWorkerRestarts); after that it builds no more, and its Tasks wait until a Reload. So ask when the instance is broken, not because a Task failed: throw from run for that.
  • Never assume one copy. Each Profile has its own pool, a pool may hold several instances (up to its maxThreads), and after a Reload the replaced Runtime's instances keep running its Runs while the new Runtime sets up its own. Bind port 0, not a fixed port, and keep anything shared outside the Worker.
  • idleTimeout defaults to 5 seconds. For a Worker costly to set up, give its Node Type's pool a longer one (pool: { idleTimeout: 60_000 }); a Profile may override it for that Node Type. 0 keeps an instance until its Runtime shuts down.

A Plugin's data directory

A Plugin that keeps state beyond one Host process (a cache its service requires, a record of a ban to wait out) sets dataDir: true (since 0.6.0). The Host then gives it a folder of its own, plugin-data/<plugin id>/ under the Host's data directory, and creates it before it builds the Plugin's Workers. Each Worker of the Plugin finds its absolute path in its config, as host.dataDir, on an inline pool or a threaded one alike:

import { join } from "node:path";
import process from "node:process";
import type { HostConfig, Worker } from "fyflow-plugin-sdk";

// `dataDir` is there whenever the Plugin sets `dataDir: true`.
type Config = { host: HostConfig & { dataDir: string }; ttlHours?: number };

export default class Lookup implements Worker {
  readonly #cache: string;

  constructor(config: Config) {
    this.#cache = join(config.host.dataDir, "answers.sqlite");
  }
  // …
}
  • The folder is kept across Reloads, updates, and uninstalling then installing again. The app never deletes it: to start afresh, delete it by hand while the Host is stopped.
  • So that the path has a place in the config, a Plugin that asks for one may not have a start-up field named host; the Host skips it with the reason. Nor may a Plugin whose peer range admits 0.11.0, whose Workers find their pool options there too (host.pool), and a Python Plugin's Workers their build (host.variant). Any other Plugin that does not ask gets no host in its config, and its own host field is its own.
  • Several instances, and the Runtime a Reload replaced, may run at once: write to the folder as a shared place (a SQLite database, a file replaced atomically), not as one Worker's own.

Allowed roots: the folders a Task may touch

A Plugin that reads or writes files a Flow names sets roots: true (since 0.11.0) to take the Host's Allowed roots (ADR-0030). A person sets them on the Profiles page: the Profile's, once for every Plugin that takes them, and your Plugin's own in its section, where they may also leave the Profile's out. Each Task then carries its reach, the folders it may touch with read or write access (write grants read), at settings.roots. Only a Profile grants roots: the Host writes them over anything a Flow puts there, and your Workers may not name roots in their settingsSchema.

The Host has no sandbox, so your Worker keeps to the reach by checking every path it is given, before it touches it:

import { allowedPath, type TaskEnvelope, type Worker } from "fyflow-plugin-sdk";

export default class Hash implements Worker<{ path?: string }, { path: string }> {
  async run({ settings, input }: TaskEnvelope<{ path?: string }, { path: string }>) {
    // Where the path really leads, or a throw saying why it is refused.
    const file = await allowedPath(settings.roots, settings.path ?? input.path, "read");
    // … read `file`
  }
}
  • allowedPath(reach, path, access, { unfollowed? }) answers where path really leads, links followed, so a link inside a root that points out of it is refused like any path outside; a part that does not exist yet is joined onto its nearest existing parent, so a path to write is checked before it is made. On Windows, case and slash direction do not matter. It refuses a relative path, every path while the reach has a relative root, and every path while the reach is empty, saying to add a root on the Profiles page. With unfollowed, a link at the path's end is checked where it stands: what deleting a link removes.
  • refuseRoots(reach, path, doing) refuses a path that is an Allowed root or holds one, compared by identity on disk: call it before you move or delete something, so a Flow never takes away a folder it was given.
  • A Python Worker has the same checks from the Host, as ctx.allowed_path(path, access="read") and ctx.refuse_roots(path, doing) (see Python Workers).

Python Workers

A Worker may be written in Python (since 0.8.0, ADR-0026). The Plugin stays a JS module: its definition, schemas, Node Types, groups and status() are as for any Plugin. It names a Python project, and for each Python Worker a class in it instead of a url. The Host brings the rest: uv, a Python, the Plugin's environment, and the process each Worker instance runs in. fyflow-app's examples/plugins/word-counter is a whole one to start from.

From pyproject.toml to a Run

Take a Plugin that tags images with a model kept loaded on the GPU between Tasks. Its folder:

acme-tagger/
  package.json          peerDependencies: { "fyflow-plugin-sdk": "^0.9.0" }
  plugin.ts
  python/
    pyproject.toml
    uv.lock
    acme_tagger/
      __init__.py
      tag.py
  1. The project. python/pyproject.toml is an ordinary uv project: its dependencies are what the Worker imports, and requires-python which Python uv downloads for it. Run uv lock in python/ and ship uv.lock beside it: the Host syncs from the lock alone (uv sync --frozen), so a Plugin without one is not loaded, and its error says to run uv lock. The project itself is not installed: its folder is on the Python path, so acme_tagger is imported from it as it is.

    [project]
    name = "acme-tagger"
    version = "1.0.0"
    requires-python = ">=3.12"
    dependencies = ["pillow>=11", "transformers>=4.50"]

    torch is not among them: it comes in a CPU and a CUDA build, which the project declares as extras, cpu and cuda (see "GPU builds" below).

  2. The Plugin. python names the project and its builds (variants); a Worker's python names its class, module:Class. The Node Type's pool is the Worker's, as for a JS Worker's:

    import { definePlugin, GPU_GROUP } from "fyflow-plugin-sdk";
    
    export default definePlugin({
      id: "acme.tagger",
      version: "1.0.0",
      base: import.meta.url,
      python: { project: "./python", variants: { cpu: ["cpu"], cuda: ["cuda"] } },
      profile: {
        schema: { type: "object", properties: { threshold: { type: "number" } }, additionalProperties: false },
        defaults: { threshold: 0.5 },
        startup: ["threshold"],
      },
      workers: { tag: { python: "acme_tagger.tag:Tag" } },
      nodeTypes: {
        Tag: {
          worker: "tag",
          profile: ["threshold"],
          // One process with the model loaded, four Tasks at once in it, holding the GPU while it lives and for
          // 10 minutes after its last Task.
          pool: { maxThreads: 1, maxConcurrentTasks: 4, idleTimeout: 600_000, residentGroups: [GPU_GROUP] },
        },
      },
    });
  3. The Worker. The class is built once per Worker instance, in a Python process of its own, with the config a JS Worker's constructor gets (the start-up fields, and host when the Host gives the Plugin anything), and, if its __init__ takes a second argument, its pool (since 0.9.0: pool.grants, the units it holds, below). setup() loads what every Task uses, once per process; run() does one Task. Each of them, and teardown(), may be plain or async def; setup() and teardown() are optional:

    import threading
    
    import torch
    from PIL import Image
    
    class Tag:
        def __init__(self, config, pool):
            self.threshold = config.get("threshold", 0.5)
            self.gpu = pool.grants.get("gpu", [0])[0]  # the GPU this process holds while it lives
    
        def setup(self):
            from transformers import pipeline
            # On that GPU with the `cuda` variant's torch, else on the CPU with the `cpu` one's.
            device = f"cuda:{self.gpu}" if torch.cuda.is_available() else "cpu"
            self.model = pipeline("image-classification", device=device)  # once per process
            self.lock = threading.Lock()
    
        def run(self, task, ctx):
            ctx.progress(0.1, "reading")
            image = Image.open(task.input["path"]).convert("RGB")  # up to four Tasks do this at once
            with self.lock:  # and take turns on the model
                labels = self.model(image)
            return {"tags": [l["label"] for l in labels if l["score"] >= self.threshold]}
    
        def teardown(self):
            del self.model
  4. The Run. Put the folder in the Host's Plugin directory (the Host provides the SDK beside it) and Reload. The Plugins page shows a line under the Plugin: Preparing (uv's current step) while the Host makes its Python environment, then Ready (the Python and the variant), or Failed (uv's last lines, and a Retry). The first sync downloads a Python and the locked packages; later ones reuse uv's cache, shared by every Plugin. A Task started meanwhile waits for it. Tag is in the palette like any Node Type; its Tasks get task.settings (with profile) and task.input as a JS Worker's do.

The Host syncs once per Plugin, variant and lock each time it starts; a Reload syncs again only a Plugin whose pyproject.toml or uv.lock changed, or whose sync failed. It uses its own uv (bundled with the packaged Host and the desktop app; in server Mode python.uv in fyflow.config.json, else the PATH's), and a Python uv downloads, never the machine's own. A Host with no uv skips the Plugin, saying so. Only a Plugin with Python Workers has a Python environment.

GPU builds: variants and pickVariant

A package often comes in a CPU build and a GPU build. variants maps a build's name to extras of the project. Without variants there is only cpu, with no extras; a cpu with extras must be listed.

The Plugin picks which build this machine runs (since 0.11.0): pickVariant({ platform, arch }) returns one of its builds, and the Host syncs that one. It is called before the sync, at the Host's start and at each Reload. Without it, the build is cpu. One that throws, returns a build the Plugin does not have, or takes over 10 s gets cpu, and the Plugin's card on the Plugins page says why. Only the Plugin knows what its builds need (a driver, its version, an OS), so detecting the machine is its own, and it may import its own npm dependencies for it. The Host's python.accelerator setting, which picked the build for every Plugin before 0.11.0, is no longer used.

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";

// An example, not an API: an NVIDIA driver is there (Linux: its version file or libcuda; Windows: nvcuda.dll).
function hasNvidiaDriver(platform: string): boolean {
  if (platform === "linux") {
    if (existsSync("/proc/driver/nvidia/version")) return /NVIDIA/.test(readFileSync("/proc/driver/nvidia/version", "utf8"));
    return ["/usr/lib/x86_64-linux-gnu/libcuda.so.1", "/usr/lib64/libcuda.so.1"].some(existsSync);
  }
  if (platform === "win32") return existsSync(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "nvcuda.dll"));
  return false;
}

python: {
  project: "./python",
  variants: { cpu: ["cpu"], cuda: ["cuda"], directml: ["directml"] },
  pickVariant: ({ platform }) => (hasNvidiaDriver(platform) ? "cuda" : platform === "win32" ? "directml" : "cpu"),
},

The detection above is an example, not an API: write the checks your own builds need.

A person may set another build for some Nodes, or for all: a Profile's section of a Python Plugin sets its build (the Default Profile's for every Node but those of a Profile setting its own), over your pick. The Host syncs every build some Profile runs, and each pool's Workers run in their Profile's build, so one Flow can run a Node on the GPU and another on the CPU. A build you do not have falls back, with a warning on the Profiles page.

Each Worker reads the build it runs in at host.variant (since 0.11.0, under the same opt-in as host.pool: your peer range admits 0.11.0), to pick what it runs on, such as ONNX execution providers:

import onnxruntime

class Tag:
    def __init__(self, config, pool):
        build = config["host"].get("variant", "cpu")
        providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] if build == "cuda" else ["CPUExecutionProvider"]
        self.session = onnxruntime.InferenceSession("model.onnx", providers=providers)

A driver that is there but too old, or broken, still fails when a session starts: keep a fallback to the CPU in your Worker too.

uv sync does not detect a machine's GPU (only uv pip has a torch backend), so torch's builds are the Plugin's to name: declare each extra's index in your own pyproject.toml, and mark the extras as conflicting, since one environment holds one torch:

[project.optional-dependencies]
cpu = ["torch>=2.7"]
cuda = ["torch>=2.7"]

[tool.uv]
conflicts = [[{ extra = "cpu" }, { extra = "cuda" }]]

[tool.uv.sources]
torch = [
  { index = "pytorch-cpu", extra = "cpu" },
  { index = "pytorch-cu128", extra = "cuda" },
]

[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true
python: { project: "./python", variants: { cpu: ["cpu"], cuda: ["cuda"] } },

uv lock locks both; each Host syncs one, and keeps an environment per variant and lock.

A Node Type's pool may differ per build (since 0.11.0): pool.variants maps a build to what it changes of the pool. A numeric option (maxThreads, maxConcurrentTasks, idleTimeout, cancelGraceMs) replaces the base pool's, which must set it too; residentGroups replaces the base's whole, {} for none. So the cpu build of a Node Type holding the GPU need not hold it, and holds more cpu instead:

pool: {
  maxThreads: 1,
  idleTimeout: 600_000,
  residentGroups: { gpu: 1, cpu: 1 },
  variants: { cpu: { residentGroups: { cpu: 4 } } },
},

Each pool takes its own build's: a Profile setting another build for its Nodes gets that build's pool, with the Profile's pool options over it. A key that is not one of the Plugin's builds skips the Plugin with the reason.

Concurrency

  • Each Worker instance is one Python process, driven from a thread of the Host's own: maxThreads counts processes, so maxThreads: 1 loads the model once. A Node Type on a Python Worker may not be inline.

  • maxConcurrentTasks is how many Tasks run at once in one process. A plain def run runs each on a thread of its own, so what they share (a model, a cache) needs a lock unless it is safe to share. A lock held for all of run makes them take turns, so hold it only around what must not overlap, as Tag does; an async def run runs them all on one event loop, so it must never block it (use await asyncio.to_thread(…) for blocking work).

  • residentGroups is held for the process's life, from setup() until it is retired, as for a JS Worker ("Holding a group while a Worker lives").

  • Which units it holds (since 0.9.0, "Which units a Worker holds"): a class whose __init__ takes a second argument gets its pool, whose grants are the process's resident units, {"gpu": [1]}; one taking the config alone is built as before. Each Task's ctx.grants are its own, its process's included. The Host sets nothing in the process's environment, such as CUDA_VISIBLE_DEVICES: pass the device to your library (cuda:1).

  • A Plugin with roots: true (since 0.11.0, "Allowed roots") finds its Task's reach at task.settings["roots"], and checks a path with ctx.allowed_path(path, access="read", unfollowed=False), which answers where it really leads or raises a PermissionError saying why it is refused, and ctx.refuse_roots(path, doing) before moving or deleting it. They are the JS checks, rule for rule and message for message:

    def run(self, task, ctx):
        image = ctx.allowed_path(task.input["path"])  # raises PermissionError outside the reach
        return self.tag(image)

Cancel

  • A Cancel stops that Task only: ctx.cancelled turns true, and run is interrupted, by KeyboardInterrupt in its thread or by CancelledError in an async def run. What it returns afterwards is discarded. Other Tasks in the process go on.
  • Python is interrupted only between its own steps: a long call into native code (a model's forward pass) finishes first. A long loop of your own may check ctx.cancelled and stop early.
  • A Task still running 10 s after its Cancel (the Node Type's pool.cancelGraceMs, 10 s for Python Workers) is forced: the process is killed, its other Tasks run again on a fresh one, and the model loads again.

Logs

  • ctx.log(message) and ctx.progress(fraction, message=None) reach the Task: its Run Events, and the Run's page. Progress is sent at most 10 times a second.
  • What the process writes to stdout or stderr (print, logging, a library, a child process) goes to the Host's log, tagged with the Plugin and Worker, and the Plugins page keeps each Python Worker's last 200 lines under "Python output" (Agents read them with get_python_output). It never reaches a Task: with several Tasks in a process, a line cannot be told apart by Task. Profile secrets are redacted from it.
  • run raising fails the Task with the exception's message, and its traceback goes to the Host's log.
  • A __init__ or setup() that raises fails the Task waiting for it with the exception's message, and the process ends. Unlike a JS Worker's, the next Task starts a fresh process, so a cause that has gone away (a file now there, a service now up) needs no Reload. A process that exits while Tasks run has them run again on a fresh one, and its exit code and last stderr line go to the Host's log.

What not to do

  • Do not catch BaseException (or a bare except:) around work a Cancel must interrupt: it swallows KeyboardInterrupt and CancelledError, and the Task runs on until it is forced. Catch Exception.
  • Do not install packages at run time (pip install, uv add): the environment is made from the lock, shared, and replaced when the lock changes. Add them to pyproject.toml and run uv lock.
  • Do not write into the project or the environment. Ask for a data directory (dataDir: true) and write under config["host"]["dataDir"].
  • Do not read stdin, or write the protocol yourself: stdin and stdout are the Host's channel to the process. Anything printed goes to the log instead, so it is harmless, but it is not a Task's output: return it, or ctx.log it.
  • Do not pass secrets in environment variables: a Worker gets them as Profile fields (writeOnly), which the Host redacts.
  • Do not return a big result: one over 16 MiB fails the Task. Write it to a file in the data directory and return its path.
  • Do not leave processes behind on purpose: when the Worker's process ends, however it ends, so does every process it started.

A Plugin's status

A Plugin that keeps something running between Tasks (a connection, a session, a queue behind a rate limit) can say how it is with status() (since 0.6.0). The Host shows the answer on the Plugin's card on its Plugins page, asking again every 5 seconds while the page is open, and gives it to Agents. A Plugin without status() shows none.

import { definePlugin } from "fyflow-plugin-sdk";
import { session } from "./session.ts"; // your own module, shared with the inline Workers

export default definePlugin({
  // …
  status: () =>
    session.blockedUntil === null
      ? { level: "ok", summary: `Logged in · ${session.queued} queued` }
      : {
          level: "warning",
          summary: "Blocked after a ban",
          details: [{ label: "Blocked until", value: session.blockedUntil.toLocaleTimeString() }],
        },
});
  • level is ok, info, warning or error; summary is one line; details are optional label and value pairs. It is data: the Host draws it, so a Plugin still ships no UI code.
  • It runs on the Host's own thread, where the Host loaded the Plugin's module. It sees what that module shares with inline Workers, not what threaded Workers hold.
  • Answer within a second. A status() that throws, rejects, answers with something else, or takes longer shows as an error status with the reason. While one answer is still pending, the Host does not ask again.
  • It is handed { dataDir } when the Plugin asks for a data directory, and {} otherwise.
  • The Host redacts its secrets from the answer, as it does from errors, but do not put one there.

The disk, cpu and gpu groups

The Host's built-in Plugin declares three Resource Groups any Plugin's Node Types may name, each a limit a Host can override on its Resource Groups page:

  • disk (exported as DISK_GROUP since 0.3.0), on Tasks that read or write the Host's disks. It is 4. Name it when your Tasks do heavy disk work (copying, transcoding, indexing), so they share one budget with the Host's own file system Node Types instead of thrashing the disk beside them.
  • cpu (exported as CPU_GROUP since 0.4.0), on Tasks that keep a processor busy (hashing, encoding, image work). It is the Host's logical processor count, so such Tasks from every Plugin together take no more processors than there are.
  • gpu (exported as GPU_GROUP since 0.7.0), on Tasks that use a GPU (inference, rendering, GPU encoding). Its units are the Host's GPUs, numbered 0 to n-1 as CUDA numbers devices: 1 unit, since a Host cannot count its GPUs, until a person sets their number on a machine with more. Since 0.9.0 a Task or Worker holding one is told which (see Which units a Worker holds). Before, it only counted, and the Plugin chose each Task's device.

A Task that does both names both:

import { CPU_GROUP, DISK_GROUP, definePlugin } from "fyflow-plugin-sdk";

export default definePlugin({
  // …
  nodeTypes: { Transcode: { worker: "transcode", groups: [DISK_GROUP, CPU_GROUP] } },
});

Other Plugins' groups stay out of reach, so Disabling one Plugin never breaks another. The keys are values, so a Plugin that imports them needs the SDK at run time; writing "disk", "cpu" or "gpu" instead works on any Host that has the group. Hosts before SDK 0.4.0 have no cpu and skip a Plugin naming it, so a Plugin that names it says ^0.4.0 in its peer range; the same goes for gpu and ^0.7.0.

Holding a group while a Worker lives

groups holds a unit of each group for one Task, from its start to its end. A Worker that holds something for as long as it lives, such as a model it keeps loaded in a GPU's memory between Tasks, names the group in its Node Type's pool.residentGroups instead (since 0.7.0): the pool takes the units when it builds the instance and gives them back when the instance is retired (idle past its idleTimeout, a Reload, or the Host stopping). Another pool builds an instance only while what it needs still fits, and its Tasks wait meanwhile: up to the holder's idleTimeout and 5 seconds more after the holder's last Task. So a Node Type holding a group resident needs an idleTimeout above 0; the Host refuses one of 0, from the Plugin or a Profile.

import { definePlugin, GPU_GROUP } from "fyflow-plugin-sdk";

export default definePlugin({
  // …
  groups: { "imagegen.vram": { type: "ConcurrentLimitGroup", config: { limit: 24 } } }, // GB
  workers: { model: { url: "./model.worker.ts" } },
  nodeTypes: {
    // Keeps its model loaded for 10 minutes after its last Task, holding the GPU and 16 GB of its memory meanwhile.
    Generate: {
      worker: "model",
      pool: { maxThreads: 1, idleTimeout: 600_000, residentGroups: { [GPU_GROUP]: 1, "imagegen.vram": 16 } },
    },
  },
});

residentGroups is a list of keys (a unit of each) or keys with how many units each holds (since 0.9.0, or which: Which units a Worker holds), and may name the same groups groups may: the Plugin's own and disk, cpu and gpu. A Host refuses a Plugin naming any other. Units are held per instance, so a pool with maxThreads: 2 takes two; and each Profile has its own pool of a Node Type, so a Node Type that Tasks run under two Profiles holds units for each. Keep maxThreads at 1 for a Worker that holds a whole device, and a Host that runs it under several Profiles needs as many units as it has pools alive at once.

Profiles change what a Worker holds

A Profile may set residentGroups for a Node Type next to its other pool options, so how many units it holds fits the machine rather than one number you picked for every machine: { cpu: 8 }, or a pin, { gpu: { from: [1] } }. Each field it leaves out falls back to your declaration's, field by field: a Profile setting cpu: 8 keeps a from you declared. It may add a group in your reach (a gpu only with a warning shown to the person, since your code may not use it), and never removes one you declared. The Host checks it as it checks your declaration.

So read what your Worker holds rather than repeating your number: since 0.11.0 each Worker of a Plugin whose peer range admits 0.11.0 finds the pool options it runs under in its config, as host.pool, with residentGroups always in one form, { <group>: { cost, from?, prefer? } }:

import type { HostConfig, Worker } from "fyflow-plugin-sdk";

export default class Classify implements Worker {
  readonly #threads: number;

  constructor(config: { host?: HostConfig }) {
    // As many threads as `cpu` units this Worker holds: what fyflow booked for it. Absent on a Host before 0.11.0.
    this.#threads = config.host?.pool?.residentGroups?.cpu?.cost ?? 1;
  }
  // …
}

host.pool holds maxThreads, maxConcurrentTasks, idleTimeout and residentGroups, each only when your Node Type or its Profile sets it (absent means fyflow's default), and is fixed for the Worker's life: a Profile's change takes effect at the next Reload. A batching Worker sizes its batches by maxConcurrentTasks the same way. Which units of a group that names them it got is in grants, below. In Python, config["host"]["pool"].

Which units a Worker holds

Since 0.9.0, a group that names its units, such as gpu (a fyflow UnitGroup), tells whoever holds one which it is. grants maps each such group's key to the units held, always a list: { gpu: [1] } for the second GPU. A group that only counts grants nothing and is absent from it.

  • A Worker's resident units are in its PoolContext, the constructor's second argument, as grants. They are there from the constructor on, so setup() loads onto that device. A replacement instance may get another.
  • A Task's units are in its WorkerContext, run's second argument, as grants: those its Node Type's groups took for it, and its Worker's resident ones, its own first where both name a group. They are granted afresh for each attempt, so read them in each run and never keep them.
import type { PoolContext, Worker, WorkerContext } from "fyflow-plugin-sdk";

export default class Model implements Worker {
  readonly #device: string;

  constructor(_config: object, pool?: PoolContext) {
    const [gpu = 0] = pool?.grants?.gpu ?? [];
    this.#device = `cuda:${gpu}`; // with maxThreads: 2 on a two-GPU Host, one instance gets 0 and the other 1
  }

  async setup() {
    // load the model onto this.#device
  }

  async run(envelope, context: WorkerContext) {
    // …
  }
}

A Node Type may also ask for particular units in pool.residentGroups, with a request in place of a number:

| Want | residentGroups | | --- | --- | | any GPU | [GPU_GROUP], or { [GPU_GROUP]: 1 } | | only GPU 1 | { [GPU_GROUP]: { from: [1] } } | | GPU 0 or 1, whichever is free | { [GPU_GROUP]: { from: [0, 1] } } | | both, together | { [GPU_GROUP]: { cost: 2, from: [0, 1] } } | | GPU 1 if free, else any | { [GPU_GROUP]: { prefer: [1] } } |

A request is judged against the Host's groups as they are: a Host whose gpu has fewer units than the request names skips the Plugin, saying which unit it lacks. Ask only for what the model needs (two cards, say), since a Plugin cannot know which GPUs a Host has. A Host before 0.9.0 refuses a request, so a Plugin using one says ^0.9.0.

Profiles and secrets

A Plugin's configuration lives on the Host, not in Flows: a Profile is a named configuration with a section for each Plugin, edited in the Host's UI with a form built from the Plugin's profile.schema. Every Host has a Default Profile. Declare your fields once, in profile, and list on each Node Type the ones it takes: a field several Node Types take is set once, for all of them. A field no Profile sets is its defaults value.

A start-up field (listed in profile.startup) is read when a Worker starts: the start-up fields a Node Type takes are its Workers' config, the constructor's argument, and a change takes effect at the Host's next Reload. Use one for what a Worker sets up once: a connection, a loaded model.

Every other field is a per-Task field: the Host reads it as each Task is dispatched and hands the ones the Node Type takes to run as settings.profile ({} when it takes none), so a change reaches the next Task with no Reload. Type them with TaskEnvelope<Settings, Input, ProfileFields>. Your settingsSchema may not name profile. List in profile.profileOnly the per-Task fields only a Profile may set: a grant, such as the folders Tasks may reach, beside a field a Flow may set to narrow it, which your Worker checks against the grant. Per-Task fields never reach a Run's records, but what run returns is recorded, so do not return them.

Mark a property of profile.schema writeOnly: true to make it a secret: the Host never shows it again, only whether it is set, and redacts its value from errors and logs. Only properties of the schema can be secrets.

Versioning

This package follows semver. SDK_VERSION always equals the package version.