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) → decimalDescription#
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
ProcessMomentPanelunder 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: fiveA 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#
- 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 as a step of a process, told on the run
- The inspector — one piece of an agent's work, readable by a person who was not there (Agents.Inspector kit) — one piece of an agent's work, opened from a run's line
- Workflow.Current (which run is this code in?) — how code knows which run it is in
- Graph — every state and every transition of a workflow · For(entity).Audit — what the picture and the history are read from