Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

The WorkQueues kit — queues, who holds what, and the service levels promised on any item

use Osysharp.WorkQueues@1; · entity Ticket : WorkItem { … } · Responded(t) · RequesterWrote(t) · WaitOn(t, WaitFor.Requester) · WorkDone(t) · TakeWork(t, expected) · Rows(BuiltInView.Mine) · Counts()

`Osysharp.WorkQueues` puts any item an app derives from `WorkItem` — a ticket, an approval, a lead — into queues with holders and a promise. The app keeps its own states and tells the kit four facts as they happen (we answered, they wrote, we are waiting, it is done); the kit keeps the clocks — first response, every next response, resolution — in business hours, paused while waiting, recorded when breached, and retargeted when the priority changes.

preview2 examples compiled by CIworkflowkitslaqueues

Summary#

use Osysharp.WorkQueues@1; gives an app WorkItem to derive its kind of work from. The app keeps what the item is — its states, fields and guards — and tells the kit four facts; the kit keeps the time and the holder:

Responded(ticket);                      // we gave the requester a response
RequesterWrote(ticket);                 // they wrote — a response is owed again
WaitOn(ticket, WaitFor.Requester);      // we are waiting on someone (… WaitFor.Others, WaitFor.Pause) — ResumeWork ends it
WorkDone(ticket);                       // it is done (ReopenWork, CloseWork, CancelWork)

Signature#

policy WorksInbox;  policy LeadsInbox;  policy AdministersInbox;          // the app supplies all three

[Authority(Extensible)] public entity WorkItem {
  string Title;  string Reference;  Queue? Queue;  Principal? Assignee;  WorkPriority Priority;  Handling Handling;
  Organization? Organization;  Principal? Requester;  string Language;  string Channel;  string Segment;  string Kind;
  DateTime ArrivedAt;   SlaPolicy? Policy;  TimeSpan? FirstResponseWithin;  …  SlaState Sla;
}

void Responded(WorkItem item, DateTime? at = null);
void RequesterWrote(WorkItem item, DateTime? at = null);
void WaitOn(WorkItem item, WaitFor reason, DateTime? at = null);    void ResumeWork(WorkItem item, DateTime? at = null);
void WorkDone(WorkItem item, DateTime? at = null);   void ReopenWork(…);   void CloseWork(…);   void CancelWork(WorkItem item, string why);
void Prioritise(WorkItem item, WorkPriority priority);
List<ItemClock> Clocks(WorkItem item);    bool HasPromise(WorkItem item);

void TakeWork(WorkItem item, Guid? expected = null);    void ReleaseWork(WorkItem item);
void HandWorkTo(WorkItem item, Principal person, Guid? expected = null);    void MoveWork(WorkItem item, Queue queue);
BulkResult TakeMany(List<WorkItem> items);    BulkResult MoveMany(List<WorkItem> items, Queue queue);
List<Handover> TrailOf(WorkItem item);
bool AddressWork(WorkItem item, Principal to);          // from a workflow's body only: give a kit's own item to a named person
int LoadOf(Principal person);    bool OnDuty(Principal person, DateTime now);    string NeedsOf(WorkItem item);

void AcceptOffer(WorkItem item);    void DeclineOffer(WorkItem item);    RoutingOffer? OfferOn(WorkItem item);
List<AccountRow> MyAccounts();
Absence GoAway(DateTime? until = null, Principal? cover = null, string reason = "");    void ComeBack();
Absence MarkAway(Principal person, DateTime? until = null, Principal? cover = null, string reason = "");    void MarkBack(Principal person);
policy ActsFor(Principal u, Principal? p);    // u is p, or covers for p while p is away
void EscalateWork(WorkItem item, string why);

List<WorkRow> Rows(BuiltInView view);   List<WorkRow> RowsIn(Queue queue);   List<WorkRow> RowsOf(InboxView view);
InboxCounts Counts();

app.Inbox = new InboxSetup { DefaultQueue = "Support", AgentsHandOn = true, AccountRouting = true,
                             KeepHolderOnEscalation = true, Routing = …, Views = …, Words = …, Links = … };

// What happens to an item is an EVENT the kit raises — each about the item, each saying itself (`e.Sentence()`, in the
// reader's language, when read). Automations, Webhooks and Activity take them as they are; an app hears one with [On].
ItemArrived · ItemQueued · ItemRouted · HolderChanged · ItemMoved · ItemEscalated · ItemOffered · OfferFellThrough
OfferWithdrawn
RoutingSkipped · ItemPooled · PickupOverdue · PriorityChanged · DueMoved · ItemCancelled · ItemReopened
PromiseAtRisk · PromiseBreached · PromiseMet · PromiseToldToHolder · PromiseToldToLeads · ItemLinked · ItemUnlinked
// Who should know is on the event: ItemOffered.To, HolderChanged.Told, PromiseToldToHolder.Holder — and, for a queue's
// leads, PickupOverdue and PromiseToldToLeads (all told in their bell by Osysharp.WorkQueues.Notifications).
List<Principal> InboxLeads(WorkItem item);              // its queue's members who satisfy LeadsInbox (else any queue's)
string InboxNotice(Event e);                            // what the person an event names reads ("Offered to you.")
bool IsInboxEvent(Event e);  DateTime InboxTime(Event e);   // for a timeline: one of these? when did it happen?
string InboxStepWords(InboxStep step);                  // a trail step's facts, said for the reader
List<TrailLine> TrailLines(WorkItem item);              // "why is this here", said for the reader (WhyHere shows it)

Description#

The app keeps its states; the kit keeps the time#

One durable WorkHandling run per item, of any kind the app derives — beside the app's own lifecycle run, when it has one (a Spend : WorkItem with its own approval workflow has two runs, and they agree because the app's workflow calls the verbs from its states). The run's Handling is the phase the clocks read:

Handlingafterresolutionresponse
Newit arrivedrunsfirst response runs
Owedthe requester wroterunsa next response runs — each its own cycle
Answeredwe respondedrunsnone
WaitingOnRequesterWaitOn(…, WaitFor.Requester)pausednone
WaitingOnOthersWaitOn(…, WaitFor.Others)runs — unless the policy's PauseWhileWaitingOnOthers, then Pausednone
PausedWaitOn(…, WaitFor.Pause)pausedpaused
DoneWorkDonemet; a reopen resumes it from where it stoppednone

Responded and Prioritise are the team's (WorksInbox): a requester cannot stop the team's clock or raise their own priority, and a timer — which is nobody — cannot say "we responded". The other facts may be said by anyone who can read the item, and from a timer in the app's own lifecycle (a week of silence that solves a ticket says WorkDone). at: replays an imported item's history as it happened; ArrivedAt is when its clocks start.

The promise: the most specific policy wins#

An SlaPolicy states conditions — Priority, the organisation's Tier (its own, else the default tier), Queue, Channel, Segment, Kind, ForVip — and the Hours its clocks run in. Of the active policies whose every stated condition matches, the one stating the most wins; ties go to the lower Position. Its SlaTarget rows (one per SlaMetric) are snapshotted onto the item. An item no policy matches has no promise: Clocks(item) is empty, and a screen says so rather than drawing a zero.

Clocks: business time, paused, at risk, breached, retargeted#

