Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

Processes — every run of a business process, listed and read from start to now (Processes kit)

use Osysharp.Processes; · class X : Process<TItem> { Name, Workflow, Graph, TitleOf, HistoryOf, StuckAfter } · app.Processes = new ProcessesSetup { Processes = […], RunPage = "/processes/{run}" } · policy FollowsProcesses => … · ProcessRuns() · ProcessRunInspector(run) · Processes.Note/Waiting/Done/Problem(said) · SetProcessBudget(workflow, perRun) · ProcessRunsNow(filter) · ProcessRunOf(id) · ProcessRunAbout(workflow, item)

Osysharp.Processes makes the runs of an app's workflows followable by the people who answer for them. The app names which workflows are processes — a class each, with what people call it and how a run's title and history are read — and the kit lists every run by process, state, age and what is stuck, and draws one run from start to now: the process on a canvas with where the run is and the path it took, each state it was in and for how long, what happened there in order, what it spent on models against the limit set for each run, and the runs it started. A process says what it did with Processes.Note; other kits record their own steps on the run.

preview2 examples compiled by CIworkflowkitui

Summary#

use Osysharp.Processes;

/// Taking a new supplier on.
public class TakingOn : Process<Supplier> {
  public override string Name => "Supplier onboarding";                 // what people call it
  public override string Workflow => "Onboarding";                      // the workflow's own name
  public override WorkflowGraph Graph => Onboarding.Graph;              // what a run is drawn on
  public override string TitleOf(Supplier s) => s.Name;                 // what a run is called in a list
  public override List<WorkflowAuditEntry> HistoryOf(Supplier s) => Onboarding.For(s).Audit;
}
app.Processes = new ProcessesSetup { Processes = [new TakingOn()], RunPage = "/processes/{run}" };

policy FollowsProcesses => IsOperations;                                 // who opens the list and a run

[Route("/processes")]       component RunsPage()        { render { ProcessRuns(); } }
[Route("/processes/{run}")] component RunPage(Guid run) { render { ProcessRunInspector(run); } }

Signature#

class Process<T> : ProcessBase {                       // T: the row the workflow tracks
  virtual string Name; virtual string Workflow; virtual string Purpose;
  virtual WorkflowGraph Graph; virtual TimeSpan? StuckAfter;
  virtual string TitleOf(T item); virtual List<WorkflowAuditEntry> HistoryOf(T item);
}
class ProcessesSetup { List<ProcessBase> Processes; string RunPage;       // app.Processes = …
                       TimeSpan StuckCheckEvery = 30 minutes; }           // how often stuck runs are asked for; zero: never
virtual policy FollowsProcesses => false;                                 // who opens the list and a run
virtual policy ErasesProcessRecords => false;                             // who deletes what the kit keeps of a run

component ProcessRuns(Action<Guid>? opened = null)
component ProcessRunInspector(Guid run)
component ProcessRunPicture(ProcessRunView view, bool tall = false)      // the picture alone
component ProcessRunHistory(ProcessRunView view)                         // "What happened" alone
[Slot] component ProcessMomentPanel(string kind, Guid subject)            // what a line is about, opened in place
[Slot] component ProcessRunPanel(Guid run)                                // more about a run, under its history

Processes.Note(said) · Processes.Waiting(said) · Processes.Done(said) · Processes.Problem(said)   // in a workflow's body
Processes.Here() → Guid?                                                  // record the run now; its row's id
NoteOnProcessRun(ProcessLine line)                                        // from a kit — the app's own code only

[On] void H(ProcessStuck e)                                               // e.Run, e.Workflow, e.State, e.Since, e.Item
TellOfStuckRuns(DateTime now) → int                                       // the kit's clock calls it; the app's own code only

ProcessRunsNow(ProcessFilter filter, int most = 50) → ProcessBoard
ProcessRunOf(Guid id) → ProcessRunView?
ProcessRunAbout(string workflow, Guid item) → ProcessRun?
SetProcessBudget(string workflow, decimal perRun, LlmOnLimit onLimit = Stop) · SetRunBudget(run, limit) · RemoveProcessBudget(workflow)
CostOf(ProcessRun run) → ProcessCost · RunBudgetOf(run) → decimal? · RunSpend(run) → decimal

Description#

What is a process?#

A workflow the app names for the people who follow it. Nothing changes in the workflow: it is still a workflow, run by the same engine. The class says what the kit cannot know — what people call it, and how to read a run's title and history from the row the run tracks. TitleOf and HistoryOf are run as whoever is looking, so they answer what that person may read.

