wf-expense-hitl
wf-expense-hitl — the SLOT-DEPENDENCY workflow demo (ExpenseApproval).
7 source files2 test files

Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-expense-hitl $ osy launch
The app
app.osy8 lines
// wf-expense-hitl — the SLOT-DEPENDENCY workflow demo (ExpenseApproval). app WfExpenseHitl { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/actions.osy14 lines
// The board's verbs, as ordinary app functions. void DecideManager(Expense e, Decision decision) { ExpenseApproval.For(e).Manager.Approve(decision); } void DecideFinance(Expense e, Decision decision) { ExpenseApproval.For(e).Finance.Approve(decision); } void DecideCfo(Expense e, Decision decision) { ExpenseApproval.For(e).Cfo.Approve(decision); } void RaiseExpense(decimal amount) { var me = Session.CurrentUser; new Expense { Employee = me, Amount = amount }; UnitOfWork.Commit(); }
model/expense_approval.osy65 lines
// EXPENSE APPROVAL — slot dependencies (`After` / `When`). Manager + Finance open in parallel; the CFO slot opens ONLY // after both are satisfied, and only exists for large expenses. Faithful to workflow_design/approvals.osy. enum Decision { Approve, Reject } enum ExpenseStatus { Approvals, Approved, Rejected } entity Expense { [Required] User Employee; decimal Amount; ExpenseStatus Status; User RejectedBy; [ForeignKey(Expense)] Approval[] Approvals; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Approval { [Required] Expense Expense; [Required] User By; DateTime At; security { allow read when IsAuthenticated; allow create when IsAuthenticated; } } workflow ExpenseApproval { Tracks = Expense.Status; Autostart = true; Initial = Approvals; event Approve(Decision decision); state Approvals { subscribe Approve(Decision decision) as Manager { Assignee = this.Item.Employee.Manager; Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Rejected; } } } subscribe Approve(Decision decision) as Finance { Candidates = u => u.Department == Dept.Finance; Assigned { Within = TimeSpan.FromHours(4); } Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Rejected; } } } subscribe Approve(Decision decision) as Cfo { When = this.Item.Amount > 10000; After = [Manager, Finance]; Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Cfo) && u != Manager.Assignee; Finished { Within = TimeSpan.FromDays(3); Unfinished { goto Rejected; } } } on Approve(Decision decision, Slot slot) { when (decision == Decision.Reject) { this.Item.RejectedBy = slot.Assignee; goto Rejected; // any rejection ends it — the CFO never opens } default { new Approval { Expense = this.Item, By = slot.Assignee, At = DurableClock.Now }; } } on Complete { goto Approved; } // every slot that EXISTS is satisfied } terminal success Approved { } terminal error Rejected { Message = "expense rejected"; } }
model/identity.osy62 lines
// The demo's identity model — WHO the people are, and on what authority. // ⚠ `Executive` exists so the CFO has somewhere to WORK that is not the pool she signs off. A department is where // you work; the office is the grant. enum Dept { Legal, Finance, Engineering, Executive } [Role] enum AppRole { Authenticator, Member, Cfo } [Principal] entity User { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; Dept Department = Dept.Legal; User Manager; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; allow create when IsCfo; allow update when IsCfo; 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 IsCfo; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsCfo => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Cfo); [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, };
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.osy132 lines
// THE BOARD — and what it has to show is not "an expense", it is WHICH SLOT IS OPEN TO WHOM, and which one exists // but is not open yet. That last state is what this demo is for: a `Cfo` slot waiting on `After = [Manager, Finance]` // is visible and PENDING, with no clock running, and it does not exist at all below the `When` threshold. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Expense approvals")] component Board() { live var work = Workflow.Work<Expense>() .Include(r => r.Item) .Include(r => r.Item.Employee) .OrderBy(r => r.Remaining); live var mine = Workflow.Inbox<Expense>(); live var expenses = Expense.Include(e => e.Employee).OrderByDescending(e => e.Amount).ToList(); live var people = User.ToList(); var me = Session.CurrentUser; decimal amount = 50000; action ApproveManager(Expense e) { DecideManager(e, Decision.Approve); } action RejectManager(Expense e) { DecideManager(e, Decision.Reject); } action ApproveFinance(Expense e) { DecideFinance(e, Decision.Approve); } action RejectFinance(Expense e) { DecideFinance(e, Decision.Reject); } action ApproveCfo(Expense e) { DecideCfo(e, Decision.Approve); } action RejectCfo(Expense e) { DecideCfo(e, Decision.Reject); } action Raise() { RaiseExpense(amount); } 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("Expense 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 (IsCfo) { Text("CFO office", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } 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("Approval slots", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("Every slot on every expense — open, pending, and who each one belongs to.", fontSize: FontSize.Caption, color: Colors.TextSecondary); if (work.Count() == 0) { Text("Nothing is waiting. Raise an expense below to start a chain.", 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.Employee.Name + "'s expense · " + r.SlotAlias, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text(r.Item.Amount + " · " + r.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (r.Status == SlotStatus.Pending) { Text("pending — waiting on manager + finance", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning); } else if (mine.Any(m => m.SlotId == r.SlotId)) { Row(gap: 2) { if (r.SlotAlias == "Manager") { Button("Approve", onPress: () => ApproveManager(r.Item), tone: Tone.Success); Button("Reject", onPress: () => RejectManager(r.Item), tone: Tone.Danger); } if (r.SlotAlias == "Finance") { Button("Approve", onPress: () => ApproveFinance(r.Item), tone: Tone.Success); Button("Reject", onPress: () => RejectFinance(r.Item), tone: Tone.Danger); } if (r.SlotAlias == "Cfo") { Button("Approve", onPress: () => ApproveCfo(r.Item), tone: Tone.Success); Button("Reject", onPress: () => RejectCfo(r.Item), tone: Tone.Danger); } } } else if (r.Assignee != null) { Text("with " + people.Single(u => u.Id == r.Assignee).Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); } else { Text("open — not yours", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } Stack(gap: 2) { Text("All expenses", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); foreach (var e in expenses) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 1) { Text(e.Employee.Name + " · " + e.Amount, fontSize: FontSize.Body, fontWeight: FontWeight.Medium); Text(e.Status + (e.Amount > 10000 ? " · needs a CFO" : " · under the CFO threshold"), fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Raise an expense", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text("Over 10,000 grows a CFO slot that does not exist below it — that threshold is the slot's `When`.", fontSize: FontSize.Caption, color: Colors.TextSecondary); Row(gap: 3, align: Align.End) { Stack(gap: 1, grow: 1) { Text("Amount", fontSize: FontSize.Caption, color: Colors.TextSecondary); Input(value: amount, label: "Amount", placeholder: "50000"); } Button("Raise", onPress: Raise, tone: Tone.Primary); } } } } } } } }
model/pages/login.osy62 lines
// Sign-in. Four people, three different reasons to be able to act — sign in as each and the board is a different // board. That IS the demo, so being able to switch quickly is the page's only job. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Expenses — sign in")] component LoginPage() { string email = "mia@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("Expense approvals", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("A manager, finance and — over 10,000 — the CFO. Sign in as each to see the chain.", 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", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: email, label: "Email", placeholder: "you@acme.test"); } Stack(gap: 1) { Text("Password", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: password, label: "Password", 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("mia@acme.test — Liam's MANAGER (an org relation)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("otto@acme.test — Finance DEPARTMENT (an org attribute)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("cleo@acme.test — holds the Cfo GRANT (an authority tier)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("liam@acme.test — the employee; raises expenses, approves none", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }