wf-approvals
A purchase-order approval workflow — it parks and waits for a human at each step.
7 source files2 test files

Get it
$ osy init wf-approvals $ osy launch
The app
app.osy9 lines
// A purchase-order approval workflow — it parks and waits for a human at each step. // The core human-in-the-loop / multi-wait workflow demo (PoApproval). app WfApprovals { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/actions.osy26 lines
// The board's verbs, as ordinary app functions. void ClaimLegal(PurchaseOrder po) { PoApproval.For(po).Legal.Claim(); } void ReleaseLegal(PurchaseOrder po) { PoApproval.For(po).Legal.Release(); } void DecideLegal(PurchaseOrder po, Decision decision, string reason) { PoApproval.For(po).Legal.Approve(decision, reason); } void DecideFinance(PurchaseOrder po, Decision decision, string reason) { PoApproval.For(po).Finance.Approve(decision, reason); } void CancelOrder(PurchaseOrder po) { PoApproval.RaiseCancel(po); } void SubmitOrder(PurchaseOrder po) { PoApproval.RaiseSubmit(po); } void RaiseOrder(string title, decimal total, PoCategory category) { var me = Session.CurrentUser; var account = Account.First(); var po = new PurchaseOrder { Title = title, Total = total, Category = category, Requester = me, Account = account }; PoApproval.RaiseSubmit(po); }
model/identity.osy68 lines
// The demo's identity model — WHO the people are, and on what authority. enum Dept { Legal, Finance } [Role] enum AppRole { Authenticator, Member, Support } [Principal] entity User { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; Dept Department = Dept.Legal; int Seniority; bool IsLead; bool OnLeave; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; allow create when IsSupport; allow update when IsSupport; deny read PasswordHash when !IsAuthenticator; // nobody but the auth flow ever sees the hash } } entity RoleGrant { [Required] User Grantee; [Required] AppRole Level = AppRole.Member; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant allow create, update, delete when IsSupport; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsSupport => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Support); [AuthMethod] string Signup(string email, string password) { var u = new User { Name = email, Email = email, PasswordHash = Security.HashPassword(password) }; var grant = new RoleGrant { Grantee = u, Level = AppRole.Member }; return Security.IssueJwt(u.Id, u.Email); } [AuthMethod] string Login(string email, string password) { var u = User.Where(x => x.Email == email).FirstOrDefault(); if (u == null) { Security.VerifyPassword(password); return ""; } if (u.PasswordHash == null) { return ""; } if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, }; entity Account { [Required, MaxLength(40)] string Code; [Required] User Owner; security { allow read when IsAuthenticated; allow create when IsSupport; allow update when IsSupport; } }
model/po_approval.osy123 lines
// PO APPROVAL — the core HITL surface (faithful to workflow_design/approvals.osy). A pool slot + a pre-assigned slot, // an org SLA declared once and cascaded, reminders, a breach that widens or escalates, and cancel-from-anywhere. enum PoStatus { Draft, Review, Approved, Rejected, Cancelled, Escalated } enum Decision { Approve, Reject } enum PoCategory { Capex, Opex, Services } entity PurchaseOrder { [Required, MaxLength(200)] string Title; PoStatus Status = PoStatus.Draft; PoCategory Category = PoCategory.Capex; decimal Total; [Required] User Requester; [Required] Account Account; [MaxLength(500)] string? RejectionReason; User ReviewedBy; TimeSpan StateExpire; TimeSpan OverallDeadline; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } /// The external config a workflow is parameterised by — app data, editable by ops. Start {} snapshots the row. entity ApprovalPolicy { PoCategory Category = PoCategory.Capex; TimeSpan StateExpire; TimeSpan OverallDeadline; security { allow read when IsAuthenticated; allow create when IsSupport; allow update when IsSupport; } } void Notify(User u) { Log.Information($"PO approval reminder — nudging {u.Id}"); } workflow PoApproval { Tracks = PurchaseOrder.Status; Autostart = this.Item.Total > 10000; Initial = Draft; Start { var policy = ApprovalPolicy.Single(p => p.Category == this.Item.Category); this.Item.StateExpire = policy.StateExpire; this.Item.OverallDeadline = policy.OverallDeadline; } Expire = this.Item.StateExpire; // DEFAULT deadline for EACH state → `on Expire` Deadline = this.Item.OverallDeadline; // the WHOLE INSTANCE → `on Deadline` Assigned { Within = TimeSpan.FromHours(4); Remind Nudge(After = Within / 2, ThenEvery = TimeSpan.FromHours(1)) { foreach (var u in slot.Candidates) { Notify(u); } } Unassigned { // nobody picked it up — widen to the lead, keep waiting var lead = User.Single(u => slot.Candidates(u) && u.IsLead); if (lead == null || lead.OnLeave) return; // no goto → keep waiting, keep reminding slot.Assign(lead); } } Finished { Within = TimeSpan.FromHours(8); Remind Nudge(After = Within / 2) { Notify(slot.Assignee); } // one nudge to the assignee (a single principal) Unfinished { goto Escalated; } // goto → the wait is over } event Approve(Decision decision, string reason); [Authorize(u => u == this.Item.Requester || RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Support))] event Cancel(); on Cancel { goto Cancelled; } on Deadline { goto Cancelled; } // region: draft-state state Draft { subscribe Submit(); on Submit { goto Review; } } // endregion state Review { Expire = TimeSpan.FromDays(2); subscribe Approve(Decision decision, string reason) as Legal { Candidates = u => u.Department == Dept.Legal && u != this.Item.Requester; } subscribe Approve(Decision decision, string reason) as Finance { Candidates = u => u.Department == Dept.Finance; Assignee = this.Item.Account.Owner; Finished { Within = TimeSpan.FromDays(2); } // override just the clock; the rest cascades } on Legal(Decision decision, string reason) { when (decision == Decision.Reject) { this.Item.RejectionReason = reason; goto Rejected; } default { this.Item.ReviewedBy = Legal.Assignee; // Legal approved → recorded, still waiting on Finance } } on Finance(Decision decision, string reason) { when (decision == Decision.Reject) { this.Item.RejectionReason = reason; goto Rejected; } } on Complete { goto Approved; } // default completion = ALL slots satisfied } event Submit(); terminal success Approved { } terminal cancel Cancelled { Message = "purchase order cancelled"; } terminal error Rejected { Message = "purchase order rejected"; } terminal error Escalated { Message = "approval SLA breached — escalated to a human"; } }
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical `model/theme.osy`, and that test is what keeps them identical. // // ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point. // The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`, // `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own // `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment // one of them reached for a kit control, that control painted itself indigo next to the demo's // blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that // existed only in the theme file. // // So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name // the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it // does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything. // // Excluded by design — these five own their look and must NOT adopt this file: arcade, ember, // motion, gestures (each demonstrates a visual world) and Apps/recall-osy. theme Demo { Colors { // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a // considered app rather than an unstyled one, and the two should be distinguishable on sight. // It is the one saturated colour on the page; everything else is warm neutral, which is what // keeps "distinct" from turning into "loud". Primary = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6")); OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F"); Bg = Modes.Of(light: "#FAF9F7", dark: "#101317"); // the page — a touch warm, so cards read as lifted Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22"); // a card, a row, a lane Muted = Modes.Of(light: "#EFEEEA", dark: "#1E242B"); // an inline notice, a disabled field Border = Modes.Of(light: "#E4E2DC", dark: "#28303A"); OnBg = Modes.Of(light: "#14181D", dark: "#E7EBF0"); OnSurface = Modes.Of(light: "#14181D", dark: "#E7EBF0"); TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3"); // labels, timestamps, captions TextMuted = Modes.Of(light: "#7C848E", dark: "#7E8894"); // the quietest text on the page Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A")); Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343")); Danger = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A")); } // Names the kit does not have, so these are extensions rather than second spellings. Radius { Control = "8px"; Card = "12px"; } // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans` // shadows the kit's own token — so every kit control picks it up with no page edit at all. // Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font // add`), because a webfont a page merely NAMES is a webfont that silently falls back. // Only the faces the app SHIPS are named here — Mono is left at the kit default because no // demo renders code, and naming an unshipped family is a silent fallback. // Geist for the UI: a neutral grotesque with real character at small sizes, which is where a // dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page // title. The pairing is the whole look — one voice for reading, one for announcing. Font { Sans = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif"; Serif = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif"; } FontSize { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; } // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. }
model/pages/board.osy159 lines
// THE BOARD — and its job is not to move you through the flow quickly. Its job is to show that the SAME order looks // different to different people, and that the difference comes from rules the workflow already declares. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Purchase approvals")] component Board() { live var work = Workflow.Work<PurchaseOrder>() .Include(r => r.Item) .Include(r => r.Item.Requester) .OrderBy(r => r.Remaining); live var orders = PurchaseOrder.Include(p => p.Requester).OrderByDescending(p => p.Total).ToList(); live var people = User.ToList(); var me = Session.CurrentUser; string title = "New laptops"; decimal total = 25000; action Claim(PurchaseOrder po) { ClaimLegal(po); } action Release(PurchaseOrder po) { ReleaseLegal(po); } action ApproveLegal(PurchaseOrder po) { DecideLegal(po, Decision.Approve, "looks fine"); } action RejectLegal(PurchaseOrder po) { DecideLegal(po, Decision.Reject, "rejected by legal"); } action ApproveFinance(PurchaseOrder po) { DecideFinance(po, Decision.Approve, "budgeted"); } action RejectFinance(PurchaseOrder po) { DecideFinance(po, Decision.Reject, "not in budget"); } action Cancel(PurchaseOrder po) { CancelOrder(po); } action Submit(PurchaseOrder po) { SubmitOrder(po); } action Raise() { RaiseOrder(title, total, PoCategory.Capex); } action SignOut() { Session.SignOut(); } render { Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) { Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "980px", px: 5, h: "60px") { Text("Purchase approvals", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(align: Align.Center, gap: 3) { Text(me.Name + " · " + me.Department, fontSize: FontSize.Caption, color: Colors.TextSecondary); if (IsSupport) { Text("Support", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text("Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); } Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") { Stack(gap: 5, w: "100%", maxW: "980px", p: 5) { Stack(gap: 2) { Text("Open work", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("Every live approval slot, whoever holds it — and what YOU can do about each one.", fontSize: FontSize.Caption, color: Colors.TextSecondary); if (work.Count() == 0) { Text("Nothing is waiting. Raise an order below to start one.", fontSize: FontSize.Caption, color: Colors.TextSecondary); } foreach (var r in work) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) { Stack(gap: 1) { Text(r.Item.Title + " · " + r.SlotAlias, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text(r.Item.Total + " · raised by " + r.Item.Requester.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (r.SlotAlias == "Legal" && r.Assignee == null) { if (PoApproval.For(r.Item).Legal.Candidates(me)) { Button("Claim", onPress: () => Claim(r.Item), tone: Tone.Primary); } else { Text("open — not yours to claim", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } if (r.SlotAlias == "Legal" && r.Assignee != null) { if (r.Assignee == me.Id) { Row(gap: 2) { Button("Approve", onPress: () => ApproveLegal(r.Item), tone: Tone.Success); Button("Reject", onPress: () => RejectLegal(r.Item), tone: Tone.Danger); Pressable(onClick: () => Release(r.Item)) { Text("release", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } else { Text("held by " + people.Single(u => u.Id == r.Assignee).Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } if (r.SlotAlias == "Finance") { if (r.Assignee == me.Id) { Row(gap: 2) { Button("Approve", onPress: () => ApproveFinance(r.Item), tone: Tone.Success); Button("Reject", onPress: () => RejectFinance(r.Item), tone: Tone.Danger); } } else { Text("with " + people.Single(u => u.Id == r.Assignee).Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } } Stack(gap: 2) { Text("All orders", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); foreach (var po in orders) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) { Stack(gap: 1) { Text(po.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium); Text(po.Status + " · " + po.Category + " · " + po.Total + " · raised by " + po.Requester.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); if (po.RejectionReason != null) { Text("Reason: " + po.RejectionReason, fontSize: FontSize.Caption, color: Colors.Danger); } } Row(gap: 2, align: Align.Center) { if (po.Status == PoStatus.Draft) { Button("Submit for approval", onPress: () => Submit(po), tone: Tone.Primary); } if (po.Status == PoStatus.Review && (po.Requester == me || IsSupport)) { Button("Cancel", onPress: () => Cancel(po), tone: Tone.Danger); } } } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Raise an order", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text("Over 10,000 autostarts the approval workflow — that threshold is the workflow's `Autostart`.", fontSize: FontSize.Caption, color: Colors.TextSecondary); Row(gap: 3, align: Align.End) { Stack(gap: 1, grow: 1) { Text("Title", fontSize: FontSize.Caption, color: Colors.TextSecondary); Input(value: title, label: "Title", placeholder: "What is being bought"); } Stack(gap: 1) { Text("Total", fontSize: FontSize.Caption, color: Colors.TextSecondary); Input(value: total, label: "Total", placeholder: "25000"); } Button("Raise", onPress: Raise, tone: Tone.Primary); } } } } } } } }
model/pages/login.osy64 lines
// Sign-in. The demo ships several people in different departments and tiers, so this page's job is to let you be a // DIFFERENT one — the whole subject is that the board looks different depending on who you are. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Approvals — sign in")] component LoginPage() { string email = "lena@acme.test"; string password = "demo1234"; string problem = ""; action SignIn() { var ticket = Login(email, password); if (ticket == "") { problem = "That email and password don't match anyone."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "420px") { Stack(gap: 1) { Text("Purchase approvals", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("Sign in as different people to see the same order treated differently.", fontSize: FontSize.Body, color: Colors.TextSecondary); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Stack(gap: 1) { Text("Email", labelFor: emailBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: email, id: emailBox, placeholder: "you@acme.test"); } Stack(gap: 1) { Text("Password", labelFor: passwordBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: password, id: passwordBox, type: "password"); } if (problem != "") { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Text(problem, fontSize: FontSize.Caption, color: Colors.Danger); } } Button("Sign in", onPress: SignIn, tone: Tone.Primary); } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 2) { Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Text("lena@acme.test — Legal, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("liam@acme.test — Legal, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("lars@acme.test — Legal lead, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("otto@acme.test — Finance, Member (owns the account)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("bea@acme.test — raises the orders, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("ops@acme.test — Support tier", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }