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:
Handling | after | resolution | response |
|---|---|---|---|
New | it arrived | runs | first response runs |
Owed | the requester wrote | runs | a next response runs — each its own cycle |
Answered | we responded | runs | none |
WaitingOnRequester | WaitOn(…, WaitFor.Requester) | paused | none |
WaitingOnOthers | WaitOn(…, WaitFor.Others) | runs — unless the policy's PauseWhileWaitingOnOthers, then Paused | none |
Paused | WaitOn(…, WaitFor.Pause) | paused | paused |
Done | WorkDone | met; a reopen resumes it from where it stopped | none |
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:
Distribution | who is given it |
|---|---|
Manual (default) | nobody — it waits in the pool, and PickupWithin tells the leads when nobody has taken it |
RoundRobin | the free member whose turn it is — the one given something longest ago |
LeastLoaded | the 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 queueAn 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:
SlaAction | does |
|---|---|
TellHolder | raises PromiseToldToHolder (its Holder is told) |
TellLeads | raises PromiseToldToLeads (its queue's leads are told — InboxLeads) |
RaisePriority | one step up (Low → Normal → High → Urgent); the promise is read again and retargeted |
Escalate | as EscalateWork, by the promise — Escalation.By is empty, Metric says which |
MoveTo | to 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")).
Links between items: a closed set the app declares#
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 aMutualkind), a one-way link run backwards, a second link of aOnekind, 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 (hrefOfbuilds 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 withOffered = falseis 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#
- The WorkQueues kit's floor — restricted queues, who may hold an item, and who moves whose work — the floor every grant stands on: restricted queues, who may hold, who moves whose work
- ServiceHours (SLA-accrual windows) — the hours a promise runs in
- Workflow.Retarget (re-base the SLA clocks) — what
Prioritisedoes to the running clocks - Imported history — StartedAt, and raising an event at the time it happened —
ArrivedAtandat:for imported items - Workflow.WorkByItem<T> (one row per item — the board read) — the board read the lists are built on
- The Presence kit — who else is on a record, and who moved it first — the "moved first" sentence, and seats on an item
kits/work-queues/tests— the kit's front door: two kinds of work, the promises, queues, views, holding, the floor