Every clock runs in the policy's ServiceHours (holidays included; none: around the clock). Each is also kept as an SlaClock record — when it started, the business time it used, when it went at risk, was met, was breached — which outlives the run and is what reports read. A breach is recorded and never fatal: the item stays owed, and is met late. AtRiskAt (default 0.75) is the fraction of a budget after which it is at risk — for every promise, the resolution included (a reminder on the run's Deadline, measured on its own clock, [[workflow-remind#deadline]]). Prioritise chooses the policy again and retargets every running clock, keeping the time already used. WorkItem.Sla is the worst running promise (on track, at risk, breached) — a column, so a list and its counts can read it.

Queues and who holds an item#

An item waits in the queue the app named at create, else InboxSetup.DefaultQueue (made on first use). Only an active QueueMember of its queue may hold it. TakeWork(item, expected) takes it — expected is who the page showed holding it, so a take that lost the race is refused with the holder's name: "Rahul took this just now." A lead may take or hand on anybody's work; an agent may only when InboxSetup.AgentsHandOn is set (the default). MoveWork moves it to another queue — a member of that queue, or a lead — and the holder keeps it only where they may hold it. Every change of holder or queue is a Handover row with its reason and who did it: TrailOf(item) is "why is this here". Each row holds FACTS — from whom, to whom, by whom, which queue, why somebody could not take it and until when — never a sentence; TrailLines(item) (and the WhyHere component) says them in the reader's language (A history in the reader's language — keep what happened, say it when it is read).

An item put to one person by name#

Some work is not for whoever is free: a question asked of one manager, an approval that is one person's to give. Say who when the item is made — new SpendApproval { Title = …, Addressee = manager } — and it is theirs as it arrives (HandoverReason.Addressed), without being routed and without using a turn of the queue's distribution. Somebody who may not hold it (not a member of its queue) is not given it; it is then routed like any other item. Addressee is said once, at create. A kit whose own run needs to pass such an item on — a request nobody answered goes to the next person — says AddressWork(item, to) from its workflow's body; from a page or a plain function that is refused, because a person hands work on with HandWorkTo, which asks whether they may.

Who is given it: rules, distribution, capacity, skills#

When an item arrives it waits in the queue the app named, else the first matching RoutingRule's, else the app's InboxRouting.QueueFor, else the default queue. Rules are an inbox admin's rows, ordered by Position: conditions (channel, words in the title, organisation, tier, VIP, language, segment, kind) and a closed set of actions — the queue, the priority, a skill the item needs, a person to give it to (only when they may hold it). The first match stops unless it says ContinueAfter. A rule may also carry the app's own action, by key (AppAction = "billing"): each matching rule that has one is handed to InboxRouting.Act(item, appAction, rule) in the rules' order, so an admin's rule can do what the kit has no word for — a helpdesk tags with it — while the kit's own set stays closed. (The settings page shows the field in the app's words: RoutingRuleSettings(appAction: "Tag it", appActionHint: "billing").) Anything else the app does to every arrival is InboxRouting.Routed.

Then the queue's Distribution:

Distributionwho is given it
Manual (default)nobody — it waits in the pool, and PickupWithin tells the leads when nobody has taken it
RoundRobinthe free member whose turn it is — the one given something longest ago
LeastLoadedthe free member with the least work for their capacity; ties to the one given something longest ago

Free means: may hold it (a member, holding every required skill), on duty (Availability.Status is Available and their Hours, in their zone, are open — no row means available around the clock), and below their capacity — the least of their own Availability.Capacity, their QueueMember.Capacity and the queue's DefaultCapacity. Load counts only work that needs them: New, Owed, Answered, waiting on others — not waiting on the requester, not done. Members holding more of the item's preferred skills are given it first, higher levels first. Capacity limits what the inbox gives; a person may still take, or be handed, more.

An item's Language is a preferred skill (a RoutingSkill of kind Language whose Code matches its primary subtag — "es" speaks to "es-MX"). For the queue's LanguageWait (10 minutes by default) only speakers are given it; after that, anybody free — dedicated never means stuck.

Nobody free: the item waits in the pool, and is pulled the moment somebody is — their load drops (done, waiting on the requester, released, handed on), or the language wait ends. A released item goes to whoever is free next, never back to who let it go. Nothing polls: each pull is the run of the item that freed the capacity, raising Route on the oldest-due pooled item. The trail says which: RoundRobin, LeastLoaded, Rule, Refill.

