Osy#betaRun it on Osyrin: osyrin.com

Reference / Agent

Requests to people — a question, an approval, a form, and the answer coming back (HumanRequests kit)

PutRequest(new NewRequest { Kind, To, Title, Context, Options, About, Within, Hours, … }) · NewRequest.For<T>() · await Requests.Answer(request) · AnswerRequest(request, answer) · AnswerByLink(link, answer) · UseApproval(request, versionNow) · CancelRequest · ExtendRequest · ReplaceRequest · RequestSheet(id) · ExternalAnswer(link) · WaitingOnPeople(…)

The `Osysharp.HumanRequests` kit puts something to a person — a question with the options it may be answered with, an approval of one exact version of a thing, a request for the fields of a class you declare — from a person, a process or an agent, and brings the answer back typed to whatever waits for it, however long that takes. A request is an ordinary item of the app's work queues, answered on its own sheet in the inbox; somebody with no account answers through a link that opens that one request. It keeps time in working hours, reminds up to a limit, goes on to the next person when it is late without deciding anything by the silence, and keeps an answer that comes too late beside the request without applying it. An approval is used once, for the version approved.

preview1 example compiled by CIagentskitworkflowpeople

Summary#

Work stops at people: somebody has to choose, approve, or say what only they know. PutRequest puts that to them and keeps it — the question, who asked, what it is about, by when — and the answer comes back to whatever was waiting:

var request = PutRequest(new NewRequest {
  Kind = RequestKind.Approval, To = manager, Title = "Print 400 of the spring poster?",
  About = Subject.Of("Proof", proof.Id, "Spring poster", version: "v" + proof.Version),
  Within = TimeSpan.FromHours(8), Hours = shopHours,
});
var answer = await Requests.Answer(request);          // in a workflow: parks here, for days if it takes days
if (answer.Approved) { UseApproval(request, "v" + proof.Version); Print(proof); }

Nothing in it is about agents. A page, a process and an agent ask the same way.

Signature#

use Osysharp.HumanRequests@1;                     // needs Osysharp.WorkQueues: a request is an item of its queues

policy AsksPeople       => IsStaff;               // who may put a request (a process asks as the app)
policy OverseesRequests => IsLead;                // who reads every request, calls one off, gives it more time
app.Requests = new RequestsSetup { Queue = "Approvals", RemindAfter = TimeSpan.FromHours(4), MaxReminders = 3 };

HumanRequest PutRequest(NewRequest ask)
class NewRequest {
  RequestKind Kind;                               // Question · Approval · Information
  string Title; string Context; List<RequestFact> Facts;
  Principal? To; string Queue; string OutsideName; string OutsideEmail;       // one person · a queue · somebody outside
  List<RequestOption> Options; List<RequestField> Fields;                      // what it may be answered with
  Subject? About; Subject? WaitingWork;           // what it is about (with its version) · the work that waits for it
  TimeSpan? Within; TimeSpan? ExpireAfter; ServiceHours? Hours;                // late after · gives up after · counted in
  TimeSpan? RemindAfter; TimeSpan? RemindEvery; int? MaxReminders; Principal? EscalateTo;
  static NewRequest For<T>();                     // a request for the members of class T
}

Task<RequestOutcome> Requests.Answer(HumanRequest request)     // wait, in a workflow
RequestOutcome OutcomeOf(HumanRequest request)                 // read, at any time
class RequestOutcome { RequestStatus Status; string Choice; string Comment; bool Answered; bool Approved; bool Rejected;
                       string ByName; Assurance? With; DateTime? At; T As<T>(); }

AnswerResult AnswerRequest(HumanRequest request, RequestAnswer answer)              // as whoever is signed in
AnswerResult AnswerByLink(AnswerLink link, RequestAnswer answer, string name = "")  // through the link
string AnswerLinkFor(HumanRequest request)   void RevokeAnswerLink(HumanRequest request)