When is a run listed?#

From the moment it starts. The platform says when a run starts, moves and ends (WorkflowRunStarted, WorkflowRunMoved, WorkflowRunEnded — hear a run start, move and end); the kit hears it for every workflow the app named a process, so a run is in the list at its first state — also one that only waits for a person and has said nothing — and the kit keeps where each run stands (ProcessRun.State, MovedAt, Standing, EndedAt) as the engine says it. Nothing is written in the workflow for this.

A run of a workflow the app has not named a process is listed from the first thing recorded for it: a line of its own (Processes.Note(…)), or a step another kit records there (Agents in business processes — an agent's work as a step of a workflow, told on the run (Agents.Processes kit)).

Do the pages follow a run while they are open?#

Yes. ProcessRuns() reads the runs again when one starts, moves or ends, and ProcessRunInspector(run) reads its run again when it moves, ends, or a line is said about it — as whoever is looking, each time. Nobody opens the page again to see where a run has got to.

What does a process say about itself?#

Processes.Note(said)something it did
Processes.Waiting(said)something it now waits for
Processes.Done(said)something it finished
Processes.Problem(said)something that went wrong, and that it handled or could not

Each is kept with the state the run was in when it was said (Workflow.Current.State), and is a unit of work of its own: the line is there whatever becomes of the body that said it. Said outside a workflow's run, it is refused — there is no run to say it about.

What does the list show?#

ProcessRuns() draws every run, newest first: what it is about, its process, where it is, how long it has run and what it has spent. It is narrowed to what is Running, Stuck or Ended, to one process, and by a word of the title. A run is stuck when it has sat in one state longer than its process's StuckAfter, or its last step failed and it has not gone on; the line says which. Each run opens on the page the app named in RunPage.

How is the app told that a run is stuck?#

By an event. Every StuckCheckEvery (half an hour unless the app says otherwise) the kit asks which runs still run and came to their state longer ago than their process's StuckAfter, and raises ProcessStuck for each — once for each stay: a run that moves and gets stuck again is said again, a run that stays where it is is not repeated, and a run that has ended is never stuck.

[On] void TellTheOffice(ProcessStuck e) {
  Notify(OfficeLead(), $"A run of {e.Workflow} has been at {e.State} since {e.Since}.");   // e.Run: the kit's row; e.Item: the row it is about
}

The kit's clock is a workflow of its own (ProcessClock), made with the first run of a process that says how long is too long; it costs one indexed read per process each time it asks. StuckCheckEvery = TimeSpan.Zero turns it off: the list still marks stuck runs, and nothing is raised — an app that wants its own cadence calls TellOfStuckRuns(now) from its own schedule (Schedule (recurring work)). What a workflow itself must do when a step takes too long is a deadline on its state, not this (Remind (milestone reminders), Assigned / Finished (milestones)).

What does one run's page show?#

ProcessRunInspector(run) reads one run from start to now, and changes nothing:

  • Where it stands — what it is about, running or ended, and at which state.
  • In figures — when it started, how long it has run, how long it has been where it is, and what it has spent on models against the limit it is held to.
  • The picture — every state of the process on a canvas (The flow kit — a node-and-edge canvas whose nodes are your own Osy#), the state the run is at marked and the path it took drawn. See how a long process is drawn.
  • What happened — each state it was in, in order, with when it got there and how long it stayed; and under each, what happened there: who took a step and who answered one, a reminder, a deadline that passed, a step that failed (For(entity).Audit), the process's own lines, and the steps other kits recorded. Narrowed to The whole run, Steps or Problems. A line that names what it is about has Open, which draws ProcessMomentPanel under it.
  • Runs around it — the run that started it and the runs it started (Workflow.Current.Starter).

How is a long process drawn?#

A process of up to eight states is drawn across the page, above its history. One with more is drawn downwards, a rank for each state at its own size, and — where the page is wide enough — stands beside the history instead of above it; on a phone it is one column. Nothing is declared for this: it follows from how many states the workflow has.

Two things are left off every picture, because they say the mechanism and not what happened:

  • A way out that every state has. on Withdraw { goto Withdrawn; } written once for the whole workflow is a transition from each of its states. Twelve lines to one box bury the way the run went, so they are not drawn — unless the run took one — and a sentence under the picture says it once: "From any state: withdraw leads to Withdrawn."
  • The word "enter" on a transition a state's own body makes when it has done its work.

What does a run cost, and what may it?#

Osysharp.Llm charges every model call made in a workflow's run to that run ([[agent-llm-spend#runs]]), so what a run cost is counted as it happens — its own calls, and every call of the agent work it gave.

SetProcessBudget("Onboarding", 2.00m);          // each run of it: two dollars on models, in all
SetRunBudget(run, 5.00m);                       // this one run: five

A call that would pass the limit is refused before it leaves — LlmBudgetExceeded for a call the body makes itself; for an agent's step, work that stops where it stands and answers Stopped, which the process handles like any step that did not finish. Budgets are the app's model budgets: whoever manages model spend sets them, and whoever may read model usage sees what a run cost.

Who reads what?#

policy FollowsProcesses => …; says who opens the list and a run — nobody, until the app says. What a run is about is still read through the app's own rules for that row. A reader who follows the process and may not read the row is shown that the run exists and when it was first seen: not its title, where it stands, its history, or the lines said about it.

Runs and lines are written by the kit alone — a line from a workflow's run, or by a kit's own handler through NoteOnProcessRun, which nobody signed in may call; where a run stands from what the engine says, and from nothing else — and a line is never changed. policy ErasesProcessRecords => …; says who deletes what the kit keeps of a run (ForgetProcessRun); the workflow's own history is the engine's.

How does another kit add to a run?#

It records a line with what the line is about, and contributes what opening one shows:

NoteOnProcessRun(new ProcessLine { Run = run, Workflow = workflow, Item = item, State = state,
                                   Said = "The courier collected the parcel.", SubjectKind = "Pickup", Subject = pickup.Id });

[Contribution("Pickup")]
component PickupOpened(string kind, Guid subject) : ProcessMomentPanel {
  render { if (kind == "Pickup") { PickupCard(subject); } }
}

ProcessRunPanel(run) is a second slot, under the history, for a section about the whole run ([Slot] and contributions — a place on a page that other kits, and the app, add to).

Two bridges do exactly this, and arrive with the kits they join: Osysharp.Agents.Processes tells each piece of an agent's work given in a run, and Osysharp.HumanRequests.Processes tells each request a run puts to a person — who was asked, and who answered.

Examples#

app Permits {
  use Osysharp.Processes;
  use Osysharp.Llm;
  use Osysharp.Identity;
  use Osysharp.Workflow;
  use Osysharp.Diagrams;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Workflow;
using Osysharp.Processes;

[Role] enum TownRole { Authenticator, Clerk, Porter }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsClerk => RoleGrant.Any(g => g.Member == user && g.Role == TownRole.Clerk);

// Who follows the town's processes.
policy FollowsProcesses => IsClerk;

entity Person : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}

enum PermitStage { Received, [Label("Site visit")] Visit, Granted, Refused }

entity Permit {
  [Required, MaxLength(120)] string Address;
  PermitStage Stage = PermitStage.Received;
  security { allow read, create, update when IsClerk; }
}

workflow Permitting {
  Tracks = Permit.Stage; Autostart = false; Initial = Received;
  event Inspected(bool sound);

  state Received {
    enter {
      Processes.Done("Checked the drawings against the town plan.");      // a line of the run, said here
      goto Visit;
    }
  }
  state Visit {
    subscribe Inspected(bool sound);
    enter { Processes.Waiting("Waiting for the inspector's visit."); }
    on Inspected(bool sound) {
      when (sound) { goto Granted; }
      default { goto Refused; }
    }
  }
  terminal success Granted { }
  terminal cancel  Refused { Message = "the site did not pass"; }
}

/// A building permit, for the people who follow it.
public class BuildingPermit : Process<Permit> {
  public override string Name => "Building permit";
  public override string Workflow => "Permitting";
  public override WorkflowGraph Graph => Permitting.Graph;
  public override TimeSpan? StuckAfter => TimeSpan.FromDays(14);
  public override string TitleOf(Permit p) => p.Address;
  public override List<WorkflowAuditEntry> HistoryOf(Permit p) => Permitting.For(p).Audit;
}

app.Processes = new ProcessesSetup { Processes = [new BuildingPermit()], RunPage = "/permits/{run}" };

[Route("/")] [Authorize(FollowsProcesses)] [Render(CSR)] [Title("Permits")]
component PermitsPage() { render { Stack(p: 5, maxW: "960px") { ProcessRuns(); } } }

[Route("/permits/{run}")] [Authorize(FollowsProcesses)] [Render(CSR)] [Title("A permit")]
component PermitPage(Guid run) { render { Stack(p: 5, maxW: "960px") { ProcessRunInspector(run); } } }

Permit Apply(string address) {
  var permit = new Permit { Address = address };
  UnitOfWork.Commit();
  Workflow.Run(permit);
  return permit;
}

[TestFixture]
void Town() {
  var clerk = new Person { Email = "[email protected]", DisplayName = "Hollis Brandt" };
  var porter = new Person { Email = "[email protected]", DisplayName = "Sim Okafor" };
  new RoleGrant { Member = clerk, Role = TownRole.Clerk };
  new RoleGrant { Member = porter, Role = TownRole.Porter };
}
principal Clerk  => Person.Single(p => p.Email == "[email protected]");
principal Porter => Person.Single(p => p.Email == "[email protected]");

[Test(Town)]
void a_permit_is_followed_from_the_first_thing_its_process_says() {
  TestClock.Set(new DateTime(2031, 4, 7, 9, 0, 0, DateTimeKind.Utc));
  Guid id = Guid.Empty;
  runas (Clerk) {
    var permit = Apply("14 Mill Lane");
    Workflow.Settle(permit);
    id = permit.Id;
  }
  TestClock.Advance(TimeSpan.FromDays(3));
  runas (Clerk) {
    // The list finds it, and the run tells each state with what was said there.
    var listed = ProcessRunsNow(new ProcessFilter { Show = "running" }).Runs.Single();
    Assert.Equal("14 Mill Lane", listed.Title);
    Assert.Equal("Site visit", listed.StateLabel);
    Assert.Equal("3 days", listed.InState);

    var run = ProcessRunOf(ProcessRunAbout("Permitting", id)!.Id)!;
    Assert.Equal(["Received", "Site visit"], run.Stays.Select(s => s.Label).ToList());
    Assert.Equal("Checked the drawings against the town plan.", run.Stays[0].Moments.Single().Said);
    Assert.Equal("Waiting for the inspector's visit.", run.Stays[1].Moments.Single().Said);
    Assert.Equal(["Received", "Visit"], run.Visited);
    Assert.Equal(4, run.States.Count);

    Permitting.RaiseInspected(Permit.Single(), true);
    Workflow.Settle(Permit.Single());
    Assert.Equal(ProcessStatus.Finished, ProcessRunOf(run.Card.Id)!.Card.Status);
  }
  // Somebody who follows no processes reads none.
  runas (Porter) { Assert.Throws<NotAuthorized>(() => ProcessRunsNow(new ProcessFilter())); }
}
app Moorings {
  use Osysharp.Processes;
  use Osysharp.Llm;
  use Osysharp.Identity;
  use Osysharp.Workflow;
  use Osysharp.Diagrams;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Workflow;
using Osysharp.Processes;

[Role] enum HarbourRole { Authenticator, Master }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsMaster => RoleGrant.Any(g => g.Member == user && g.Role == HarbourRole.Master);
policy FollowsProcesses => IsMaster;

entity Person : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}

enum BerthStage { Asked, Offered, Taken, Lapsed }

entity BerthRequest {
  [Required, MaxLength(80)] string Boat;
  BerthStage Stage;
  security { allow read, create, update when IsMaster; }
}

// Nothing in the workflow says anything of the run: it waits, and is moved.
workflow Berthing {
  Tracks = BerthRequest.Stage; Autostart = true; Initial = Asked;
  event Offer();
  event Accept();
  state Asked   { subscribe Offer();  on Offer  { goto Offered; } }
  state Offered { subscribe Accept(); on Accept { goto Taken; } }
  terminal success Taken  { }
  terminal cancel  Lapsed { }
}

public class BerthAllocation : Process<BerthRequest> {
  public override string Name => "Berth allocation";
  public override string Workflow => "Berthing";
  public override WorkflowGraph Graph => Berthing.Graph;
  public override TimeSpan? StuckAfter => TimeSpan.FromDays(3);            // three days in one place is too long
  public override string TitleOf(BerthRequest r) => r.Boat;
  public override List<WorkflowAuditEntry> HistoryOf(BerthRequest r) => Berthing.For(r).Audit;
}

app.Processes = new ProcessesSetup { Processes = [new BerthAllocation()] };

/// What the harbour office was told.
entity OfficeNotice {
  [MaxLength(300)] string Said = "";
  security { allow read when IsMaster; }
}

[On]
void TellTheOffice(ProcessStuck e) {
  new OfficeNotice { Said = $"{e.Workflow} has been at {e.State} since {e.Since:d MMM}" };
}

[TestFixture]
void Harbour() {
  var master = new Person { Email = "[email protected]", DisplayName = "Edda Varnes" };
  new RoleGrant { Member = master, Role = HarbourRole.Master };
}
principal Master => Person.Single(p => p.Email == "[email protected]");

[Test(Harbour)]
void a_run_that_only_waits_is_listed_from_its_start_and_said_when_it_is_stuck() {
  TestClock.Set(new DateTime(2031, 6, 2, 8, 0, 0, DateTimeKind.Utc));
  runas (Master) {
    var asked = new BerthRequest { Boat = "Kittiwake" };
    Workflow.Settle(asked);
  }
  Background.Settle();
  runas (Master) {
    // It has said nothing, and it is in the list, at its first state.
    var listed = ProcessRunsNow(new ProcessFilter()).Runs.Single();
    Assert.Equal("Kittiwake", listed.Title);
    Assert.Equal("Asked", listed.StateLabel);
    Assert.Equal(0, ProcessNote.Count());
    Assert.Equal("Asked", ProcessRun.Single().State);                     // what the kit keeps, as the engine said it
  }

  // Four days on, the kit's clock has asked: the office is told, once.
  TestClock.Advance(TimeSpan.FromDays(4));
  Background.Settle();
  TestClock.Advance(TimeSpan.FromHours(2));
  Background.Settle();
  runas (Master) {
    Assert.Equal("Berthing has been at Asked since 2 Jun", OfficeNotice.Single().Said);
    Assert.True(ProcessRunsNow(new ProcessFilter { Show = "stuck" }).Runs.Single().Stuck);
  }
}

Notes#

  • A workflow the app has not named a process is still recorded when a kit records a step on one of its runs; it is listed under the workflow's own name, without a picture or a history.
  • A run of a process started when the engine says it did. For a workflow the app has not named a process, it is the first moment anything was recorded for it.
  • A run that is stopped where it stands — called off while its workflow has no cancelled state to go to (What happens to work a run stops waiting for) — reads Stopped at the state it was in.
  • A run's budget covers the calls made in that run. A run it started is a run of its own, with its own limit; what the two cost together is shown on the run that started the other.

See also#

Related

Workflow.Current (which run is this code in?)

`Workflow.Current` is the workflow run this code is executing in — one value saying its id, the workflow's declared…

People in business processes — what a run asks of people, told on the run (HumanRequests.Processes kit)

A process asks a person by putting a request from its workflow and awaiting the answer. With Osysharp.HumanRequests and…

WorkflowRunStarted, WorkflowRunMoved, WorkflowRunEnded — hear a run start, move and end

The platform raises an event when a workflow run starts, when it leaves one state for another, and when it ends —…

What happens to work a run stops waiting for

A run that is waiting on child runs — one it awaits, several joined, tasks that wait — and is ended from somewhere else…

Schedule (recurring work)

A schedule produces work on a cadence. Each occurrence creates a row of the entity its Template names — carrying…

Remind (milestone reminders)

A reminder scheduled off a milestone. Its SCHEDULE is config in the header parens — `After` is the first fire (once, at…

Assigned / Finished (milestones)

A milestone puts an SLA on a slot's progress — Assigned (someone must PICK IT UP within Within) and Finished (it must…

Graph — every state and every transition of a workflow

The whole shape of a workflow — every state (with its label, and whether it starts or ends a run) and every transition…

For(entity).Audit

Reads a running instance's lifecycle timeline — every transition, claim, deposit, reminder and refusal as an…

Flow metrics — how long an item took, and how much was waiting

Wall-clock lead time for a run, split into the part somebody was working it and the part it sat waiting — plus where…

The flow kit — a node-and-edge canvas whose nodes are your own Osy#

A modelling canvas — pan, zoom, drag nodes, draw edges between ports, select and delete, by mouse and by keyboard —…

Agents in business processes — an agent's work as a step of a workflow, told on the run (Agents.Processes kit)

An agent's work is a step of a business process by being called from the process's workflow: an awaited call in a…

The inspector — one piece of an agent's work, readable by a person who was not there (Agents.Inspector kit)

Osysharp.Agents.Inspector draws one page for one piece of an agent's work: what it was and how it ended; who gave it…

Model spend — budgets, the meter and the price book (Llm kit)

The Llm kit keeps three things about what model calls cost. The METER: one `ModelCall` row for every attempt, with the…

[Slot] and contributions — a place on a page that other kits, and the app, add to

A slot is a place on a page others add to. A kit marks one (`[Slot] public abstract component QuotePanel(Quote…

Workflows (the run that outlives the request)

A `workflow` is a run that survives the process it started in — it parks, waits days for a person or a timer, and…