Two items given out at the same instant to the same person meet at that member's row ([ConcurrencyCheck]): the later one's start is refused and redelivered, and is routed again with the first one counted.

Account routing: the organisation's own people first#

An item of an organisation with an account team (the Accounts add-on's AccountTeamMember, The Accounts add-on — the tier, entitlements, account team and VIPs of a customer organisation) is offered to that team before its queue hands it out:

owner ─▶ backup (or the owner's cover) ─▶ the pod (least loaded first) ─▶ the segment queue (ForTier / ForSegment) ─▶ the queue

An offer is a RoutingOffer with its own durable run (OfferFlow). It waits the queue's OfferFor (15 minutes by default) in the queue's Hours, so a dedicated owner on a business-hours queue gets an overnight item first thing in the morning; then it falls through to the next step. Anybody who cannot take it now — away, off their own hours, busy, offline, at capacity, or not allowed to hold it — is skipped at once, and the trail says why ("Mateo is away until Mon 8 Jun 06:00 UTC — skipped."): dedicated never means stuck. The offered person sees it in Rows(BuiltInView.Offered); taking it (AcceptOffer, TakeWork) or responding to it accepts it, and DeclineOffer falls through at once. Somebody else taking it withdraws the offer. The account team's own people may hold the item wherever it waits, unless its queue is restricted. The item's clocks never wait for routing: account routing changes who gets it first, never what was promised. InboxSetup.AccountRouting = false turns it off.

MyAccounts() lists the organisations whose account team the viewer is on, each with its open, at-risk, offered and waiting work — one grouped read; Rows(BuiltInView.MyAccounts) is their work as a list.

Away, cover, and follow-the-sun#

Routing reads what a person said, never a socket. A person is on duty when their Availability.Status is Available, their own Hours (in their zone) are open, and they have no live Absence.

GoAway(until, cover) — or a lead's MarkAway(person, until, cover), or an Absence planned ahead — hands the person's held work over when it starts, by each queue's OnAway rule: Keep, ToCover (the default: to the cover when the cover may hold the item, otherwise back to the queue, which gives it out again), or ToQueue. What is offered to them falls through at once. At Until (or ComeBack()), the absence is over and they are given work again; what cover took stays with the cover. ActsFor(u, p) — u is p, or covers for p while p is away — is what an approval step asks, always beside its own rule: [Authorize(u => ActsFor(u, this.Item.Approver) && u != this.Item.Requester)].

Follow-the-sun: halfway to the first-response promise, an item whose holder is off their hours goes to the least loaded member of its queue who is on theirs (HandoverReason.OffHoursCover), unless the queue says CoverOffHours = false.

Escalation: levels, and what a promise does at its moments#

A queue has a Level (L1 = 1, L2 = 2, L3 = 3) and may name the queue it EscalatesTo. EscalateWork(item, why) (the team's) moves the item there and raises its EscalationLevel: the promise is read again — a policy may be keyed by queue — and every running clock is retargeted keeping the time used; the item is routed again at the new level, and its holder keeps it only when they may hold it there (and InboxSetup.KeepHolderOnEscalation). Each escalation is an Escalation row (from, to, by, why, and the promise that did it) and a Handover with reason Escalated.

A policy's SlaEscalation rows say what its clocks do at two moments — AtRisk and Breached — per promise (SlaMetric). The actions are a closed set:

SlaActiondoes
TellHolderraises PromiseToldToHolder (its Holder is told)
TellLeadsraises PromiseToldToLeads (its queue's leads are told — InboxLeads)
RaisePriorityone step up (Low → Normal → High → Urgent); the promise is read again and retargeted
Escalateas EscalateWork, by the promise — Escalation.By is empty, Metric says which
MoveToto the row's MoveTo queue, the holder kept only where they may hold it

Each fires once per clock cycle — the first response; each next response; the resolution — because the moment is recorded once, on the clock. Anything richer (post in a channel, set a tag) is an automation's — on PromiseAtRisk or PromiseBreached. Who is told is the app's composition: with Osysharp.Notifications and Osysharp.WorkQueues.Notifications, the holder reads "First reply is at risk." in their bell with nothing written for it, and so do the queue's leads (Inbox.Notifications — the people the inbox names, told in their bell).

Who leads a queue#

InboxLeads(item) answers the people who lead an item's queue: its active members who satisfy the app's LeadsInbox — asked of each of them with Security.Authorize — does THIS person satisfy a policy, so the kit never needs to know how the app decides it — or, when none of them does (or the item waits in no queue), everybody who leads in any queue, so an item in a queue nobody leads still reaches somebody. Each person once; an account that cannot sign in is nobody's lead. It is what the leads' two events are told to, and what an app hands its own "tell the leads" action.

What a view holds, and how it is counted#

Rows(BuiltInView.Mine | Offered | MyAccounts | Unassigned | Open | AtRisk | Waiting | Done), RowsIn(queue) and RowsOf(savedView) are each a fixed handful of statements however long the list. Counts() is one grouped read summed into every built-in view's count and every queue's — and, like every list, the viewer's own read: a count never includes an item the viewer may not read. A saved InboxView filters by queue, holder, priority floor, tier, organisation, at-risk, language, segment, kind and the app's own filter (InboxViews.Narrow("tag:refund")).

Two items are often about one thing — a request that repeats another, one caused by another, many reports of one fault. The app declares the KINDS (InboxSetup.Links), each with its words from both sides; the kit keeps the link (WorkLink), refuses one that makes no sense with a sentence, shows it on both items, and raises an event on both (ItemLinked/ItemUnlinked, each saying itself — "Linked: Duplicate of #1042."):

public class DeskLinks : InboxLinks {
  public override List<LinkKind> Kinds() {
    return [
      new LinkKind { Key = "related", Says = "Related to", Mutual = true },
      new LinkKind { Key = "duplicate", Says = "Duplicate of", Back = "Duplicated by", One = true },
      new LinkKind { Key = "incident", Says = "Incident of", Back = "Problem behind", One = true },
      new LinkKind { Key = "split", Says = "Split from", Back = "Split into", Offered = false },   // the app's code only
    ];
  }
}
app.Inbox = new InboxSetup { Links = new DeskLinks() };
  • Link(from, to, key) / Unlink(link) — the verbs (no commit); LinkWork(item, other, key, back) / UnlinkWork(link) for a page (committed; back: the OTHER item is the one that says it). Refused: an undeclared kind, an item linked to itself, a link that already stands (either way, for a Mutual kind), a one-way link run backwards, a second link of a One kind, and a chain of one (linking to an item that itself links that way).
  • A link moves nothing — no holder, queue or clock. What a kind MEANS is the app's: LinkedFrom(problem, "incident") lists the incidents its own "solved" can solve.
  • ItemLinks(item, hrefOf, shortcut, empty) — the panel: every link from the item's side, live, each opening the other item (hrefOf builds the app's route from it), removable; "Link…" asks which way and finds the other item by its reference or words of its title (FindWork). A kind with Offered = false is shown and never chosen or removed.
  • The team's alone (inbox-links-are-staffs): a requester never reads a link, nor makes or removes one.

Examples#

A desk on the kit#

app Desk {
  use Osysharp.Identity@0;
  use Osysharp.Organizations@0;
  use Osysharp.Organizations.ServicePlans@0;
  use Osysharp.Presence@0;
  use Osysharp.Observability;
  use Osysharp.WorkQueues@1;
  use Osysharp.Workflow;
  use Osysharp.ServiceHours;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.WorkQueues;

[Role] enum DeskRole { Authenticator, Agent, Lead, Admin }
policy WorksInbox => RoleGrant.Any(g => g.Member == user
  && (g.Role == DeskRole.Agent || g.Role == DeskRole.Lead || g.Role == DeskRole.Admin));
policy LeadsInbox => RoleGrant.Any(g => g.Member == user && (g.Role == DeskRole.Lead || g.Role == DeskRole.Admin));
policy AdministersInbox => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Admin);
policy ManagesOrganizations => AdministersInbox;
policy CollaboratesOnRecords => WorksInbox;

/// The desk's own kind of work. Its grants are its own: staff work every ticket; a customer reads theirs.
entity Ticket : WorkItem {
  [MaxLength(4000)] string Body = "";
  security {
    allow read, create, update when WorksInbox;
    allow read, create when IsAuthenticated where Requester == user;
  }
}

/// A reply goes out — and the inbox hears "we responded".
public void Reply(Ticket t, string text) {
  t.Body = t.Body + "\n\n" + text;
  Responded(t);
}

/// The customer wrote back — a response is owed again.
public void CustomerWrote(Ticket t, string text) {
  t.Body = t.Body + "\n\n" + text;
  RequesterWrote(t);
}

/// An admin's setup: money words go to Billing, and Billing hands its work out in turn, five at a time each.
public void SetUpBilling() {
  var billing = new Queue { Name = "Billing", Distribution = Distribution.RoundRobin, DefaultCapacity = 5 };
  new RoutingRule { Name = "Money words", TitleContains = "invoice|refund", ToQueue = billing };
}

/// An agent's holiday: their tickets go to the colleague covering, and they are given nothing until they are back.
public void Holiday(DateTime until, Principal cover) { GoAway(until, cover, "Holiday"); }

/// The accounts the viewer looks after, with their open and at-risk work.
public int AccountsAtRisk() {
  var atRisk = 0;
  foreach (var a in MyAccounts()) { atRisk = atRisk + a.AtRisk; }
  return atRisk;
}

/// An agent sends a ticket up a level — the team above it gets it, with the reason on its trail.
public void Escalate(Ticket t, string why) { EscalateWork(t, why); }

/// What an agent's "Mine" shows, and the rail's counts beside it.
public int MineCount() { return Rows(BuiltInView.Mine).Count; }
public int OpenCount() { return Counts().Open; }

Hearing what happens to an item#

Every escalation is an ItemEscalated the kit raises. The desk keeps a line for each, with the kit's own sentence — said again in the reader's language when the line is read — and the trigger needs nothing registered.

using Osysharp.WorkQueues;

entity EscalationNote {
  [Required, MaxLength(300)] string Says;
  Ticket? Ticket;
  security { allow read, create when WorksInbox; }
}

[On]
void NoteTheEscalation(ItemEscalated e) {
  var about = Events.About(e);
  if (about is Ticket t) { new EscalationNote { Ticket = t, Says = e.Sentence() }; }
}

See also#

Related

ServiceHours (SLA-accrual windows)

A schedule the SLA clock accrues within — the platform WALKS its weekly windows (and holiday exceptions) to advance…

Workflow.Retarget (re-base the SLA clocks)

Re-evaluates every SLA clock's budget on the current run against the now-updated entity, so a mid-run change to the SLA…

Imported history — StartedAt, and raising an event at the time it happened

For an item whose history began before its run did — a ticket moved from another helpdesk, a seeded month of work —…

Workflow.WorkByItem&lt;T&gt; (one row per item — the board read)

One row per tracked ENTITY, where `Workflow.Work<T>()` is one per SLOT. A board, a queue and a "my work" screen are all…

The WorkQueues kit's floor — restricted queues, who may hold an item, and who moves whose work

What holds in every app on `Osysharp.WorkQueues`, whatever the app grants: an item in a restricted queue is invisible —…

The Presence kit — who else is on a record, and who moved it first

`Osysharp.Presence` shows who else has a record open right now — any record, addressed by its id — with what they are…

The Organizations kit — companies, teams, and roles held within one

`Osysharp.Organizations` holds the groups people belong to — the companies an app's customers work for and the teams…