void UseApproval(HumanRequest request, string versionNow)      bool ApprovalCovers(HumanRequest request, string versionNow)
void CancelRequest(HumanRequest request, string why)
void ExtendRequest(HumanRequest request, TimeSpan within, string why, TimeSpan? expireAfter = null)
HumanRequest ReplaceRequest(HumanRequest old, NewRequest ask, string why)

// events: HumanRequestPut · Answered · Reminded · Overdue · Expired · Cancelled · Retimed · AnsweredLate
// screens: RequestSheet(id) · ExternalAnswer(link) · WaitingOnPeople(askedByName, waitingType, waitingId, open)
// slot:    RequestSubject(subjectType, subjectId, request, changed) — the kit that owns what a request is about draws it

Description#

What can be asked?#

KindThe person seesThe answer
Questionthe question and a button per RequestOption — or a box to write in when it offers noneChoice (the option's key), Comment
Approvalthe thing, what a yes covers, Approve and Reject — a no asks why before it sendsApproved / Rejected, Comment (the reason)
Informationa form: one field per member of the class asked forAs<T>() — the class, filled in

NewRequest.For<SupplierDetails>() makes the form from the class: a member's name in words is its label, its /// comment the line under it, its type the kind of field (text, a paragraph, a number, a yes or no, a date, the members of an enum), and T? means it may be left empty. The answer is checked against the same fields before it is taken, and read back as the class.

Who is asked, and where do they answer?#

  • One person (To): the request is theirs in the inbox as it arrives ([[workflow-inbox-kit#addressee]]).
  • Whoever works a queue (no To): the first of them to answer has answered it.
  • Somebody with no account (OutsideEmail): nobody inside answers it. AnswerLinkFor(request) is the text of a link that opens that one request; the app mails it, and gives the page a route:
[On] void MailTheLink(HumanRequestPut e) {
  if (e.Request.OutsideEmail != "") { Mail(e.Request.OutsideEmail, $"{App.BaseUrl}/answer/{AnswerLinkFor(e.Request)}"); }
}
[Route("/answer/{link}")] [AllowAnonymous]
component AnswerPage(AnswerLink link) { render { ExternalAnswer(link); } }

An answer is written only by the kit's own function, which asks who is answering before it writes: the person asked, the person it went on to, whoever holds it, the people of its queue, or the holder of its link — and never the person who asked, even when the request is put to a queue they work themselves. NewRequest.NotBy names one more person who may not answer it though they work its queue: the person whose own request it decides — a refund they asked for, put to Finance.

How does the answer come back?#

In a workflow, await Requests.Answer(request) parks the run until the request closes and answers what came back — the answer, or that it was called off or ran out of time. Put the request in a step, so a body that is run again finds the one it already put:

var request = Workflow.Once("ask-manager", () => PutRequest(new NewRequest { … }));
var answer = await Requests.Answer(request);

Code that cannot wait — a page's action, a plain function — hears HumanRequestAnswered with [On], or reads OutcomeOf(request) when it looks again. Requests.Answer says so if it is called there (Workflow.InRun (can this code wait?)).

What does an approval cover?#

One exact version, once. An approval is put with what it is of and the version shown (About = Subject.Of(type, id, label, version:) — a revision number, a hash of what was shown; the asker's own terms). The person is told so on the sheet, before the buttons. The act it allows calls UseApproval(request, versionNow) with the version of the thing as it is now, in the same unit of work:

UseApproval
approved, the same version, not yet usedmarks it used — the act goes on
the thing changed after the yesrefused: "… has changed since it was approved, so the approval no longer applies. Ask again, about it as it is now."
already usedrefused: an approval is used once
a no, called off, ran out of time, not answered yetrefused, with which

ReplaceRequest(old, ask, why) asks again about the thing as it is now and calls the old request off.

What happens when nobody answers?#

Within is how long it may wait before it is late, counted in Hours (ServiceHours (SLA-accrual windows)) — the date the inbox shows for the item is the same instant. RemindAfter / RemindEvery raise HumanRequestReminded, in working hours, at most MaxReminders times; the app sends the reminder its own way.

Late is not closed. Past its date the request is overdue: HumanRequestOverdue is raised, it goes on to EscalateTo when one is named, and it still waits. Nothing is decided by the silence — an approval nobody gave is not an approval. It closes unanswered only when it was given a time to give up at (ExpireAfter) or is called off (CancelRequest, by whoever asked or whoever oversees requests). ExtendRequest moves its date and its reminders, recorded with who and why.

What about an answer that comes too late?#

An answer to a request that already has one, was called off or ran out of time is kept and not applied: a LateAnswer row beside the request, with who sent it, how they were known and when; HumanRequestAnsweredLate is raised; the person is told their answer was kept and changes nothing by itself. A link goes on opening its request after it closed, for exactly this.

What does a person see?#

  • RequestSheet(id) — the request as whoever is looking may read it. In the inbox it is drawn for you under the item's own head. For the person asked: who asks, what is waiting on the answer, the context, the facts, what it is about as the kit that owns it draws it (RequestSubject), when it is wanted by, and the answer a press away. For whoever asked: who it waits for; then what came back.
  • ExternalAnswer(link) — the page behind a link: the one request, the form, their name, and what became of the answer.
  • WaitingOnPeople(waitingType:, waitingId:) — on the page of the thing that waits: every open request holding it up, of whom, since when, and whether it is late. askedByName: lists what one process or agent has put.
  • "Approve all" on the inbox's bulk bar while every picked item is an approval — each approved as the person's own answer, so one that is not theirs stays as it was.

Who may do what?#

A request is read by the person it is put to, the person it went on to, whoever holds it, whoever asked, the people of its queue when it names nobody, and whoever OverseesRequests. It is made only by PutRequest (AsksPeople); answered only through AnswerRequest / AnswerByLink; an approval's use is marked only by UseApproval; its time is changed only by ExtendRequest. A link opens the one request it was made for, for that call, and nothing else in the app.

Examples#

app Printshop {
  use Osysharp.HumanRequests;
  use Osysharp.WorkQueues;
  use Osysharp.Identity;
  use Osysharp.Organizations;
  use Osysharp.Organizations.ServicePlans;
  use Osysharp.Presence;
  use Osysharp.Observability;
  use Osysharp.Workflow;
  use Osysharp.ServiceHours;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Organizations.ServicePlans;
using Osysharp.WorkQueues;
using Osysharp.HumanRequests;
using Osysharp.Workflow;

[Role] enum ShopRole { Authenticator, Printer, Manager }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsPrinter => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Printer);
policy IsManager => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Manager);

// The inbox's questions and the kit's two, answered.
policy WorksInbox            => IsPrinter || IsManager;
policy LeadsInbox            => IsManager;
policy AdministersInbox      => IsManager;
policy ManagesOrganizations  => IsManager;
policy CollaboratesOnRecords => IsPrinter || IsManager;
policy AsksPeople            => IsPrinter || IsManager;
policy OverseesRequests      => IsManager;

entity Person : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}
partial entity WorkItem { security { allow read when IsPrinter || IsManager; } }
partial entity ServiceHours { security { allow read when IsAuthenticated; } }
partial entity ServiceWindow { security { allow read when IsAuthenticated; } }
partial entity ServiceException { security { allow read when IsAuthenticated; } }

public enum ProofStage { Drafted, WithTheManager, Printed, SentBack }

/// A proof of something to print. Every change to it is a new version.
entity Proof {
  [Required("Name the job."), MaxLength(120)] string Job;
  int Copies = 100;
  int Version = 1;
  ProofStage Stage = ProofStage.Drafted;
  bool Printed = false;
  [MaxLength(400)] string Note = "";
  security { allow read, create, update when IsPrinter || IsManager; }
}

/// What the customer is asked for before a job is delivered.
public class DeliveryDetails {
  /// Where the boxes go.
  public string Address = "";
  /// How many boxes we may leave without a signature.
  public int UnattendedBoxes;
  /// Anything the driver should know.
  public string? Notes;
}

public Subject ProofAsShown(Proof p) { return Subject.Of("Proof", p.Id, p.Job, version: "v" + p.Version.ToString()); }

/// Ask the manager to approve a proof as it stands.
public HumanRequest AskToPrint(Proof p, Person manager) {
  return PutRequest(new NewRequest {
    Kind = RequestKind.Approval, To = manager, Title = "Print " + p.Copies + " of " + p.Job + "?",
    Facts = [new RequestFact("Copies", p.Copies.ToString()), new RequestFact("Version", p.Version.ToString())],
    About = ProofAsShown(p), WaitingWork = Subject.Of("Proof", p.Id, p.Job),
  });
}

/// Print it — covered by the approval of this version, once.
public void Print(Proof p, HumanRequest approval) {
  UseApproval(approval, "v" + p.Version.ToString());
  p.Printed = true;
}

/// A proof's way to the press: the manager is asked, the process waits, and what it does next is what she said.
workflow ProofFlow {
  Tracks = Proof.Stage; Autostart = false; Initial = WithTheManager;
  state WithTheManager {
    enter {
      var request = Workflow.Once("ask", () => AskToPrint(this.Item, Person.Single(x => x.Email == "[email protected]")));
      var answer = await Requests.Answer(request);
      if (answer.Approved) {
        UseApproval(request, "v" + this.Item.Version.ToString());
        this.Item.Printed = true;
        goto Printed;
      }
      this.Item.Note = answer.Comment;
      goto SentBack;
    }
  }
  terminal success Printed { }
  terminal success SentBack { }
}

app.Inbox = new InboxSetup { DefaultQueue = "Shop" };

[TestFixture]
void Shop() {
  var noor = new Person { Email = "[email protected]", DisplayName = "Noor" };
  var ida = new Person { Email = "[email protected]", DisplayName = "Ida" };
  new RoleGrant { Member = noor, Role = ShopRole.Printer };
  new RoleGrant { Member = ida, Role = ShopRole.Manager };
  new OrganizationTier { Name = "Standard", Rank = 1, IsDefault = true };
  var shop = new Queue { Name = "Shop", Position = 1 };
  new QueueMember { Queue = shop, Member = noor };
  new QueueMember { Queue = shop, Member = ida };
  new Proof { Job = "Spring poster", Copies = 400 };
}
principal Noor => Person.Single(p => p.Email == "[email protected]");
principal Ida => Person.Single(p => p.Email == "[email protected]");
Proof Poster() { return Proof.Single(p => p.Job == "Spring poster"); }

[Test(Shop)]
void an_approval_covers_the_version_that_was_approved_and_nothing_it_became() {
  Guid id = Guid.Empty;
  runas (Noor) { id = AskToPrint(Poster(), Ida).Id; }
  runas (Ida) {
    Assert.Equal(["Print 400 of Spring poster?"], Requests.Mine().Select(r => r.Title).ToList());
    AnswerRequest(Requests.Mine().Single(), RequestAnswer.Approve());
  }
  runas (Noor) {
    // The proof is changed after her yes: 400 copies became 4,000.
    var poster = Poster();
    poster.Copies = 4000;
    poster.Version = poster.Version + 1;
    var refused = Assert.Throws<ApprovalNoLongerApplies>(() => Print(Poster(), HumanRequest.Single(r => r.Id == id)));
    Assert.Equal("Spring poster has changed since it was approved, so the approval no longer applies. Ask again, about it as it is now.", refused.Message);
    Assert.False(Poster().Printed);
  }
}

[Test(Shop)]
void whoever_asked_cannot_approve_it_themselves() {
  runas (Noor) {
    var request = AskToPrint(Poster(), Ida);
    var refused = Assert.Throws<RequestRefused>(() => AnswerRequest(request, RequestAnswer.Approve()));
    Assert.Equal("You asked this, so it is not yours to answer. It waits for Ida.", refused.Message);
  }
}

[Test(Shop)]
void a_process_waits_a_month_for_a_person_and_goes_on_with_the_answer() {
  runas (Noor) {
    var poster = Poster();
    Workflow.Run(poster);
    Workflow.Settle(poster);
    Assert.Equal(ProofStage.WithTheManager, poster.Stage);
  }
  TestClock.Advance(TimeSpan.FromDays(30));
  runas (Ida) {
    var mine = Requests.Mine().Single();
    AnswerRequest(mine, RequestAnswer.Reject("The date on it is last year's."));
    Workflow.Settle(mine);
  }
  runas (Noor) {
    Workflow.Settle(Poster());
    Assert.Equal(ProofStage.SentBack, Poster().Stage);
    Assert.Equal("The date on it is last year's.", Poster().Note);
    Assert.False(Poster().Printed);
  }
}

[Test(Shop)]
void a_request_for_the_members_of_a_class_comes_back_as_the_class() {
  Guid id = Guid.Empty;
  runas (Noor) {
    var ask = NewRequest.For<DeliveryDetails>();
    ask.To = Ida;
    ask.Title = "Where does the spring poster go?";
    var request = PutRequest(ask);
    id = request.Id;
    var fields = JsonSerializer.Deserialize<List<RequestField>>(request.FieldsJson);
    Assert.Equal(["Address", "Unattended boxes", "Notes"], fields.Select(f => f.Label).ToList());
    Assert.Equal("Where the boxes go.", fields[0].Help);
  }
  runas (Ida) {
    var missing = Assert.Throws<ValidationException>(() => AnswerRequest(HumanRequest.Single(r => r.Id == id), new RequestAnswer()));
    Assert.Equal("“Address” is needed.", missing.Message);
    AnswerRequest(HumanRequest.Single(r => r.Id == id), RequestAnswer.Of(new DeliveryDetails { Address = "Dock 4, Varvsgatan 12", UnattendedBoxes = 2 }));
  }
  runas (Noor) {
    var details = OutcomeOf(HumanRequest.Single(r => r.Id == id)).As<DeliveryDetails>();
    Assert.Equal("Dock 4, Varvsgatan 12", details.Address);
    Assert.Equal(2, details.UnattendedBoxes);
    Assert.Null(details.Notes);
  }
}

Notes#

  • Sending is the app's. The kit raises HumanRequestPut and HumanRequestReminded; whether that becomes a mail, a text message or a bell is the app's [On] handler, with the kit it already uses for it.
  • A request is put in the caller's unit of work. It exists when that commits — so a request about a record that was never saved is never put. Call a request off in a later request, not in the one that put it.
  • Instants are shown as stored (UTC) on the kit's screens until the platform says a viewer's own zone.
  • An assignment lists the requests attached to it when the app also uses the bridge Osysharp.Assignments.WorkQueues.

See also#

Related

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

`Osysharp.WorkQueues` puts any item an app derives from `WorkItem` — a ticket, an approval, a lead — into queues with…

Workflow.Run (start a workflow)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start…

Workflow.InRun (can this code wait?)

`Workflow.InRun` is true in a workflow's body and in everything that body calls — where `await Workflow.Run(…)` may…

Tasks that wait (Task.WhenAll across days)

In a workflow body, a call started as a task may wait — on a child workflow run that takes days, a person's answer —…

grants (a link that opens one row)

A `grant` is a link the platform signs that lets whoever holds it reach ONE row of ONE entity, for a while, without…

ServiceHours (SLA-accrual windows)

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

Assignments — a piece of work owned until its outcome is reached (Assignments kit)

An assignment of the `Osysharp.Assignments` kit says who asked, the outcome wanted and what must be true for it to be…

Asking for help — an agent that consults a more capable model (Agents kit)

An agent can name one of the app's models to consult. The model doing the work may then ask it one focused question…

Agents (a class with a job, its own account, a budget and a record)

An agent is a class of the `Osysharp.Agents` kit: a purpose, the skills it holds and what it may spend. A skill is a…