From d8ecd24655d9182be08bc7ea464c17456af71a07 Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 16:49:32 +0700 Subject: [PATCH 1/8] feat(autopilot): ticket flow definition, validator and versioned per-tenant storage TicketFlowDefinition (ordered step nodes + pass/fail edges) with the safety invariants, workspaces.ticket_flows (append-only versions, RLS) and the ticket flow snapshot on sessions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Agents/TicketFlowDefinition.cs | 460 ++++++++++++++++++ .../Entities/RemoteSessionEntity.cs | 5 + .../Persistence/ISessionRepository.cs | 3 + ...024091337_AddTicketFlowVersion.Designer.cs | 246 ++++++++++ .../20261024091337_AddTicketFlowVersion.cs | 30 ++ .../SessionsDbContextModelSnapshot.cs | 3 + .../Repositories/SessionRepository.cs | 9 + src/AgentOs.Modules.Tenants/IAuditLog.cs | 1 + .../Entities/TicketFlowVersionEntity.cs | 31 ++ .../Persistence/ITicketFlowStore.cs | 39 ++ .../20261024091332_AddTicketFlows.Designer.cs | 256 ++++++++++ .../20261024091332_AddTicketFlows.cs | 56 +++ .../WorkspacesDbContextModelSnapshot.cs | 46 ++ .../Repositories/TicketFlowStores.cs | 136 ++++++ .../Persistence/WorkspacesDbContext.cs | 20 + .../WorkspacesModule.cs | 3 + .../Persistence/RowLevelSecurity.cs | 2 +- .../AgentOsUi/RowLevelSecurityRealDbTests.cs | 2 +- .../Persistence/RowLevelSecurityTests.cs | 3 +- 19 files changed, 1348 insertions(+), 3 deletions(-) create mode 100644 src/AgentOs.Modules.Pipeline/Agents/TicketFlowDefinition.cs create mode 100644 src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.Designer.cs create mode 100644 src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.cs create mode 100644 src/AgentOs.Modules.Workspaces/Persistence/Entities/TicketFlowVersionEntity.cs create mode 100644 src/AgentOs.Modules.Workspaces/Persistence/ITicketFlowStore.cs create mode 100644 src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.Designer.cs create mode 100644 src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.cs create mode 100644 src/AgentOs.Modules.Workspaces/Persistence/Repositories/TicketFlowStores.cs diff --git a/src/AgentOs.Modules.Pipeline/Agents/TicketFlowDefinition.cs b/src/AgentOs.Modules.Pipeline/Agents/TicketFlowDefinition.cs new file mode 100644 index 00000000..0aa8044e --- /dev/null +++ b/src/AgentOs.Modules.Pipeline/Agents/TicketFlowDefinition.cs @@ -0,0 +1,460 @@ +// The ticket flow as data (ADR-0016): the per-ticket graph Autopilot runs — Code → Checks → Review → Test → Merge by +// default — is a definition the owner sees and edits in the Workflow app (Workflow › Ticket flow). Both engines run it: +// the MAF workflow (TicketWorkflow builds its graph from it) and the step-by-step rules (AutopilotPlanner skips the +// steps it leaves out for a ticket). A definition is ordered step nodes joined by conditional edges: "pass" leads on +// to the next step, "fail" sends the change back to Code (bounded by the task's persisted fix rounds against +// Settings › Autopilot › Automatic fix rounds — there is no second counter). +// +// Safety invariants (TicketFlowValidator — checked on save AND when a run loads it; a definition that breaks one is +// refused with a plain-language reason and the run falls back to Default): it starts at Code, ends at Merge, Checks +// always runs before Merge, every ticket passes at least one merge gate (Review / Test) before Merge whatever its +// labels, no cycle except a fail edge back to Code, every step reachable, steps in their natural order. +// +// Adding a step kind (e.g. the upcoming Design/screenshot gate) is local: one TicketStepKind value, one +// TicketStepKinds entry (rank, agent roles, whether it is a merge gate / can send a change back), one executor in +// TicketWorkflow and — if the step-by-step rules should run it — one move in AutopilotPlanner. + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AgentOs.Modules.Pipeline.Agents; + +/// What a step of a ticket's flow does. Values are stored by name — never rename one. +public enum TicketStepKind +{ + /// The Coding (tickets) agent implements the ticket (and every fix sent back to it). + Code, + + /// The pull request's automated checks (CI) and whether it merges cleanly. + Checks, + + /// The Reviewer agent reviews the change. + Review, + + /// The Testing (tickets) agent tests the change against the ticket's acceptance criteria. + Test, + + /// The change is merged (or left for a person when the board doesn't auto-merge). + Merge, +} + +/// A step kind's fixed traits — the one place a new kind is declared. +/// The kind. +/// Its natural position: steps run in rank order (Code first, Merge last). +/// The name people see. +/// One sentence: what the step does. +/// The agent roles ( keys) that can run it, the default first; empty = no agent. +/// Always runs, for every ticket: can't be turned off or limited by labels. +/// A failure sends the change back to Code (a fix round). +/// Counts as a quality gate: every ticket passes at least one before Merge. +public sealed record TicketStepKindInfo(TicketStepKind Kind, int Rank, string Label, string Description, IReadOnlyList Roles, + bool Required, bool CanSendBack, bool IsMergeGate) +{ + /// The agent role that runs the step when its node names none. + public string? DefaultRole => Roles.Count > 0 ? Roles[0] : null; +} + +/// The registry of step kinds. +public static class TicketStepKinds +{ + /// Every kind, in rank order. + public static IReadOnlyList All { get; } = + [ + new(TicketStepKind.Code, 0, "Code", "The Coding (tickets) agent implements the ticket and pushes a pull request; every fix comes back here.", + [AgentRoles.IssueWork], Required: true, CanSendBack: false, IsMergeGate: false), + new(TicketStepKind.Checks, 10, "Checks", "Waits for the pull request's automated checks; red checks or a conflict go back to Code.", + [], Required: true, CanSendBack: true, IsMergeGate: false), + new(TicketStepKind.Review, 20, "Review", "The Reviewer agent reviews the change; must-fix findings go back to Code.", + [AgentRoles.Reviewer], Required: false, CanSendBack: true, IsMergeGate: true), + new(TicketStepKind.Test, 30, "Test", "The Testing (tickets) agent tests the change against the ticket's acceptance criteria; failures go back to Code.", + [AgentRoles.Acceptance], Required: false, CanSendBack: true, IsMergeGate: true), + new(TicketStepKind.Merge, 100, "Merge", "Merges the change — or leaves it ready for a person when the project doesn't auto-merge.", + [], Required: true, CanSendBack: false, IsMergeGate: false), + ]; + + private static readonly FrozenDictionary ByKind = All.ToFrozenDictionary(k => k.Kind); + + /// The traits of . + public static TicketStepKindInfo Of(TicketStepKind kind) => + ByKind.TryGetValue(kind, out var info) ? info : throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ticket step kind."); + + /// The merge gates' names, joined for a sentence ("neither Review nor Test"). + public static string NeitherGate() + { + var gates = All.Where(k => k.IsMergeGate).Select(k => k.Label).ToList(); + return gates.Count switch + { + 0 => "no gate", + 1 => "no " + gates[0], + 2 => $"neither {gates[0]} nor {gates[1]}", + _ => "none of " + string.Join(", ", gates), + }; + } + + /// The merge gates' names as alternatives ("Review or Test"). + public static string EitherGate() => string.Join(" or ", All.Where(k => k.IsMergeGate).Select(k => k.Label)); +} + +/// One step of a ticket flow. +/// Stable id within the definition (edges name it). +/// What the step does. +/// Off = every ticket passes through it. +/// The agent role ( key) that runs it; null = the kind's default. +/// Tickets with any of these labels pass through the step. +/// When set, only tickets with one of these labels run the step. +public sealed record TicketFlowNode(string Id, TicketStepKind Kind, bool Enabled = true, string? Role = null, + IReadOnlyList? SkipLabels = null, IReadOnlyList? OnlyLabels = null) +{ + /// The kind's traits. + [JsonIgnore] public TicketStepKindInfo Info => TicketStepKinds.Of(Kind); + + /// The agent role that runs the step (its own or the kind's default), or null for a step with no agent. + [JsonIgnore] public string? EffectiveRole => Role ?? Info.DefaultRole; + + /// Skip labels, never null. + [JsonIgnore] public IReadOnlyList Skip => SkipLabels ?? []; + + /// Only labels, never null. + [JsonIgnore] public IReadOnlyList Only => OnlyLabels ?? []; + + /// True when a ticket with runs this step. + public bool AppliesTo(IReadOnlyCollection labels) + { + ArgumentNullException.ThrowIfNull(labels); + if (!Enabled) { return false; } + if (Skip.Any(s => labels.Contains(s, StringComparer.OrdinalIgnoreCase))) { return false; } + return Only.Count == 0 || Only.Any(o => labels.Contains(o, StringComparer.OrdinalIgnoreCase)); + } + + /// Who runs it and for which tickets, in a few words ("Reviewer · not type:chore"). + public string Conditions() + { + var parts = new List(); + if (Only.Count > 0) { parts.Add("only " + string.Join(", ", Only)); } + if (Skip.Count > 0) { parts.Add("not " + string.Join(", ", Skip)); } + return string.Join(" · ", parts); + } +} + +/// When an edge is followed. +public enum TicketFlowEdgeWhen +{ + /// The step passed: on to the next one. + Pass, + + /// The step failed: the change goes back to Code (a fix round). + Fail, +} + +/// A conditional edge between two steps. +public sealed record TicketFlowEdge(string From, string To, TicketFlowEdgeWhen When); + +/// A ticket flow: ordered step nodes joined by conditional edges. +public sealed record TicketFlowDefinition(IReadOnlyList Nodes, IReadOnlyList Edges) +{ + /// The built-in flow — what Autopilot has always run: Code → Checks → Review → Test → Merge, every failure + /// back to Code. + public static TicketFlowDefinition Default { get; } = Chain( + [ + new("code", TicketStepKind.Code), + new("checks", TicketStepKind.Checks), + new("review", TicketStepKind.Review), + new("test", TicketStepKind.Test), + new("merge", TicketStepKind.Merge), + ]); + + /// Steps in the order given, each passing to the next, every step that can fail sending the change back + /// to the Code step. + public static TicketFlowDefinition Chain(IReadOnlyList nodes) + { + ArgumentNullException.ThrowIfNull(nodes); + var edges = new List(); + for (var i = 0; i + 1 < nodes.Count; i++) { edges.Add(new(nodes[i].Id, nodes[i + 1].Id, TicketFlowEdgeWhen.Pass)); } + var code = nodes.FirstOrDefault(n => n.Kind == TicketStepKind.Code); + if (code is not null) + { + edges.AddRange(nodes.Where(n => n.Info.CanSendBack).Select(n => new TicketFlowEdge(n.Id, code.Id, TicketFlowEdgeWhen.Fail))); + } + return new(nodes, edges); + } + + /// This definition with in place of the node with its id. + public TicketFlowDefinition With(TicketFlowNode node) + { + ArgumentNullException.ThrowIfNull(node); + return this with { Nodes = Nodes.Select(n => n.Id == node.Id ? node : n).ToList() }; + } + + /// The node with , or null. + public TicketFlowNode? Find(string id) => Nodes.FirstOrDefault(n => string.Equals(n.Id, id, StringComparison.Ordinal)); + + /// The steps in run order: from Code along the pass edges (a malformed definition stops where it breaks). + public IReadOnlyList Ordered() + { + var byId = Nodes.GroupBy(n => n.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var next = Edges.Where(e => e.When == TicketFlowEdgeWhen.Pass).GroupBy(e => e.From, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().To, StringComparer.Ordinal); + var order = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var cur = Nodes.FirstOrDefault(n => n.Kind == TicketStepKind.Code); + while (cur is not null && seen.Add(cur.Id)) + { + order.Add(cur); + cur = next.TryGetValue(cur.Id, out var to) && byId.TryGetValue(to, out var n) ? n : null; + } + return order; + } + + /// What a ticket with runs: the steps that apply to it, in order. + public TicketFlowPlan Resolve(IReadOnlyCollection? labels) + { + var l = labels ?? []; + var ordered = Ordered(); + return new TicketFlowPlan(ordered.Where(n => n.AppliesTo(l)).ToList(), ordered.Where(n => !n.AppliesTo(l)).ToList()); + } + + /// The flow in one line ("Code → Checks → Review → Test → Merge"); a step limited by labels says so. + public string Summary() => + string.Join(" → ", Ordered().Where(n => n.Enabled).Select(n => n.Conditions() is { Length: > 0 } c ? $"{n.Info.Label} ({c})" : n.Info.Label)); + + /// Serialized for storage. + public string ToJson() => JsonSerializer.Serialize(this, TicketFlowJsonContext.Default.TicketFlowDefinition); + + /// A stored definition, or null when it can't be read (unknown step kind, broken JSON). + public static TicketFlowDefinition? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) { return null; } + try + { + var def = JsonSerializer.Deserialize(json, TicketFlowJsonContext.Default.TicketFlowDefinition); + return def is { Nodes: not null, Edges: not null } ? def : null; + } + catch (JsonException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + } +} + +/// The steps one ticket runs, in order (Code first, Merge last), and those it passes through. +public sealed class TicketFlowPlan(IReadOnlyList steps, IReadOnlyList skipped) +{ + /// The steps the ticket runs, in order. + public IReadOnlyList Steps { get; } = steps; + + /// The steps it passes through (off, or not for its labels). + public IReadOnlyList Skipped { get; } = skipped; + + /// The default flow's plan — every step. + public static TicketFlowPlan Default { get; } = TicketFlowDefinition.Default.Resolve([]); + + /// True when the ticket runs a step of . + public bool Runs(TicketStepKind kind) => Steps.Any(s => s.Kind == kind); + + /// The steps in one line ("Code → Checks → Test → Merge"). + public string Summary => string.Join(" → ", Steps.Select(s => s.Info.Label)); + + /// Why this plan must not run (the run-time twin of the save-time invariants), or null when it is safe. + public string? SafetyProblem() + { + if (Steps.Count == 0 || Steps[0].Kind != TicketStepKind.Code) { return "it doesn't start with Code"; } + if (Steps[^1].Kind != TicketStepKind.Merge) { return "it doesn't end with Merge"; } + if (!Runs(TicketStepKind.Checks)) { return "Checks wouldn't run before Merge"; } + if (!Steps.Any(s => s.Info.IsMergeGate)) { return $"the ticket would reach Merge with {TicketStepKinds.NeitherGate()}"; } + for (var i = 1; i < Steps.Count; i++) + { + if (Steps[i].Info.Rank <= Steps[i - 1].Info.Rank) { return $"{Steps[i].Info.Label} comes after {Steps[i - 1].Info.Label}"; } + } + return null; + } +} + +/// A reason a ticket flow can't be used — attached to a step when it is about one. +public sealed record TicketFlowProblem(string? NodeId, string Message); + +/// The safety invariants of a ticket flow. +public static class TicketFlowValidator +{ + /// Most steps a flow may have. + public const int MaxNodes = 16; + + /// Most labels a step's filter may list. + public const int MaxLabelsPerFilter = 8; + + /// Most distinct labels across the gates' filters (each combination is checked). + public const int MaxDistinctLabels = 12; + + /// Longest label. + public const int MaxLabelLength = 50; + + /// Every reason can't be used, in plain language; empty when it is safe. + public static IReadOnlyList Validate(TicketFlowDefinition? definition) + { + var problems = new List(); + if (definition is null || definition.Nodes is null || definition.Edges is null) + { + problems.Add(new(null, "The ticket flow couldn't be read.")); + return problems; + } + var nodes = definition.Nodes; + if (nodes.Count == 0) { problems.Add(new(null, "The ticket flow has no steps.")); return problems; } + if (nodes.Count > MaxNodes) { problems.Add(new(null, $"A ticket flow has at most {MaxNodes} steps.")); return problems; } + + // Ids and kinds. + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var n in nodes) + { + if (string.IsNullOrWhiteSpace(n.Id) || n.Id.Length > 40) { problems.Add(new(null, "Every step needs a short id.")); continue; } + if (!Enum.IsDefined(n.Kind)) { problems.Add(new(n.Id, $"Step {n.Id} is of an unknown kind.")); continue; } + if (!byId.TryAdd(n.Id, n)) { problems.Add(new(n.Id, $"Two steps share the id {n.Id}.")); } + } + if (problems.Count > 0) { return problems; } + foreach (var dup in nodes.GroupBy(n => n.Kind).Where(g => g.Count() > 1)) + { + problems.Add(new(dup.Skip(1).First().Id, $"There can be only one {TicketStepKinds.Of(dup.Key).Label} step.")); + } + var code = nodes.FirstOrDefault(n => n.Kind == TicketStepKind.Code); + var merge = nodes.FirstOrDefault(n => n.Kind == TicketStepKind.Merge); + if (code is null) { problems.Add(new(null, "The flow must start with Code — every ticket starts by coding it.")); } + if (merge is null) { problems.Add(new(null, "The flow must end with Merge.")); } + if (nodes.All(n => n.Kind != TicketStepKind.Checks)) { problems.Add(new(null, "Checks must run before Merge — add it back.")); } + + // Each step on its own: required steps always run, roles fit the step, label filters are sane. + foreach (var n in nodes) + { + var info = n.Info; + if (info.Required && !n.Enabled) { problems.Add(new(n.Id, $"{info.Label} always runs — it can't be turned off.")); } + if (info.Required && (n.Skip.Count > 0 || n.Only.Count > 0)) + { + problems.Add(new(n.Id, $"{info.Label} runs for every ticket — it can't be limited by labels.")); + } + if (n.Role is { } role) + { + if (info.Roles.Count == 0) { problems.Add(new(n.Id, $"{info.Label} isn't run by an agent, so it can't name one.")); } + else if (!info.Roles.Contains(role, StringComparer.Ordinal)) + { + var allowed = string.Join(" or ", info.Roles.Select(AgentRoles.Label)); + problems.Add(new(n.Id, AgentRoles.Find(role) is null + ? $"{info.Label} names an unknown agent ({role}) — pick {allowed}." + : $"{info.Label} can't be run by {AgentRoles.Label(role)} — pick {allowed}.")); + } + } + foreach (var (list, what) in new[] { (n.Skip, "skip"), (n.Only, "only") }) + { + if (list.Count > MaxLabelsPerFilter) { problems.Add(new(n.Id, $"{info.Label}: list at most {MaxLabelsPerFilter} labels to {what}.")); } + if (list.Any(l => string.IsNullOrWhiteSpace(l) || l.Length > MaxLabelLength || l.Trim().Length != l.Length || l.Contains(',', StringComparison.Ordinal))) + { + problems.Add(new(n.Id, $"{info.Label}: a label is empty, too long or has stray spaces or commas.")); + } + } + foreach (var both in n.Skip.Intersect(n.Only, StringComparer.OrdinalIgnoreCase)) + { + problems.Add(new(n.Id, $"{info.Label} both requires and skips {both}.")); + } + } + + // Edges: known steps; pass = one next step each (none after Merge, none into Code); fail = back to Code only. + foreach (var e in definition.Edges) + { + if (!byId.ContainsKey(e.From) || !byId.ContainsKey(e.To)) + { + problems.Add(new(null, $"An edge points at a step that doesn't exist ({e.From} → {e.To}).")); + } + } + var edges = definition.Edges.Where(e => byId.ContainsKey(e.From) && byId.ContainsKey(e.To)).ToList(); + var pass = edges.Where(e => e.When == TicketFlowEdgeWhen.Pass).ToList(); + var fail = edges.Where(e => e.When == TicketFlowEdgeWhen.Fail).ToList(); + foreach (var n in nodes) + { + var info = n.Info; + var outs = pass.Count(e => e.From == n.Id); + if (n.Kind == TicketStepKind.Merge && outs > 0) { problems.Add(new(n.Id, "Nothing may follow Merge — it is the last step.")); } + else if (n.Kind != TicketStepKind.Merge && outs == 0) { problems.Add(new(n.Id, $"{info.Label} has no next step — every step leads on to Merge.")); } + else if (outs > 1) { problems.Add(new(n.Id, $"{info.Label} has more than one next step.")); } + var backs = fail.Where(e => e.From == n.Id).ToList(); + if (!info.CanSendBack && backs.Count > 0) { problems.Add(new(n.Id, $"{info.Label} can't send a change back.")); } + if (info.CanSendBack && backs.Count == 0) { problems.Add(new(n.Id, $"{info.Label} needs its way back to Code for when it fails.")); } + if (backs.Count > 1) { problems.Add(new(n.Id, $"{info.Label} has more than one way back.")); } + if (backs.Any(e => byId[e.To].Kind != TicketStepKind.Code)) { problems.Add(new(n.Id, $"A failed {info.Label} can only go back to Code.")); } + } + if (code is not null && pass.Any(e => e.To == code.Id)) + { + problems.Add(new(code.Id, "Nothing may come before Code — every ticket starts by coding it.")); + } + if (problems.Count > 0 || code is null) { return problems; } + + // The pass chain from Code: no cycle, ends at Merge, reaches every step, in rank order. + var chain = new List(); + var seen = new HashSet(StringComparer.Ordinal); + for (var cur = code; cur is not null;) + { + if (!seen.Add(cur.Id)) { problems.Add(new(cur.Id, "The steps loop back on themselves — only a failure may go back to Code.")); return problems; } + chain.Add(cur); + var to = pass.FirstOrDefault(e => e.From == cur.Id)?.To; + cur = to is null ? null : byId[to]; + } + if (chain[^1].Kind != TicketStepKind.Merge) { problems.Add(new(chain[^1].Id, "The flow must end with Merge.")); } + foreach (var n in nodes.Where(n => !seen.Contains(n.Id))) + { + problems.Add(new(n.Id, $"{n.Info.Label} can't be reached from Code.")); + } + for (var i = 1; i < chain.Count; i++) + { + if (chain[i].Info.Rank <= chain[i - 1].Info.Rank) + { + problems.Add(new(chain[i].Id, $"{chain[i].Info.Label} must come before {chain[i - 1].Info.Label}.")); + } + } + if (problems.Count > 0) { return problems; } + + // Every ticket — whatever its labels — passes at least one merge gate before Merge. + if (GateProblem(chain) is { } gate) { problems.Add(gate); } + return problems; + } + + /// True when keeps every invariant. + public static bool IsValid(TicketFlowDefinition? definition) => Validate(definition).Count == 0; + + // Only the labels named in the gates' filters change which gates run, so checking every combination of those + // (smallest first, so the reason names the simplest ticket) covers every possible ticket. + private static TicketFlowProblem? GateProblem(IReadOnlyList chain) + { + var gates = chain.Where(n => n.Info.IsMergeGate && n.Enabled).ToList(); + if (gates.Count == 0) + { + return new(null, $"Turn on {TicketStepKinds.EitherGate()}: every ticket needs at least one of them before Merge."); + } + var labels = gates.SelectMany(g => g.Skip.Concat(g.Only)).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList(); + if (labels.Count > MaxDistinctLabels) + { + return new(null, $"Use at most {MaxDistinctLabels} different labels in the steps' filters."); + } + var subsets = Enumerable.Range(0, 1 << labels.Count) + .OrderBy(m => System.Numerics.BitOperations.PopCount((uint)m)).ThenBy(m => m); + foreach (var mask in subsets) + { + var ticket = labels.Where((_, i) => (mask & (1 << i)) != 0).ToList(); + if (gates.Any(g => g.AppliesTo(ticket))) { continue; } + var neither = TicketStepKinds.NeitherGate(); + return ticket.Count == 0 + ? new(null, $"A ticket with none of the labels {string.Join(", ", labels)} would reach Merge with {neither}.") + : new(null, string.Create(CultureInfo.InvariantCulture, + $"A ticket labelled {string.Join(" and ", ticket)} would reach Merge with {neither}.")); + } + return null; + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(TicketFlowDefinition))] +internal sealed partial class TicketFlowJsonContext : JsonSerializerContext; diff --git a/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs b/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs index 0221f91d..74f6bb51 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs @@ -62,4 +62,9 @@ public sealed class RemoteSessionEntity // conflicts, review, acceptance or a person's "Ask for changes" — and when its last change merged. public int FixRounds { get; set; } public DateTimeOffset? MergedAtUtc { get; set; } + + // The ticket flow (workspaces.ticket_flows) version Autopilot started this task with (ADR-0016): the task keeps + // running that flow — through the MAF workflow and, after a restart, the step-by-step rules — even when the flow is + // changed meanwhile. 0 = the built-in flow; null = a task started before ticket flows (it follows the current one). + public int? TicketFlowVersion { get; set; } } diff --git a/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs b/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs index 84773ec3..3876a654 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs @@ -61,6 +61,9 @@ Task UpdateRepoRunResultAsync( /// Take back a round that never happened (the member's AI plan was at its limit) — never below zero. Task DecrementFixRoundsForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default) => Task.CompletedTask; + /// Record the ticket flow version the task runs (snapshot when Autopilot starts it; 0 = the built-in flow). + Task SetTicketFlowVersionForTenantAsync(string tenantId, Guid sessionId, int version, CancellationToken ct = default) => Task.CompletedTask; + /// Delivery of the tenant's tasks merged since (null = all time). Task GetDeliveryForTenantAsync(string tenantId, DateTimeOffset? since = null, CancellationToken ct = default) => Task.FromResult(DeliverySummary.Empty); diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.Designer.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.Designer.cs new file mode 100644 index 00000000..46b34ff2 --- /dev/null +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.Designer.cs @@ -0,0 +1,246 @@ +// +using System; +using AgentOs.Modules.Sessions.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AgentOs.Modules.Sessions.Persistence.Migrations +{ + [DbContext(typeof(SessionsDbContext))] + [Migration("20261024091337_AddTicketFlowVersion")] + partial class AddTicketFlowVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sessions") + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.RemoteSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoardItemNodeId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Brain") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Quick"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FixRounds") + .HasColumnType("integer"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("MemberUserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MergedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PrUrl") + .HasColumnType("text"); + + b.Property("RepoDefaultBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RepoName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RepoOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RunOnMachine") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketFlowVersion") + .HasColumnType("integer"); + + b.Property("TicketKind") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TicketType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.ToTable("sessions", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.RunnerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastSeenUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OwnerUserId"); + + b.ToTable("runners", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.SessionRepoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BranchName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultBranch") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Repo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceRepoId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "SessionId"); + + b.ToTable("session_repos", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.SessionRepoEntity", b => + { + b.HasOne("AgentOs.Modules.Sessions.Persistence.Entities.RemoteSessionEntity", null) + .WithMany() + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.cs new file mode 100644 index 00000000..8aed4185 --- /dev/null +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261024091337_AddTicketFlowVersion.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AgentOs.Modules.Sessions.Persistence.Migrations +{ + /// + public partial class AddTicketFlowVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TicketFlowVersion", + schema: "sessions", + table: "sessions", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TicketFlowVersion", + schema: "sessions", + table: "sessions"); + } + } +} diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs index 72fee7a3..3fe08b0c 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs @@ -96,6 +96,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("character varying(64)"); + b.Property("TicketFlowVersion") + .HasColumnType("integer"); + b.Property("TicketKind") .HasMaxLength(32) .HasColumnType("character varying(32)"); diff --git a/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs b/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs index 363d8089..c6adcf8b 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs @@ -214,6 +214,15 @@ public async Task IncrementFixRoundsForTenantAsync(string tenantId, Guid session await _db.SaveChangesAsync(ct).ConfigureAwait(false); } + public async Task SetTicketFlowVersionForTenantAsync(string tenantId, Guid sessionId, int version, CancellationToken ct = default) + { + var session = await _db.Sessions.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == sessionId && s.TenantId == tenantId, ct).ConfigureAwait(false); + if (session is null) { return; } + session.TicketFlowVersion = version; + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + } + public async Task DecrementFixRoundsForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default) { var session = await _db.Sessions.IgnoreQueryFilters() diff --git a/src/AgentOs.Modules.Tenants/IAuditLog.cs b/src/AgentOs.Modules.Tenants/IAuditLog.cs index b855acc9..3ab7c1a7 100644 --- a/src/AgentOs.Modules.Tenants/IAuditLog.cs +++ b/src/AgentOs.Modules.Tenants/IAuditLog.cs @@ -46,6 +46,7 @@ public static class AuditActions public const string TenantExported = "tenant.exported"; public const string ApiTokenCreated = "apitoken.created"; public const string ApiTokenRevoked = "apitoken.revoked"; + public const string TicketFlowChanged = "ticketflow.changed"; } /// Audit "tenant" ids that are not tenants. diff --git a/src/AgentOs.Modules.Workspaces/Persistence/Entities/TicketFlowVersionEntity.cs b/src/AgentOs.Modules.Workspaces/Persistence/Entities/TicketFlowVersionEntity.cs new file mode 100644 index 00000000..3e9263f7 --- /dev/null +++ b/src/AgentOs.Modules.Workspaces/Persistence/Entities/TicketFlowVersionEntity.cs @@ -0,0 +1,31 @@ +// One saved version of a ticket flow (the per-ticket graph Autopilot runs — ADR-0016). Append-only: a save, a revert +// or "use the default flow" adds a version, so every change keeps who made it and when, and any older version can be +// brought back. Scope: BoardId = Guid.Empty is the tenant's default flow; a board id is that board's override (board +// wins). Version numbers run per tenant across every scope, so "v7" names one row of the tenant and a running ticket +// can snapshot it. Tenant-scoped (EF filter + RLS, workspaces.ticket_flows in RowLevelSecurity.Tables). + +using System; + +namespace AgentOs.Modules.Workspaces.Persistence.Entities; + +public sealed class TicketFlowVersionEntity +{ + public Guid Id { get; set; } + public string TenantId { get; set; } = string.Empty; + + /// The board the flow is for; = the tenant's default. + public Guid BoardId { get; set; } + + /// Per-tenant version number (1, 2, …) across every scope. + public int Version { get; set; } + + /// The definition (JSON); null = this board uses the tenant's flow again. + public string? DefinitionJson { get; set; } + + public string? CreatedByUserId { get; set; } + public string? CreatedByName { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + + /// What the change was ("Reverted to v3"), when it says more than "saved". + public string? Note { get; set; } +} diff --git a/src/AgentOs.Modules.Workspaces/Persistence/ITicketFlowStore.cs b/src/AgentOs.Modules.Workspaces/Persistence/ITicketFlowStore.cs new file mode 100644 index 00000000..1ed7ccbb --- /dev/null +++ b/src/AgentOs.Modules.Workspaces/Persistence/ITicketFlowStore.cs @@ -0,0 +1,39 @@ +// Versioned storage of ticket flows (ADR-0016). The store keeps JSON and who/when; what a flow means (its steps and +// safety invariants) belongs to the Pipeline module's TicketFlowDefinition, validated by the Web's TicketFlowService +// before anything is appended. Every method is tenant-explicit (a Blazor circuit and the Autopilot worker have no +// HttpContext). EF (Postgres) when a database is configured, process-local otherwise (the standalone Web). + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AgentOs.Modules.Workspaces.Persistence; + +/// One saved version of a ticket flow. +/// The board it is for; = the tenant's default. +/// Per-tenant version number. +/// The definition; null = the board uses the tenant's flow again. +/// Who saved it. +/// Their name, as shown. +/// When. +/// What the change was, when it says more than "saved". +public sealed record TicketFlowVersion(Guid BoardId, int Version, string? DefinitionJson, string? CreatedByUserId, string? CreatedByName, + DateTimeOffset CreatedAtUtc, string? Note); + +/// Append-only, versioned ticket flows per tenant and board. +public interface ITicketFlowStore +{ + /// The newest version saved for exactly this scope (a board, or for the tenant). + Task CurrentAsync(string tenantId, Guid boardId, CancellationToken ct = default); + + /// One version of the tenant (any scope), or null. + Task GetVersionAsync(string tenantId, int version, CancellationToken ct = default); + + /// The scope's versions, newest first. + Task> HistoryAsync(string tenantId, Guid boardId, int max = 20, CancellationToken ct = default); + + /// Append a version to the scope; returns it with its number. + Task AppendAsync(string tenantId, Guid boardId, string? definitionJson, string? userId, string? userName, string? note, + CancellationToken ct = default); +} diff --git a/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.Designer.cs b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.Designer.cs new file mode 100644 index 00000000..080c13cc --- /dev/null +++ b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.Designer.cs @@ -0,0 +1,256 @@ +// +using System; +using AgentOs.Modules.Workspaces.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AgentOs.Modules.Workspaces.Persistence.Migrations +{ + [DbContext(typeof(WorkspacesDbContext))] + [Migration("20261024091332_AddTicketFlows")] + partial class AddTicketFlows + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workspaces") + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.AutopilotEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("BoardId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "BoardId", "AtUtc"); + + b.ToTable("autopilot_events", "workspaces"); + }); + + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.TicketFlowVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoardId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DefinitionJson") + .HasMaxLength(64000) + .HasColumnType("character varying(64000)"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Version") + .IsUnique(); + + b.HasIndex("TenantId", "BoardId", "Version"); + + b.ToTable("ticket_flows", "workspaces"); + }); + + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.WorkspaceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Autopilot") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Off"); + + b.Property("AutopilotUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("CredentialRef") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DefaultBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Owner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Project") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectNodeId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectNumber") + .HasColumnType("integer"); + + b.Property("ProjectOwner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProjectScope") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RemoteUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Repo") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Autopilot"); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.ToTable("workspaces", "workspaces"); + }); + + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.WorkspaceRepoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultBranch") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Private") + .HasColumnType("boolean"); + + b.Property("RemoteUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Repo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("TenantId", "WorkspaceId"); + + b.ToTable("workspace_repos", "workspaces"); + }); + + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.WorkspaceRepoEntity", b => + { + b.HasOne("AgentOs.Modules.Workspaces.Persistence.Entities.WorkspaceEntity", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.cs b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.cs new file mode 100644 index 00000000..3d1d2390 --- /dev/null +++ b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/20261024091332_AddTicketFlows.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AgentOs.Modules.Workspaces.Persistence.Migrations +{ + /// + public partial class AddTicketFlows : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ticket_flows", + schema: "workspaces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + BoardId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: false), + DefinitionJson = table.Column(type: "character varying(64000)", maxLength: 64000, nullable: true), + CreatedByUserId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CreatedByName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Note = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ticket_flows", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ticket_flows_TenantId_BoardId_Version", + schema: "workspaces", + table: "ticket_flows", + columns: new[] { "TenantId", "BoardId", "Version" }); + + migrationBuilder.CreateIndex( + name: "IX_ticket_flows_TenantId_Version", + schema: "workspaces", + table: "ticket_flows", + columns: new[] { "TenantId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ticket_flows", + schema: "workspaces"); + } + } +} diff --git a/src/AgentOs.Modules.Workspaces/Persistence/Migrations/WorkspacesDbContextModelSnapshot.cs b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/WorkspacesDbContextModelSnapshot.cs index 9362ffea..fa7edab2 100644 --- a/src/AgentOs.Modules.Workspaces/Persistence/Migrations/WorkspacesDbContextModelSnapshot.cs +++ b/src/AgentOs.Modules.Workspaces/Persistence/Migrations/WorkspacesDbContextModelSnapshot.cs @@ -52,6 +52,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("autopilot_events", "workspaces"); }); + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.TicketFlowVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoardId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DefinitionJson") + .HasMaxLength(64000) + .HasColumnType("character varying(64000)"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Version") + .IsUnique(); + + b.HasIndex("TenantId", "BoardId", "Version"); + + b.ToTable("ticket_flows", "workspaces"); + }); + modelBuilder.Entity("AgentOs.Modules.Workspaces.Persistence.Entities.WorkspaceEntity", b => { b.Property("Id") diff --git a/src/AgentOs.Modules.Workspaces/Persistence/Repositories/TicketFlowStores.cs b/src/AgentOs.Modules.Workspaces/Persistence/Repositories/TicketFlowStores.cs new file mode 100644 index 00000000..8e603423 --- /dev/null +++ b/src/AgentOs.Modules.Workspaces/Persistence/Repositories/TicketFlowStores.cs @@ -0,0 +1,136 @@ +// ITicketFlowStore implementations. EF (Postgres): every method is tenant-explicit — IgnoreQueryFilters() plus an +// explicit TenantId predicate (the global filter reads ITenantContext, which a circuit or the Autopilot worker +// doesn't have) — and RLS binds each connection to the ambient tenant as the second wall. Version numbers are +// max + 1 per tenant under a unique (TenantId, Version) index; two concurrent saves race on it and the loser retries. +// In-memory: the stateless standalone Web (no connection string) — same semantics, process-local. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Modules.Workspaces.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AgentOs.Modules.Workspaces.Persistence.Repositories; + +internal static class TicketFlowMapping +{ + public const int MaxHistory = 100; + + public static TicketFlowVersion ToRecord(TicketFlowVersionEntity e) => + new(e.BoardId, e.Version, e.DefinitionJson, e.CreatedByUserId, e.CreatedByName, e.CreatedAtUtc, e.Note); + + public static string? Clip(string? value, int max) => value is null || value.Length <= max ? value : value[..max]; +} + +internal sealed class EfTicketFlowStore(WorkspacesDbContext db) : ITicketFlowStore +{ + public async Task CurrentAsync(string tenantId, Guid boardId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var row = await db.TicketFlows.IgnoreQueryFilters().AsNoTracking() + .Where(x => x.TenantId == tenantId && x.BoardId == boardId) + .OrderByDescending(x => x.Version).FirstOrDefaultAsync(ct).ConfigureAwait(false); + return row is null ? null : TicketFlowMapping.ToRecord(row); + } + + public async Task GetVersionAsync(string tenantId, int version, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var row = await db.TicketFlows.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Version == version, ct).ConfigureAwait(false); + return row is null ? null : TicketFlowMapping.ToRecord(row); + } + + public async Task> HistoryAsync(string tenantId, Guid boardId, int max = 20, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var rows = await db.TicketFlows.IgnoreQueryFilters().AsNoTracking() + .Where(x => x.TenantId == tenantId && x.BoardId == boardId) + .OrderByDescending(x => x.Version).Take(Math.Clamp(max, 1, TicketFlowMapping.MaxHistory)) + .ToListAsync(ct).ConfigureAwait(false); + return rows.Select(TicketFlowMapping.ToRecord).ToList(); + } + + public async Task AppendAsync(string tenantId, Guid boardId, string? definitionJson, string? userId, string? userName, + string? note, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + for (var attempt = 0; ; attempt++) + { + var last = await db.TicketFlows.IgnoreQueryFilters().AsNoTracking() + .Where(x => x.TenantId == tenantId).MaxAsync(x => (int?)x.Version, ct).ConfigureAwait(false) ?? 0; + var now = DateTimeOffset.UtcNow; + var row = new TicketFlowVersionEntity + { + Id = Guid.CreateVersion7(now), + TenantId = tenantId, + BoardId = boardId, + Version = last + 1, + DefinitionJson = definitionJson, + CreatedByUserId = TicketFlowMapping.Clip(userId, 64), + CreatedByName = TicketFlowMapping.Clip(userName, 200), + CreatedAtUtc = now, + Note = TicketFlowMapping.Clip(note, 200), + }; + db.TicketFlows.Add(row); + try + { + await db.SaveChangesAsync(ct).ConfigureAwait(false); + return TicketFlowMapping.ToRecord(row); + } + catch (DbUpdateException) when (attempt < 3) + { + // Another save took this number first (unique TenantId + Version): take the next one. + db.Entry(row).State = EntityState.Detached; + } + } + } +} + +internal sealed class InMemoryTicketFlowStore : ITicketFlowStore +{ + private readonly List<(string TenantId, TicketFlowVersion Version)> _rows = []; + private readonly Lock _gate = new(); + + public Task CurrentAsync(string tenantId, Guid boardId, CancellationToken ct = default) + { + lock (_gate) + { + return Task.FromResult(_rows.Where(r => r.TenantId == tenantId && r.Version.BoardId == boardId) + .Select(r => r.Version).MaxBy(v => v.Version)); + } + } + + public Task GetVersionAsync(string tenantId, int version, CancellationToken ct = default) + { + lock (_gate) + { + return Task.FromResult(_rows.Where(r => r.TenantId == tenantId && r.Version.Version == version).Select(r => r.Version).FirstOrDefault()); + } + } + + public Task> HistoryAsync(string tenantId, Guid boardId, int max = 20, CancellationToken ct = default) + { + lock (_gate) + { + return Task.FromResult>(_rows.Where(r => r.TenantId == tenantId && r.Version.BoardId == boardId) + .Select(r => r.Version).OrderByDescending(v => v.Version).Take(Math.Clamp(max, 1, TicketFlowMapping.MaxHistory)).ToList()); + } + } + + public Task AppendAsync(string tenantId, Guid boardId, string? definitionJson, string? userId, string? userName, + string? note, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + lock (_gate) + { + var next = _rows.Where(r => r.TenantId == tenantId).Select(r => r.Version.Version).DefaultIfEmpty(0).Max() + 1; + var version = new TicketFlowVersion(boardId, next, definitionJson, TicketFlowMapping.Clip(userId, 64), TicketFlowMapping.Clip(userName, 200), + DateTimeOffset.UtcNow, TicketFlowMapping.Clip(note, 200)); + _rows.Add((tenantId, version)); + return Task.FromResult(version); + } + } +} diff --git a/src/AgentOs.Modules.Workspaces/Persistence/WorkspacesDbContext.cs b/src/AgentOs.Modules.Workspaces/Persistence/WorkspacesDbContext.cs index 49946b35..85b3f471 100644 --- a/src/AgentOs.Modules.Workspaces/Persistence/WorkspacesDbContext.cs +++ b/src/AgentOs.Modules.Workspaces/Persistence/WorkspacesDbContext.cs @@ -30,11 +30,31 @@ public WorkspacesDbContext(DbContextOptions options, ITenan public DbSet AutopilotEvents => Set(); + public DbSet TicketFlows => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.HasDefaultSchema("workspaces"); + // Ticket flows (ADR-0016): append-only versions; BoardId = Guid.Empty is the tenant's default flow. + modelBuilder.Entity(e => + { + e.ToTable("ticket_flows"); + e.HasKey(x => x.Id); + e.Property(x => x.TenantId).IsRequired().HasMaxLength(64); + e.Property(x => x.BoardId).IsRequired(); + e.Property(x => x.Version).IsRequired(); + e.Property(x => x.DefinitionJson).HasMaxLength(64_000); + e.Property(x => x.CreatedByUserId).HasMaxLength(64); + e.Property(x => x.CreatedByName).HasMaxLength(200); + e.Property(x => x.CreatedAtUtc).IsRequired(); + e.Property(x => x.Note).HasMaxLength(200); + e.HasIndex(x => new { x.TenantId, x.Version }).IsUnique(); + e.HasIndex(x => new { x.TenantId, x.BoardId, x.Version }); + e.HasQueryFilter(x => x.TenantId == (_tenant != null ? _tenant.TenantId : null)); + }); + modelBuilder.Entity(e => { e.ToTable("autopilot_events"); diff --git a/src/AgentOs.Modules.Workspaces/WorkspacesModule.cs b/src/AgentOs.Modules.Workspaces/WorkspacesModule.cs index 9d991aad..79dc95bc 100644 --- a/src/AgentOs.Modules.Workspaces/WorkspacesModule.cs +++ b/src/AgentOs.Modules.Workspaces/WorkspacesModule.cs @@ -48,6 +48,7 @@ public void AddServices(IServiceCollection services, IConfiguration configuratio .AddInterceptors(AgentOs.SharedKernel.Persistence.RowLevelSecurityInterceptor.Instance)); services.AddNpgsqlConnectionFactory(connectionString); services.AddScoped(); + services.AddScoped(); } else { @@ -60,6 +61,8 @@ public void AddServices(IServiceCollection services, IConfiguration configuratio + "or set Persistence:RequireDatabase=false to opt into the legacy no-op repositories."); } services.AddSingleton(); + // The standalone Web keeps ticket flows in memory, so the Workflow › Ticket flow editor saves and reverts. + services.AddSingleton(); } } diff --git a/src/AgentOs.SharedKernel/Persistence/RowLevelSecurity.cs b/src/AgentOs.SharedKernel/Persistence/RowLevelSecurity.cs index a2ad9b79..a58f704d 100644 --- a/src/AgentOs.SharedKernel/Persistence/RowLevelSecurity.cs +++ b/src/AgentOs.SharedKernel/Persistence/RowLevelSecurity.cs @@ -41,7 +41,7 @@ public static partial class RowLevelSecurity ("pipeline", "pipeline_runs"), ("pipeline", "run_metrics"), ("pipeline", "orchestrations"), ("pipeline", "scheduled_runs"), ("sessions", "sessions"), ("sessions", "session_repos"), ("sessions", "runners"), ("tools", "tool_invocations"), - ("workspaces", "workspaces"), ("workspaces", "workspace_repos"), ("workspaces", "autopilot_events"), + ("workspaces", "workspaces"), ("workspaces", "workspace_repos"), ("workspaces", "autopilot_events"), ("workspaces", "ticket_flows"), ("memory", "nodes"), ("memory", "edges"), ]; diff --git a/tests/AgentOs.E2E.Tests/AgentOsUi/RowLevelSecurityRealDbTests.cs b/tests/AgentOs.E2E.Tests/AgentOsUi/RowLevelSecurityRealDbTests.cs index 7f0ac66f..ba44362e 100644 --- a/tests/AgentOs.E2E.Tests/AgentOsUi/RowLevelSecurityRealDbTests.cs +++ b/tests/AgentOs.E2E.Tests/AgentOsUi/RowLevelSecurityRealDbTests.cs @@ -20,7 +20,7 @@ public sealed class RowLevelSecurityRealDbTests [ "pipeline.pipeline_runs", "pipeline.run_metrics", "pipeline.orchestrations", "sessions.sessions", "sessions.session_repos", "sessions.runners", - "tools.tool_invocations", "workspaces.workspaces", "workspaces.workspace_repos", + "tools.tool_invocations", "workspaces.workspaces", "workspaces.workspace_repos", "workspaces.ticket_flows", ]; private static string? Conn => diff --git a/tests/AgentOs.Tests/Persistence/RowLevelSecurityTests.cs b/tests/AgentOs.Tests/Persistence/RowLevelSecurityTests.cs index 737b436e..77602d49 100644 --- a/tests/AgentOs.Tests/Persistence/RowLevelSecurityTests.cs +++ b/tests/AgentOs.Tests/Persistence/RowLevelSecurityTests.cs @@ -38,8 +38,9 @@ public void StageOne_LeavesPlatformScopedTablesToStageTwo() { // app_config and audit_events hold platform-scope rows (__platform / _platform) read by every tenant. RowLevelSecurity.Tables.ShouldNotContain(t => t.Table == "app_config" || t.Table == "audit_events"); - RowLevelSecurity.Tables.Count.ShouldBe(13); // + pipeline.scheduled_runs, memory.nodes, memory.edges, workspaces.autopilot_events + RowLevelSecurity.Tables.Count.ShouldBe(14); // + pipeline.scheduled_runs, memory.nodes, memory.edges, workspaces.autopilot_events, workspaces.ticket_flows RowLevelSecurity.Tables.ShouldContain(("pipeline", "scheduled_runs")); + RowLevelSecurity.Tables.ShouldContain(("workspaces", "ticket_flows")); RowLevelSecurity.Tables.ShouldContain(("memory", "nodes")); } From 3ceac3a668170432e2a84c8e3074fb467d8cb1b9 Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 16:49:32 +0700 Subject: [PATCH 2/8] feat(autopilot): TicketWorkflow builds its MAF graph from the ticket flow Both engines follow the flow a ticket started with; the planner skips steps the flow leaves out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- src/AgentOs.Web/Program.cs | 4 +- .../Orchestration/AutopilotPlanner.cs | 21 +- .../ProjectOrchestrator.Agent.cs | 5 +- .../Orchestration/ProjectOrchestrator.cs | 59 ++++- .../Orchestration/TicketFlowService.cs | 211 ++++++++++++++++++ .../Services/Orchestration/TicketWorkflow.cs | 128 ++++++++--- .../Orchestration/TicketWorkflowRunner.cs | 79 ++++++- 7 files changed, 455 insertions(+), 52 deletions(-) create mode 100644 src/AgentOs.Web/Services/Orchestration/TicketFlowService.cs diff --git a/src/AgentOs.Web/Program.cs b/src/AgentOs.Web/Program.cs index 1ee33914..fd62dfb8 100644 --- a/src/AgentOs.Web/Program.cs +++ b/src/AgentOs.Web/Program.cs @@ -244,8 +244,10 @@ // The project orchestrator (Autopilot): a pass over every autopilot board each minute, decisions logged per board. builder.Services.AddSingleton(); builder.Services.AddSingleton(); -// Per-ticket Microsoft Agent Framework workflows (Coding → checks → Reviewer → Testing → merge), started by Autopilot. +// Per-ticket Microsoft Agent Framework workflows, started by Autopilot: each runs the board's ticket flow (ADR-0016 — +// Code → Checks → Review → Test → Merge by default, edited in Workflow › Ticket flow; versioned per tenant / board). builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Self-improvement: after a merge, the Retrospective agent distils the project's lessons into its practices. builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/AgentOs.Web/Services/Orchestration/AutopilotPlanner.cs b/src/AgentOs.Web/Services/Orchestration/AutopilotPlanner.cs index cafd73e4..8fef6c8b 100644 --- a/src/AgentOs.Web/Services/Orchestration/AutopilotPlanner.cs +++ b/src/AgentOs.Web/Services/Orchestration/AutopilotPlanner.cs @@ -44,6 +44,10 @@ public sealed record PlanTask(Guid SessionId, int IssueNumber, string Status, IR /// Its last run was cut short by the member's plan limit (nothing failed): it resumes once the plan /// resets, without spending a fix round or a retry. public bool CutShortByPlanLimit { get; init; } + + /// The steps this task runs — its ticket flow (the version it started with) resolved for its ticket's + /// labels. The rules make no move for a step it passes through (no review when Review is off for it). + public TicketFlowPlan Flow { get; init; } = TicketFlowPlan.Default; } /// One thing to do, and the sentence that explains it. @@ -164,7 +168,8 @@ public static IReadOnlyList Decide(AutopilotMode mode, IReadOnl running++; continue; } - if (task.OpenChanges.Any(c => c.Review == ChangeReview.NotReviewed)) + // The task's ticket flow decides which of review and testing it gets (at least one — a safety invariant). + if (task.Flow.Runs(TicketStepKind.Review) && task.OpenChanges.Any(c => c.Review == ChangeReview.NotReviewed)) { // A review only reads the code: no app, no ports — just a free slot and the member's computer. if (!canTest || running >= maxParallel) { continue; } @@ -191,7 +196,7 @@ public static IReadOnlyList Decide(AutopilotMode mode, IReadOnl running++; continue; } - if (task.OpenChanges.Any(c => c.Testing == ChangeTesting.Untested)) + if (task.Flow.Runs(TicketStepKind.Test) && task.OpenChanges.Any(c => c.Testing == ChangeTesting.Untested)) { if (!canTest || running >= maxParallel || testing >= maxParallelTests) { continue; } actions.Add(new AutopilotAction.RunTests(task.SessionId, task.IssueNumber, @@ -213,7 +218,7 @@ public static IReadOnlyList Decide(AutopilotMode mode, IReadOnl foreach (var change in task.OpenChanges) { actions.Add(new AutopilotAction.Merge(task.SessionId, task.IssueNumber, change.RepoId, change.PrNumber, - $"Merged #{task.IssueNumber}'s change (PR #{change.PrNumber}) — checks green and acceptance testing passed.")); + $"Merged #{task.IssueNumber}'s change (PR #{change.PrNumber}) — {Passed(task.Flow)}.")); } } } @@ -234,6 +239,16 @@ public static IReadOnlyList Decide(AutopilotMode mode, IReadOnl return actions; } + // What a change passed before it merges, as its ticket flow ran it ("checks green, review approved and acceptance + // testing passed"). + private static string Passed(TicketFlowPlan flow) + { + var passed = new List { "checks green" }; + if (flow.Runs(TicketStepKind.Review)) { passed.Add("review approved"); } + if (flow.Runs(TicketStepKind.Test)) { passed.Add("acceptance testing passed"); } + return passed.Count == 1 ? passed[0] : string.Join(", ", passed.Take(passed.Count - 1)) + " and " + passed[^1]; + } + /// True for a move that occupies a work slot on the member's computer. public static bool TakesSlot(AutopilotAction action) => action is AutopilotAction.StartTicket or AutopilotAction.ResumeTask or AutopilotAction.FixChecks or AutopilotAction.RunReview or AutopilotAction.RunTests; diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs index 582836ff..dfb36935 100644 --- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs +++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs @@ -317,7 +317,10 @@ internal static string Snapshot(IReadOnlyList tickets, IReadOnlyLis sb.Append("\nTasks in progress:\n"); foreach (var task in planTasks) { - sb.Append('#').Append(task.IssueNumber).Append(' ').Append(task.Status).Append(", fix rounds used ").Append(task.FixRounds).Append('\n'); + sb.Append('#').Append(task.IssueNumber).Append(' ').Append(task.Status).Append(", fix rounds used ").Append(task.FixRounds); + // Its ticket flow leaves steps out: the agent must not expect (or ask for) a review or test it won't get. + if (task.Flow.Skipped.Count > 0) { sb.Append(", ticket flow ").Append(task.Flow.Summary); } + sb.Append('\n'); foreach (var c in task.OpenChanges) { sb.Append(" PR #").Append(c.PrNumber).Append(": checks ").Append(c.Checks).Append(", review ").Append(c.Review) diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs index e93f2923..017bb29f 100644 --- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs +++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs @@ -119,7 +119,8 @@ public sealed partial class ProjectOrchestrator( AgentOs.Web.Services.Preview.PreviewService previews, TicketWorkflowRunner workflows, RetrospectiveService retrospectives, - IPlanLimits planLimits) : IDisposable + IPlanLimits planLimits, + TicketFlowService flows) : IDisposable { /// public void Dispose() => _oneAtATime.Dispose(); @@ -177,7 +178,10 @@ public async Task> StepAsync(WorkspaceEntity boar await CloseSupersededAsync(board, pat, sessions, repos, sp, ct).ConfigureAwait(false); // Proposed changes: merged ones are recorded; open ones get their check state. Every task carries its - // persisted fix rounds — the one counter the caps read, whichever engine sent the change back. + // persisted fix rounds — the one counter the caps read, whichever engine sent the change back — and its + // ticket flow (the version it started with, for its ticket's labels), so the rules skip what the flow skips. + var labels = TicketLabels(tickets); + var taskFlows = new Dictionary(); var planTasks = new List(); foreach (var s in sessions.Where(s => s.Status is "Failed" && !workflows.IsRunning(tenant, s.Id) && CutShortByPlanLimit(s, repos))) { @@ -224,7 +228,8 @@ public async Task> StepAsync(WorkspaceEntity boar var findings = testing == ChangeTesting.Rejected || review == ChangeReview.ChangesRequested ? r.Error : null; open.Add(new PlanChange(r.Id, number, checks, report, testing, findings, merge, r.DefaultBranch, review)); } - planTasks.Add(new PlanTask(s.Id, s.IssueNumber ?? 0, s.Status, open, s.FixRounds)); + var flow = await TaskFlowAsync(board, s.TicketFlowVersion, taskFlows, ct).ConfigureAwait(false); + planTasks.Add(new PlanTask(s.Id, s.IssueNumber ?? 0, s.Status, open, s.FixRounds) { Flow = flow.Definition.Resolve(LabelsOf(s, labels)) }); } // Tickets: a ticket with any task that isn't closed is the person's or already handled — never doubled. @@ -294,18 +299,25 @@ private async Task ExecuteAsync(AutopilotAction action, WorkspaceEntity board, s var session = await tasks.CreateAsync(tenant, member, board.Id, ticket.Number, $"#{ticket.Number} {ticket.Title}", ticket.ItemNodeId, ticket.Kind.ToString(), [new BoardTaskService.TaskRepo(ticket.RepoOwner!, ticket.RepoName!, curated?.DefaultBranch ?? "main", curated?.Id)], onComputer, ticketType: ticket.Labels.FirstOrDefault(l => l.StartsWith("type:", StringComparison.OrdinalIgnoreCase)), ct: ct).ConfigureAwait(false); + // The ticket runs the board's ticket flow as it is now — snapshotted on the task, so a later change of the + // flow never changes a ticket under way (MAF workflow or, after a restart, the step-by-step passes). + var flow = await flows.ResolveAsync(tenant, board.Id, ct).ConfigureAwait(false); + LogRefused(board, flow); + await sp.GetRequiredService().SetTicketFlowVersionForTenantAsync(tenant, session.Id, flow.Version, ct).ConfigureAwait(false); + var plan = flow.Definition.Resolve(ticket.Labels); + var flowNote = flow.Version == 0 && plan.Skipped.Count == 0 ? "" : $" · ticket flow v{flow.Version}: {plan.Summary}"; // A MAF workflow runs the ticket's whole agent flow (TicketWorkflow); step by step, the passes drive it. var engine = await sp.GetRequiredService() .GetAsync(AgentOs.Modules.Pipeline.Agents.AutopilotSettings.TicketEngineKey, tenant, ct).ConfigureAwait(false); if (AgentOs.Modules.Pipeline.Agents.AutopilotSettings.UsesMafWorkflow(engine.Value) - && workflows.Start(board, pat, session.Id, start.Number, ParseMode(board.Autopilot) == AutopilotMode.BuildAndMerge)) + && workflows.Start(board, pat, session.Id, start.Number, ParseMode(board.Autopilot) == AutopilotMode.BuildAndMerge, plan, flow.Version)) { - log.Add(tenant, board.Id, start.Why + (onComputer ? " (on your computer)" : " (on our servers)") + " — a MAF workflow runs it."); + log.Add(tenant, board.Id, start.Why + (onComputer ? " (on your computer)" : " (on our servers)") + " — a MAF workflow runs it" + flowNote + "."); break; } var started = await tasks.StartAsync(tenant, session.Id, ct: ct).ConfigureAwait(false); log.Add(tenant, board.Id, started == TaskStart.Started - ? start.Why + (onComputer ? " (on your computer)" : " (on our servers)") + ? start.Why + (onComputer ? " (on your computer)" : " (on our servers)") + flowNote : $"Couldn't start #{start.Number}: {started}."); break; } @@ -446,6 +458,41 @@ private async Task> DependenciesAsync(ISourceProvider? provid return deps; } + // Each ticket's labels by (repo, number) — what a task's ticket flow is resolved for. + internal static Dictionary<(string Owner, string Repo, int Number), IReadOnlyList> TicketLabels(IReadOnlyList tickets) + { + var map = new Dictionary<(string, string, int), IReadOnlyList>(); + foreach (var t in tickets.Where(t => t.Number.HasValue && t.RepoOwner is not null && t.RepoName is not null)) + { + map.TryAdd((t.RepoOwner!, t.RepoName!, t.Number.GetValueOrDefault()), t.Labels); + } + return map; + } + + // A task's ticket labels now; without its ticket on the board, the type label it was created with. + internal static IReadOnlyList LabelsOf(RemoteSessionEntity task, IReadOnlyDictionary<(string Owner, string Repo, int Number), IReadOnlyList> labels) => + task is { IssueNumber: { } n, RepoOwner: { } o, RepoName: { } r } && labels.TryGetValue((o, r, n), out var l) ? l + : task.TicketType is { Length: > 0 } type ? [type] : []; + + // The flow a task runs (the version it started with), once per version per pass. + private async Task TaskFlowAsync(WorkspaceEntity board, int? snapshot, Dictionary memo, CancellationToken ct) + { + var key = snapshot ?? -1; + if (memo.TryGetValue(key, out var known)) { return known; } + var flow = await flows.ForTaskAsync(board.TenantId, board.Id, snapshot, ct).ConfigureAwait(false); + LogRefused(board, flow); + memo[key] = flow; + return flow; + } + + private readonly ConcurrentDictionary<(Guid Board, string Why), bool> _refusedLogged = new(); + + // A stored flow that breaks a safety rule runs as the built-in one: said once per board in the Autopilot log. + private void LogRefused(WorkspaceEntity board, EffectiveTicketFlow flow) + { + if (flow.Refused is { } why && _refusedLogged.TryAdd((board.Id, why), true)) { log.Add(board.TenantId, board.Id, why); } + } + private static int? PrNumber(string? url) { if (string.IsNullOrEmpty(url)) { return null; } diff --git a/src/AgentOs.Web/Services/Orchestration/TicketFlowService.cs b/src/AgentOs.Web/Services/Orchestration/TicketFlowService.cs new file mode 100644 index 00000000..a2e90b01 --- /dev/null +++ b/src/AgentOs.Web/Services/Orchestration/TicketFlowService.cs @@ -0,0 +1,211 @@ +// The ticket flow a board runs (ADR-0016): the board's own flow when it has one, else the tenant's, else the built-in +// one (Code → Checks → Review → Test → Merge). Every load is validated: a stored version that breaks a safety +// invariant is refused — the run uses the built-in flow and the reason is returned so Autopilot's log can say so. +// Saving validates first (a definition that breaks an invariant is refused with the reasons, nothing is stored), only +// an admin may save, and every change appends a version (who / when), so any earlier one can be brought back. +// Versions are immutable, so a parsed version is cached per (tenant, version) — the Autopilot pass reads one per task. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Modules.Pipeline.Agents; +using AgentOs.Modules.Workspaces.Persistence; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgentOs.Web.Services.Orchestration; + +/// Where the ticket flow a board runs comes from. +public enum TicketFlowSource +{ + /// The built-in flow (nothing saved, or what was saved was refused). + BuiltIn, + + /// The tenant's flow (every project without its own). + Tenant, + + /// This project's own flow. + Board, +} + +/// The flow a board runs now. +/// The definition (always safe to run). +/// Its version (0 = the built-in flow). +/// Where it comes from. +/// Why a stored flow was refused in favour of the built-in one (plain words), or null. +public sealed record EffectiveTicketFlow(TicketFlowDefinition Definition, int Version, TicketFlowSource Source, string? Refused = null) +{ + /// The built-in flow. + public static EffectiveTicketFlow BuiltIn { get; } = new(TicketFlowDefinition.Default, 0, TicketFlowSource.BuiltIn); +} + +/// Who changes a ticket flow. +public sealed record TicketFlowActor(string? UserId, string? Name, bool IsAdmin); + +/// The outcome of a save or a revert. +/// The new version, when saved. +/// Why it was refused (empty when saved). +public sealed record TicketFlowSaveResult(TicketFlowVersion? Version, IReadOnlyList Problems) +{ + /// True when a version was appended. + public bool Saved => Version is not null; + + /// The reasons in one sentence each. + public string Reason => string.Join(" ", Problems.Select(p => p.Message)); +} + +/// Reads, validates, saves and reverts ticket flows. Singleton; tenant-explicit. +public sealed class TicketFlowService(IServiceScopeFactory scopes, ILogger logger) +{ + private const int CacheCap = 2048; + private readonly ConcurrentDictionary<(string Tenant, int Version), (TicketFlowDefinition? Definition, Guid BoardId)> _parsed = new(); + + /// Raised (with the tenant) after a flow of that tenant changed, so open views refresh. + public event Action? Changed; + + /// The flow runs now (null / = the tenant's flow). + public async Task ResolveAsync(string tenantId, Guid? boardId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + using var scope = scopes.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + if (boardId is { } board && board != Guid.Empty + && await store.CurrentAsync(tenantId, board, ct).ConfigureAwait(false) is { DefinitionJson: not null } own) + { + return Load(tenantId, own, TicketFlowSource.Board); + } + return await store.CurrentAsync(tenantId, Guid.Empty, ct).ConfigureAwait(false) is { DefinitionJson: not null } tenant + ? Load(tenantId, tenant, TicketFlowSource.Tenant) + : EffectiveTicketFlow.BuiltIn; + } + + /// The flow a task runs: the version it started with (; 0 = built-in), or — for a + /// task started before ticket flows — what the board runs now. + public async Task ForTaskAsync(string tenantId, Guid boardId, int? snapshot, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (snapshot is null) { return await ResolveAsync(tenantId, boardId, ct).ConfigureAwait(false); } + if (snapshot.Value <= 0) { return EffectiveTicketFlow.BuiltIn; } + if (_parsed.TryGetValue((tenantId, snapshot.Value), out var cached)) + { + return cached.Definition is null + ? Refuse(snapshot.Value, "it breaks a safety rule") + : new(cached.Definition, snapshot.Value, cached.BoardId == Guid.Empty ? TicketFlowSource.Tenant : TicketFlowSource.Board); + } + using var scope = scopes.CreateScope(); + var row = await scope.ServiceProvider.GetRequiredService().GetVersionAsync(tenantId, snapshot.Value, ct).ConfigureAwait(false); + return row is { DefinitionJson: not null } + ? Load(tenantId, row, row.BoardId == Guid.Empty ? TicketFlowSource.Tenant : TicketFlowSource.Board) + : Refuse(snapshot.Value, "it no longer exists"); + } + + /// The versions saved for a scope (a board, or null for the tenant), newest first. + public async Task> HistoryAsync(string tenantId, Guid? boardId, int max = 20, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + using var scope = scopes.CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .HistoryAsync(tenantId, boardId ?? Guid.Empty, max, ct).ConfigureAwait(false); + } + + /// Save as the scope's flow — refused, with the reasons, when it breaks an + /// invariant or the actor isn't an admin. + public async Task SaveAsync(string tenantId, Guid? boardId, TicketFlowDefinition definition, TicketFlowActor actor, + string? note = null, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(actor); + if (!actor.IsAdmin) { return new(null, [new(null, "Only an admin can change the ticket flow.")]); } + var problems = TicketFlowValidator.Validate(definition); + if (problems.Count > 0) { return new(null, problems); } + return await AppendAsync(tenantId, boardId, definition.ToJson(), actor, note, ct).ConfigureAwait(false); + } + + /// Bring version of the scope back (as a new version, so the history stays whole). + public async Task RevertAsync(string tenantId, Guid? boardId, int version, TicketFlowActor actor, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(actor); + if (!actor.IsAdmin) { return new(null, [new(null, "Only an admin can change the ticket flow.")]); } + TicketFlowVersion? row; + using (var scope = scopes.CreateScope()) + { + row = await scope.ServiceProvider.GetRequiredService().GetVersionAsync(tenantId, version, ct).ConfigureAwait(false); + } + var scopeId = boardId ?? Guid.Empty; + if (row is null || row.BoardId != scopeId) { return new(null, [new(null, $"Version {version} isn't one of this flow's versions.")]); } + var note = $"Reverted to v{version}"; + if (row.DefinitionJson is null) { return await AppendAsync(tenantId, boardId, null, actor, note, ct).ConfigureAwait(false); } + var definition = TicketFlowDefinition.FromJson(row.DefinitionJson); + var problems = TicketFlowValidator.Validate(definition); + if (problems.Count > 0) { return new(null, problems); } + return await AppendAsync(tenantId, boardId, definition!.ToJson(), actor, note, ct).ConfigureAwait(false); + } + + /// The board drops its own flow and runs the tenant's again. + public Task UseTenantFlowAsync(string tenantId, Guid boardId, TicketFlowActor actor, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(actor); + if (!actor.IsAdmin) { return Task.FromResult(new TicketFlowSaveResult(null, [new(null, "Only an admin can change the ticket flow.")])); } + return boardId == Guid.Empty + ? Task.FromResult(new TicketFlowSaveResult(null, [new(null, "Pick a project first.")])) + : AppendAsync(tenantId, boardId, null, actor, "Uses the default flow again", ct); + } + + private async Task AppendAsync(string tenantId, Guid? boardId, string? json, TicketFlowActor actor, string? note, CancellationToken ct) + { + TicketFlowVersion saved; + using (var scope = scopes.CreateScope()) + { + saved = await scope.ServiceProvider.GetRequiredService() + .AppendAsync(tenantId, boardId ?? Guid.Empty, json, actor.UserId, actor.Name, note, ct).ConfigureAwait(false); + await AuditAsync(scope.ServiceProvider, tenantId, actor, saved, ct).ConfigureAwait(false); + } + try + { + Changed?.Invoke(tenantId); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "A ticket flow listener failed"); + } + return new(saved, []); + } + + // Governance: the change is in Settings › Activity log like any other tenant setting change. Best-effort. + private static async Task AuditAsync(IServiceProvider sp, string tenantId, TicketFlowActor actor, TicketFlowVersion saved, CancellationToken ct) + { + if (sp.GetService() is not { } audit) { return; } + var target = (saved.BoardId == Guid.Empty ? "ticket flow (all projects)" : $"ticket flow (project {saved.BoardId:D})") + $" v{saved.Version}" + + (saved.Note is null ? "" : $" — {saved.Note}"); + await audit.WriteAsync(new AgentOs.Modules.Tenants.AuditEntry(Guid.NewGuid(), tenantId, actor.UserId, + AgentOs.Modules.Tenants.AuditActions.TicketFlowChanged, target, null, null, DateTimeOffset.UtcNow), ct).ConfigureAwait(false); + } + + private EffectiveTicketFlow Load(string tenantId, TicketFlowVersion row, TicketFlowSource source) + { + if (_parsed.Count > CacheCap) { _parsed.Clear(); } + var (definition, _) = _parsed.GetOrAdd((tenantId, row.Version), _ => (Parse(row.DefinitionJson), row.BoardId)); + return definition is null ? Refuse(row.Version, ReasonFor(row.DefinitionJson)) : new(definition, row.Version, source); + } + + // Parsed AND valid, or null: a cached entry is always safe to run. + private static TicketFlowDefinition? Parse(string? json) + { + var definition = TicketFlowDefinition.FromJson(json); + return TicketFlowValidator.IsValid(definition) ? definition : null; + } + + private static string ReasonFor(string? json) => + TicketFlowValidator.Validate(TicketFlowDefinition.FromJson(json)) is { Count: > 0 } problems ? problems[0].Message.TrimEnd('.') : "it can't be read"; + + private EffectiveTicketFlow Refuse(int version, string why) + { + logger.LogWarning("Ticket flow v{Version} refused; the built-in flow runs instead", version); + return EffectiveTicketFlow.BuiltIn with { Refused = $"Ticket flow v{version} was refused ({why}) — the built-in flow runs instead." }; + } +} diff --git a/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs b/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs index 1f49b661..eac49c2c 100644 --- a/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs +++ b/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs @@ -1,8 +1,11 @@ // One ticket's whole agent flow as a Microsoft Agent Framework Workflow: the Autopilot Orchestrator agent decides -// WHICH ticket starts; this graph then runs it — Coding → checks → Reviewer → Testing → merge — routing each verdict -// along a conditional edge (red checks, conflicts, requested changes and failed acceptance go back to the Coding agent, -// bounded by the same fix rounds the rules use — Settings › Autopilot, counted from the task's persisted FixRounds, so -// the cap is the true total across engines and restarts). Each step is one of the existing agents (ITicketSteps — the Board's +// WHICH ticket starts; this graph then runs it. The graph is built from the ticket flow (ADR-0016 — the definition the +// owner edits in Workflow › Ticket flow, Code → Checks → Review → Test → Merge by default), resolved for the ticket's +// labels: one executor per step the ticket runs, a "pass" edge to the next one, a "fail" edge back to Code for every step +// that can send a change back (red checks, conflicts, requested changes and failed acceptance), bounded by the same fix +// rounds the rules use — Settings › Autopilot, counted from the task's persisted FixRounds, so the cap is the true total +// across engines and restarts. A plan that breaks a safety invariant is never built: the default flow runs instead. +// Each step is one of the existing agents (ITicketSteps — the Board's // BoardTicketSteps in production), so governance, metrics (Insights) and evidence on the PR are unchanged. When the // graph ends without a merge — "needs a person", or an agent that couldn't run — the task is handed back to the // Orchestrator agent and the step-by-step rules, which also pick up any flow a restart cut short. A member's plan limit @@ -14,6 +17,7 @@ using System.Threading; using System.Threading.Tasks; using AgentOs.Domain.Workspaces; +using AgentOs.Modules.Pipeline.Agents; using AgentOs.Web.Services.BoardTasks; using Microsoft.Agents.AI.Workflows; @@ -42,6 +46,8 @@ public enum TicketStage Stopped, /// The member's AI plan hit its limit — handed back; Autopilot picks it up after the reset. PlanLimited, + /// A step of a kind added later (e.g. a design gate) passed: on to the next step. + Passed, } /// The state a ticket's workflow threads along every edge. @@ -65,6 +71,28 @@ public sealed class TicketFlow(string tenantId, Guid boardId, Guid sessionId, in /// The steps taken, for the Autopilot log. public List Trail { get; } = []; + /// The steps this ticket runs — its ticket flow resolved for its labels, snapshotted when it started. + public TicketFlowPlan Plan { get; init; } = TicketFlowPlan.Default; + + /// The ticket flow version came from (0 = the built-in flow). + public int FlowVersion { get; init; } + + /// The step running now (its node id), null before the first. + public string? StepId { get; private set; } + + /// The kind of the step running now. + public TicketStepKind? Step { get; private set; } + + /// Told whenever a step starts (the Workflow › Ticket flow view highlights it). + public Action? OnStep { get; init; } + + internal void Enter(TicketFlowNode node) + { + StepId = node.Id; + Step = node.Kind; + OnStep?.Invoke(this); + } + /// Send the change back to the Coding agent with — or stop when the rounds are used up. public TicketFlow SendBack(string what, string change) { @@ -130,49 +158,79 @@ public interface ITicketSteps /// Builds and runs a ticket's MAF workflow. public static class TicketWorkflow { - /// The graph. - public static Workflow Build(ITicketSteps steps, TimeSpan checksTimeout, TimeSpan pollInterval) + /// The default flow's graph (Code → Checks → Review → Test → Merge). + public static Workflow Build(ITicketSteps steps, TimeSpan checksTimeout, TimeSpan pollInterval) => + Build(steps, TicketFlowPlan.Default, checksTimeout, pollInterval); + + /// The graph for — the steps one ticket runs. A plan that breaks a safety invariant + /// () is never built: the default flow's graph is. + public static Workflow Build(ITicketSteps steps, TicketFlowPlan plan, TimeSpan checksTimeout, TimeSpan pollInterval) { ArgumentNullException.ThrowIfNull(steps); - var code = new CodeExecutor(steps); - var checks = new ChecksExecutor(steps, checksTimeout, pollInterval); - var review = new ReviewExecutor(steps); - var test = new TestExecutor(steps); - var merge = new MergeExecutor(steps); + ArgumentNullException.ThrowIfNull(plan); + var nodes = (plan.SafetyProblem() is null ? plan : TicketFlowPlan.Default).Steps; + var executors = nodes.Select(n => Create(n, steps, checksTimeout, pollInterval)).ToList(); + var code = executors[0]; var done = new DoneExecutor(); - return new WorkflowBuilder(code) - .AddEdge(code, checks, (TicketFlow? f) => f?.Stage == TicketStage.Coded) - .AddEdge(code, done, (TicketFlow? f) => f is not null && f.Stage != TicketStage.Coded) - .AddEdge(checks, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) - .AddEdge(checks, review, (TicketFlow? f) => f?.Stage == TicketStage.Green) - .AddEdge(checks, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Green)) - .AddEdge(review, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) - .AddEdge(review, test, (TicketFlow? f) => f?.Stage == TicketStage.Approved) - .AddEdge(review, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Approved)) - .AddEdge(test, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) - .AddEdge(test, merge, (TicketFlow? f) => f?.Stage == TicketStage.Accepted) - .AddEdge(test, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Accepted)) - .AddEdge(merge, done) - .WithOutputFrom(done) - .Build(); + var builder = new WorkflowBuilder(code); + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + var from = executors[i]; + if (i == nodes.Count - 1) + { + builder.AddEdge(from, done); + continue; + } + var passed = PassStage(node.Kind); + var backToCode = node.Info.CanSendBack; + builder.AddEdge(from, executors[i + 1], (TicketFlow? f) => f?.Stage == passed); + if (backToCode) { builder.AddEdge(from, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix); } + builder.AddEdge(from, done, (TicketFlow? f) => f is not null && f.Stage != passed && !(backToCode && f.Stage == TicketStage.Fix)); + } + return builder.WithOutputFrom(done).Build(); } - /// Run to its end; returns the final state. + /// Run (its ) to its end; returns the final state. public static async Task RunAsync(ITicketSteps steps, TicketFlow flow, TimeSpan checksTimeout, TimeSpan pollInterval, CancellationToken ct) { ArgumentNullException.ThrowIfNull(flow); - var run = await InProcessExecution.RunAsync(Build(steps, checksTimeout, pollInterval), flow, cancellationToken: ct).ConfigureAwait(false); + var run = await InProcessExecution.RunAsync(Build(steps, flow.Plan, checksTimeout, pollInterval), flow, cancellationToken: ct).ConfigureAwait(false); return run.NewEvents.OfType().Select(e => e.Data).OfType().LastOrDefault() ?? flow; } + // The stage a step leaves behind when it passed — what its "pass" edge reads. + private static TicketStage PassStage(TicketStepKind kind) => kind switch + { + TicketStepKind.Code => TicketStage.Coded, + TicketStepKind.Checks => TicketStage.Green, + TicketStepKind.Review => TicketStage.Approved, + TicketStepKind.Test => TicketStage.Accepted, + TicketStepKind.Merge => TicketStage.Merged, + _ => TicketStage.Passed, + }; + + // One executor per step kind — a new kind (TicketStepKinds) adds its executor here. + private static Executor Create(TicketFlowNode node, ITicketSteps steps, TimeSpan checksTimeout, TimeSpan pollInterval) => + node.Kind switch + { + TicketStepKind.Code => new CodeExecutor(node, steps), + TicketStepKind.Checks => new ChecksExecutor(node, steps, checksTimeout, pollInterval), + TicketStepKind.Review => new ReviewExecutor(node, steps), + TicketStepKind.Test => new TestExecutor(node, steps), + TicketStepKind.Merge => new MergeExecutor(node, steps), + _ => throw new NotSupportedException($"No executor for the {node.Kind} step."), + }; + // The member's plan is at its limit: stop before an agent runs (and after one ran into it) — a round spent then // would be spent on nothing. private static TicketFlow? PlanPause(ITicketSteps steps, TicketFlow f) => steps.PlanLimited(f) is { } why ? f.PausedForPlan(why) : null; - private sealed class CodeExecutor(ITicketSteps steps) : Executor("code") + private sealed class CodeExecutor(TicketFlowNode node, ITicketSteps steps) : Executor(node.Id) { public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) { + f.Enter(node); if (PlanPause(steps, f) is { } before) { return before; } var run = await steps.CodeAsync(f, ct).ConfigureAwait(false); if (run.Status != TaskStart.Started) { return f.Stop($"The Coding agent couldn't start: {run.Status}."); } @@ -190,10 +248,11 @@ public override async ValueTask HandleAsync(TicketFlow f, IWorkflowC } } - private sealed class ChecksExecutor(ITicketSteps steps, TimeSpan timeout, TimeSpan poll) : Executor("checks") + private sealed class ChecksExecutor(TicketFlowNode node, ITicketSteps steps, TimeSpan timeout, TimeSpan poll) : Executor(node.Id) { public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) { + f.Enter(node); var deadline = DateTimeOffset.UtcNow + timeout; do { @@ -235,10 +294,11 @@ public override async ValueTask HandleAsync(TicketFlow f, IWorkflowC } } - private sealed class ReviewExecutor(ITicketSteps steps) : Executor("review") + private sealed class ReviewExecutor(TicketFlowNode node, ITicketSteps steps) : Executor(node.Id) { public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) { + f.Enter(node); if (PlanPause(steps, f) is { } before) { return before; } var run = await steps.ReviewAsync(f, ct).ConfigureAwait(false); if (run.Status != TaskStart.Started) { return f.Stop($"The Reviewer agent couldn't start: {run.Status}."); } @@ -260,10 +320,11 @@ public override async ValueTask HandleAsync(TicketFlow f, IWorkflowC } } - private sealed class TestExecutor(ITicketSteps steps) : Executor("test") + private sealed class TestExecutor(TicketFlowNode node, ITicketSteps steps) : Executor(node.Id) { public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) { + f.Enter(node); if (PlanPause(steps, f) is { } before) { return before; } var run = await steps.TestAsync(f, ct).ConfigureAwait(false); if (run.Status != TaskStart.Started) { return f.Stop($"The Testing agent couldn't start: {run.Status}."); } @@ -285,10 +346,11 @@ public override async ValueTask HandleAsync(TicketFlow f, IWorkflowC } } - private sealed class MergeExecutor(ITicketSteps steps) : Executor("merge") + private sealed class MergeExecutor(TicketFlowNode node, ITicketSteps steps) : Executor(node.Id) { public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) { + f.Enter(node); if (!f.MayMerge) { f.Stage = TicketStage.ReadyToMerge; diff --git a/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs b/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs index b27d634d..c99511b1 100644 --- a/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs +++ b/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs @@ -22,27 +22,51 @@ namespace AgentOs.Web.Services.Orchestration; +/// A ticket whose MAF workflow runs now, and the step it is on — what Workflow › Ticket flow highlights. +/// Its board. +/// Its task. +/// Its ticket. +/// The ticket flow version it runs (0 = built-in). +/// The step running now (node id), null before the first. +/// Its kind. +/// The ids of the steps this ticket runs, in order. +/// When the current step started. +public sealed record TicketFlowLive(Guid BoardId, Guid SessionId, int IssueNumber, int FlowVersion, string? StepId, + TicketStepKind? Step, IReadOnlyList Steps, DateTimeOffset Since); + /// Runs ticket workflows. Singleton. public sealed class TicketWorkflowRunner(IServiceScopeFactory scopes, BoardTaskService tasks, OrchestratorLog log, ILogger logger, RetrospectiveService retrospectives, IPlanLimits planLimits) { private readonly ConcurrentDictionary<(string Tenant, Guid Session), Task> _running = new(); + private readonly ConcurrentDictionary<(string Tenant, Guid Session), TicketFlowLive> _live = new(); + + /// Raised (with the tenant) when one of its tickets enters a step or its workflow ends. Called off the + /// circuit — a view marshals with InvokeAsync. + public event Action? Changed; /// True while the task's workflow runs. public bool IsRunning(string tenantId, Guid sessionId) => _running.ContainsKey((tenantId, sessionId)); - /// Start the ticket's workflow; false when one already runs for it. - public bool Start(WorkspaceEntity board, string pat, Guid sessionId, int issueNumber, bool mayMerge) + /// The tenant's tickets whose workflow runs now, with their current step. + public IReadOnlyList Live(string tenantId) => + _live.Where(kv => kv.Key.Tenant == tenantId).Select(kv => kv.Value).OrderBy(l => l.IssueNumber).ToList(); + + /// Start the ticket's workflow on (its ticket flow resolved for its labels — the + /// default flow when null); false when one already runs for it. + public bool Start(WorkspaceEntity board, string pat, Guid sessionId, int issueNumber, bool mayMerge, TicketFlowPlan? plan = null, int flowVersion = 0) { ArgumentNullException.ThrowIfNull(board); var key = (board.TenantId, sessionId); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (!_running.TryAdd(key, gate.Task)) { return false; } + var run = plan ?? TicketFlowPlan.Default; + _live[key] = new TicketFlowLive(board.Id, sessionId, issueNumber, flowVersion, null, null, run.Steps.Select(s => s.Id).ToList(), DateTimeOffset.UtcNow); _ = Task.Run(async () => { try { - var (start, limits) = await SeedAsync(board, sessionId, issueNumber, mayMerge).ConfigureAwait(false); + var (start, limits) = await SeedAsync(board, sessionId, issueNumber, mayMerge, run, flowVersion, f => Entered(key, f)).ConfigureAwait(false); var flow = await TicketWorkflow.RunAsync(new BoardTicketSteps(tasks, scopes, board, pat, planLimits), start, limits.ChecksTimeout, limits.ChecksPoll, CancellationToken.None) .ConfigureAwait(false); @@ -56,29 +80,68 @@ public bool Start(WorkspaceEntity board, string pat, Guid sessionId, int issueNu finally { _running.TryRemove(key, out _); + _live.TryRemove(key, out _); gate.TrySetResult(); + Notify(board.TenantId); } }); + Notify(board.TenantId); return true; } + private void Entered((string Tenant, Guid Session) key, TicketFlow flow) + { + if (_live.TryGetValue(key, out var live)) + { + _live[key] = live with { StepId = flow.StepId, Step = flow.Step, Since = DateTimeOffset.UtcNow }; + } + Notify(key.Tenant); + } + + private void Notify(string tenantId) + { + try + { + Changed?.Invoke(tenantId); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "A ticket flow view failed to refresh"); + } + } + // The tenant's bounds now, and the flow's round counter from the task's persisted fix rounds. - private async Task<(TicketFlow Flow, AutopilotLimits Limits)> SeedAsync(WorkspaceEntity board, Guid sessionId, int issueNumber, bool mayMerge) + private async Task<(TicketFlow Flow, AutopilotLimits Limits)> SeedAsync(WorkspaceEntity board, Guid sessionId, int issueNumber, bool mayMerge, + TicketFlowPlan plan, int flowVersion, Action onStep) { using var scope = scopes.CreateScope(); var limits = await AutopilotSettings.ReadLimitsAsync(scope.ServiceProvider.GetRequiredService(), board.TenantId).ConfigureAwait(false); var task = (await scope.ServiceProvider.GetRequiredService().ListForTenantAsync(board.TenantId).ConfigureAwait(false)) .FirstOrDefault(s => s.Id == sessionId); - return (Seed(board.TenantId, board.Id, sessionId, issueNumber, mayMerge, task?.FixRounds ?? 0, limits), limits); + return (Seed(board.TenantId, board.Id, sessionId, issueNumber, mayMerge, task?.FixRounds ?? 0, limits, plan, flowVersion, onStep), limits); } - /// A ticket's flow, its rounds counted from under . - internal static TicketFlow Seed(string tenantId, Guid boardId, Guid sessionId, int issueNumber, bool mayMerge, int persistedFixRounds, AutopilotLimits limits) => - new(tenantId, boardId, sessionId, issueNumber, mayMerge) { Round = Math.Max(0, persistedFixRounds), MaxRounds = limits.MaxFixRounds }; + /// A ticket's flow, its rounds counted from under , + /// running (the default flow when null) — the ticket flow snapshotted when it started. + internal static TicketFlow Seed(string tenantId, Guid boardId, Guid sessionId, int issueNumber, bool mayMerge, int persistedFixRounds, AutopilotLimits limits, + TicketFlowPlan? plan = null, int flowVersion = 0, Action? onStep = null) => + new(tenantId, boardId, sessionId, issueNumber, mayMerge) + { + Round = Math.Max(0, persistedFixRounds), + MaxRounds = limits.MaxFixRounds, + Plan = plan ?? TicketFlowPlan.Default, + FlowVersion = flowVersion, + OnStep = onStep, + }; private async Task FinishAsync(WorkspaceEntity board, TicketFlow flow) { var steps = flow.Trail.Count == 0 ? "" : " (" + string.Join(" → ", flow.Trail) + ")"; + // A ticket that ran a flow other than the built-in one says which, so the log reads true. + if (flow.FlowVersion > 0 || flow.Plan.Skipped.Count > 0) + { + steps += $" · ticket flow v{flow.FlowVersion}: {flow.Plan.Summary}"; + } var line = flow.Stage switch { TicketStage.Merged => $"#{flow.IssueNumber} · MAF workflow: merged{steps}.", From 6b75a5a348a0c8b56598fa0c627e3c5a6e3a61de Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 16:52:03 +0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(autopilot):=20Workflow=20=E2=80=BA=20T?= =?UTF-8?q?icket=20flow=20view=20and=20Board=20=E2=80=BA=20Autopilot=20lin?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph + table of the ticket flow with inline validation, save, revert and the live step; members read-only. Board › Autopilot summarises the flow and links to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Agents/AutopilotSettings.cs | 2 +- .../Orchestration/TicketFlowView.razor | 549 ++++++++++++++++++ .../Pages/AutopilotSettingsApp.razor | 3 +- .../Components/Pages/BoardApp.razor | 22 + .../Pages/OrchestrationStudio.razor | 47 +- src/AgentOs.Web/wwwroot/orchestration.css | 67 +++ 6 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor diff --git a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs index 0787c6a6..30c18cbb 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs @@ -110,7 +110,7 @@ public IEnumerable Describe() { DefaultValue = MafWorkflow, Choices = [MafWorkflow, StepByStep], - Help = "MAF workflow: a Microsoft Agent Framework graph runs Coding → checks → Reviewer → Testing → merge for the ticket, routing every verdict. Step by step: Autopilot drives each step itself every minute. Both use the same agents, rules and evidence.", + Help = "MAF workflow: a Microsoft Agent Framework graph runs the ticket flow (Workflow › Ticket flow — Code → Checks → Review → Test → Merge by default) for the ticket, routing every verdict. Step by step: Autopilot drives each step itself every minute. Both run the same ticket flow, agents, rules and evidence.", }; yield return Bound(MaxParallelKey, "Tasks at once", "How many tickets the AI works on at the same time on a project (coding, fixing, reviewing or testing). Each one uses your computer."); diff --git a/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor b/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor new file mode 100644 index 00000000..fc530769 --- /dev/null +++ b/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor @@ -0,0 +1,549 @@ +@* Workflow › Ticket flow (ADR-0016): the per-ticket graph Autopilot runs, drawn with the studio's node cards. An admin + toggles steps, picks the agent per step, limits a step by ticket labels, sees every broken safety rule inline, saves + (a new version) and reverts to an earlier one; a member sees it read-only. While a ticket's MAF workflow runs, the + step it is on is highlighted — the only thing here that animates. A table shows the same flow for screen readers + and keyboard users. *@ +@using System.Globalization +@using AgentOs.Modules.Pipeline.Agents +@using AgentOs.Modules.Workspaces.Persistence +@using AgentOs.Web.Services.Orchestration +@using AgentOs.Web.Shell.Services +@inject TicketFlowService Flows +@inject TicketWorkflowRunner Runner +@inject IWorkspaceRepository Workspaces +@inject AgentOs.Domain.Configuration.ISettings SettingsStore +@inject ToastService Toasts +@implements IDisposable + +
+
+ + @SourceLine +
+
+ + +
+ @if (IsAdmin) + { + @if (_scope is not null && _effective.Source == TicketFlowSource.Board) + { + + } + + + } +
+ + @if (!IsAdmin) + { +

Only an admin can change the ticket flow — you see it as it runs.

+ } + else if (_scope is not null && _effective.Source != TicketFlowSource.Board) + { +

This project runs the default flow. Saving here gives it its own flow — it then wins over the default.

+ } + @if (_effective.Refused is { } refused) + { +

@refused

+ } + @if (_saveError is { } error) + { + + } + else if (_problems.Count > 0) + { +
+ This flow can't run as it is: +
    @foreach (var p in _problems){
  • @p.Message
  • }
+
+ } + +
+
+ @if (_table) + { +
+ + + + + @foreach (var (node, i) in _ordered.Select((n, i) => (n, i))) + { + + + + + + + + + + + } + +
@Caption
#StepRunsAgentFor ticketsWhen it passesWhen it failsNow
@(i + 1) + @if (IsAdmin) + { + + } + else { @node.Info.Label } + @(node.Enabled ? "Yes" : "Off")@(node.EffectiveRole is { } role ? AgentRoles.Label(role) : "—")@ForTickets(node)@OnPass(node)@OnFail(node)@LiveText(node)
+
+ } + else + { +
+
    + @foreach (var (node, i) in _ordered.Select((n, i) => (n, i))) + { + var here = LiveAt(node.Id); +
  1. + + @if (i < _ordered.Count - 1) + { + + } +
  2. + } +
+

Fail → back to Code: @SendsBack — up to @Rounds automatic fix @(_limits.MaxFixRounds == 1 ? "round" : "rounds") per task in total (Settings › Autopilot), then a person is asked.

+
+ } + + @if (_live.Count > 0) + { +

+ Running now: + @foreach (var l in _live) + { + #@l.IssueNumber · @(l.Step is { } s ? TicketStepKinds.Of(s).Label : "starting") · v@(l.FlowVersion) + } +

+ } + +
+

Versions

+ @if (_history.Count == 0) + { +

@(_scope is null ? "Nothing saved yet — every project runs the built-in flow." : "Nothing saved for this project yet.")

+ } + else + { +
    + @foreach (var (v, i) in _history.Select((v, i) => (v, i))) + { +
  1. + v@(v.Version) + @(v.Note ?? (v.DefinitionJson is null ? "Uses the default flow" : "Saved")) · @Summary(v) + @(v.CreatedByName ?? v.CreatedByUserId ?? "someone") · + @if (i == 0) + { + In use + } + else if (IsAdmin) + { + + } +
  2. + } +
+ } +
+
+ + +
+
+ +@code { + /// The signed-in tenant (a circuit has no HttpContext — passed explicitly). + [Parameter, EditorRequired] public string TenantId { get; set; } = string.Empty; + + /// The signed-in user (recorded on every saved version). + [Parameter] public string? UserId { get; set; } + + /// Their name as shown in the versions. + [Parameter] public string? UserName { get; set; } + + /// Admins edit; members see it read-only. + [Parameter] public bool IsAdmin { get; set; } + + /// The project to open on (Board › Autopilot links here); null = the default flow. + [Parameter] public Guid? BoardId { get; set; } + + private sealed record BoardOption(Guid Id, string Name); + + private List _boards = []; + private Guid? _scope; + private Guid? _openedFor; + private EffectiveTicketFlow _effective = EffectiveTicketFlow.BuiltIn; + private TicketFlowDefinition _draft = TicketFlowDefinition.Default; + private IReadOnlyList _ordered = []; + private IReadOnlyList _problems = []; + private IReadOnlyList _history = []; + private IReadOnlyList _live = []; + private AutopilotLimits _limits = AutopilotLimits.Default; + private string? _selectedId; + private string? _saveError; + private bool _table; + private bool _busy; + + private TicketFlowNode? Selected => _selectedId is null ? null : _draft.Find(_selectedId); + + // Computed so each class attribute is one expression the phantom-CSS scanner reads as class names. + private string DockClass => Selected is null ? "syn-dock empty" : "syn-dock"; + private string GraphBtnClass => _table ? "syn-btn" : "syn-btn is-on"; + private string TableBtnClass => _table ? "syn-btn is-on" : "syn-btn"; + private string ScopeValue => _scope?.ToString("D") ?? string.Empty; + private bool Dirty => !string.Equals(_draft.ToJson(), _effective.Definition.ToJson(), StringComparison.Ordinal); + private string Rounds => _limits.MaxFixRounds.ToString(CultureInfo.InvariantCulture); + + private string Caption => $"Ticket flow {(_scope is null ? "for all projects" : "for " + BoardName(_scope.Value))}: {_draft.Summary()}"; + + private string SourceLine => _effective.Source switch + { + TicketFlowSource.Board => $"v{_effective.Version} · this project's own flow", + TicketFlowSource.Tenant => $"v{_effective.Version} · the default flow for all projects", + _ => "Built-in flow", + } + (Dirty ? " · unsaved changes" : ""); + + private string SendsBack => string.Join(", ", _ordered.Where(n => n.Info.CanSendBack && n.Enabled).Select(n => n.Info.Label)) is { Length: > 0 } s ? s : "nothing"; + + protected override async Task OnInitializedAsync() + { + Flows.Changed += OnFlowsChanged; + Runner.Changed += OnRunnerChanged; + try + { + _boards = (await Workspaces.ListForTenantAsync(TenantId)).Select(b => new BoardOption(b.Id, b.Name)).ToList(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The project list only adds per-project scopes — the default flow still opens. + _boards = []; + } + _limits = await AutopilotSettings.ReadLimitsAsync(SettingsStore, TenantId); + } + + protected override async Task OnParametersSetAsync() + { + // Board › Autopilot opens the view on its project (a request while the window is already open lands here too). + if (BoardId != _openedFor || _ordered.Count == 0) + { + _openedFor = BoardId; + _scope = BoardId is { } b && b != Guid.Empty && _boards.Any(x => x.Id == b) ? b : null; + await LoadAsync(); + } + } + + private async Task LoadAsync() + { + _effective = await Flows.ResolveAsync(TenantId, _scope); + _history = await Flows.HistoryAsync(TenantId, _scope); + _draft = _effective.Definition; + _saveError = null; + Revalidate(); + _live = LiveForScope(); + } + + private void Revalidate() + { + _ordered = _draft.Ordered(); + _problems = TicketFlowValidator.Validate(_draft); + } + + private async Task OnScopeChanged(ChangeEventArgs e) + { + _scope = Guid.TryParse(e.Value as string, out var id) ? id : null; + _selectedId = null; + await LoadAsync(); + } + + private void Select(string id) => _selectedId = _selectedId == id ? null : id; + + private void Update(TicketFlowNode node) + { + if (!IsAdmin) { return; } + _draft = _draft.With(node); + _saveError = null; + Revalidate(); + } + + private void Discard() + { + _draft = _effective.Definition; + _saveError = null; + Revalidate(); + } + + private TicketFlowActor Actor => new(UserId, UserName, IsAdmin); + + private async Task SaveAsync() + { + _busy = true; + try + { + var result = await Flows.SaveAsync(TenantId, _scope, _draft, Actor); + if (!result.Saved) + { + _saveError = result.Reason; + Toasts.Show("Ticket flow", "Not saved: " + result.Reason, ToastKind.Err); + return; + } + Toasts.Show("Ticket flow", $"Saved v{result.Version!.Version} — new tickets run it; tickets under way keep the flow they started with.", ToastKind.Ok); + await LoadAsync(); + } + finally + { + _busy = false; + } + } + + private async Task RevertAsync(int version) + { + _busy = true; + try + { + var result = await Flows.RevertAsync(TenantId, _scope, version, Actor); + if (!result.Saved) + { + _saveError = result.Reason; + Toasts.Show("Ticket flow", "Not reverted: " + result.Reason, ToastKind.Err); + return; + } + Toasts.Show("Ticket flow", $"Reverted to v{version.ToString(CultureInfo.InvariantCulture)} (saved as v{result.Version!.Version}).", ToastKind.Ok); + await LoadAsync(); + } + finally + { + _busy = false; + } + } + + private async Task UseDefaultAsync() + { + if (_scope is not { } board) { return; } + _busy = true; + try + { + var result = await Flows.UseTenantFlowAsync(TenantId, board, Actor); + if (result.Saved) { Toasts.Show("Ticket flow", "This project runs the default flow again.", ToastKind.Ok); } + await LoadAsync(); + } + finally + { + _busy = false; + } + } + + // Another admin saved, or a ticket moved on: refresh (an unsaved edit here is kept). + private void OnFlowsChanged(string tenant) + { + if (tenant != TenantId) { return; } + _ = InvokeAsync(async () => + { + var keep = Dirty ? _draft : null; + await LoadAsync(); + if (keep is not null) { _draft = keep; Revalidate(); } + StateHasChanged(); + }); + } + + private void OnRunnerChanged(string tenant) + { + if (tenant != TenantId) { return; } + _ = InvokeAsync(() => + { + _live = LiveForScope(); + StateHasChanged(); + }); + } + + private IReadOnlyList LiveForScope() => + Runner.Live(TenantId).Where(l => _scope is null || l.BoardId == _scope).ToList(); + + private List LiveAt(string nodeId) => _live.Where(l => l.StepId == nodeId).ToList(); + + private string LiveText(TicketFlowNode node) => LiveAt(node.Id) is { Count: > 0 } here + ? string.Join(", ", here.Select(l => "#" + l.IssueNumber.ToString(CultureInfo.InvariantCulture))) + " running" + : "—"; + + private string BoardName(Guid id) => _boards.FirstOrDefault(b => b.Id == id)?.Name ?? "this project"; + + private string CardClass(TicketFlowNode node, bool running) + { + var run = running ? "run-running" : node.Enabled ? "run-idle" : "run-skipped"; + var sel = node.Id == _selectedId ? " is-selected" : ""; + var bad = _problems.Any(p => p.NodeId == node.Id) ? " tf-has-problem" : ""; + return $"step-node tf-node cat-{CatOf(node.Kind)} {run}{sel}{bad}"; + } + + private string CardLabel(TicketFlowNode node, IReadOnlyList here) + { + var who = node.EffectiveRole is { } r ? $", run by {AgentRoles.Label(r)}" : ""; + var state = node.Enabled ? "on" : "off"; + var now = here.Count > 0 ? $", running now for {string.Join(", ", here.Select(l => "#" + l.IssueNumber.ToString(CultureInfo.InvariantCulture)))}" : ""; + return $"{node.Info.Label} step, {state}{who}, {ForTickets(node)}{now}. {(IsAdmin ? "Press to edit." : "Press for details.")}"; + } + + private static string CatOf(TicketStepKind kind) => kind switch + { + TicketStepKind.Code or TicketStepKind.Review or TicketStepKind.Test => "agent", + TicketStepKind.Merge => "terminal", + _ => "logic", + }; + + private static string IconOf(TicketStepKind kind) => kind switch + { + TicketStepKind.Code => "terminal", + TicketStepKind.Checks => "shield", + TicketStepKind.Review => "search", + TicketStepKind.Test => "check", + TicketStepKind.Merge => "git-pull-request", + _ => "diamond", + }; + + private static AgentMascot.MascotState MascotOf(TicketFlowNode node, bool running) => + running ? AgentMascot.MascotState.Running : node.Enabled ? AgentMascot.MascotState.Idle : AgentMascot.MascotState.Skipped; + + private static string ForTickets(TicketFlowNode node) => + !node.Enabled ? "Off — every ticket passes through" + : node.Conditions() is { Length: > 0 } c ? "Tickets " + c.Replace("only ", "with ", StringComparison.Ordinal).Replace("not ", "without ", StringComparison.Ordinal) + : "Every ticket"; + + private string OnPass(TicketFlowNode node) + { + if (node.Kind == TicketStepKind.Merge) { return "Done"; } + var after = _ordered.SkipWhile(n => n.Id != node.Id).Skip(1).Where(n => n.Enabled).Select(n => n.Info.Label).FirstOrDefault(); + return after is null ? "—" : "On to " + after; + } + + private static string OnFail(TicketFlowNode node) => node.Kind switch + { + TicketStepKind.Merge => "Handed back to Autopilot", + _ when node.Info.CanSendBack => "Back to Code (a fix round)", + _ => "Handed back to Autopilot", + }; + + private static string? RoleOrDefault(TicketFlowNode node, string? picked) => + string.IsNullOrWhiteSpace(picked) || picked == node.Info.DefaultRole ? null : picked; + + private static List ParseLabels(string? text) => + (text ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + private static string Summary(TicketFlowVersion v) => + v.DefinitionJson is null ? "the default flow" : TicketFlowDefinition.FromJson(v.DefinitionJson)?.Summary() ?? "can't be read"; + + public void Dispose() + { + Flows.Changed -= OnFlowsChanged; + Runner.Changed -= OnRunnerChanged; + } +} diff --git a/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor b/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor index ebfb2cb3..3c3fbbbc 100644 --- a/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor +++ b/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor @@ -40,7 +40,8 @@ else
    -
  • Merging. With Build and merge, a change is merged only when its checks are green, the Reviewer agent approved it and the Testing agent passed it against the ticket's acceptance criteria — which needs a connected computer. Nothing untested is merged.
  • +
  • Merging. With Build and merge, a change is merged only when its checks are green and its ticket flow's gates passed — by default the Reviewer agent approved it and the Testing agent passed it against the ticket's acceptance criteria, which needs a connected computer.
  • +
  • Ticket flow. The steps each ticket runs are the ticket flow in Workflow › Ticket flow (per project, versioned). It may skip Review or Test for some tickets, but never both, and checks always run before a merge.
  • Order. Approved tickets start p0 first, then oldest — never before the tickets they depend on are done. Tickets marked for a person are never touched.
  • Plan limits. When your computer's Claude or Codex plan hits its limit, Autopilot waits until the plan resets: no new work, and no fix round or retry is spent. Work it cut short picks up again after the reset — nothing is lost.
diff --git a/src/AgentOs.Web/Components/Pages/BoardApp.razor b/src/AgentOs.Web/Components/Pages/BoardApp.razor index 03d56d92..26c7859c 100644 --- a/src/AgentOs.Web/Components/Pages/BoardApp.razor +++ b/src/AgentOs.Web/Components/Pages/BoardApp.razor @@ -35,6 +35,7 @@ @inject AgentOs.Web.Services.BoardTasks.BoardTaskService TaskService @inject AgentOs.Web.Services.Orchestration.ProjectOrchestrator Orchestrator @inject AgentOs.Web.Services.Orchestration.OrchestratorLog OrchestratorLog +@inject AgentOs.Web.Services.Orchestration.TicketFlowService TicketFlows @inject AgentOs.Modules.Sessions.Pairing.IComputerPairingCodes PairingCodes @inject AgentOs.Modules.RemoteAgent.IRemoteAgentBroker Broker @inject AgentOs.Modules.RemoteAgent.IPlanLimits PlanLimits @@ -695,6 +696,8 @@ else if (_tab == Tab.Autopilot) ? "The Orchestrator agent reads the whole project whenever it changes and decides the next moves — within fixed rules: nothing untested is merged, no ticket starts before what it depends on. It retries stuck work with guidance, asks you precise questions on the ticket, and never touches tickets marked for a person." : "The fixed rules decide the next moves: approved tickets in dependency order, p0 first; nothing untested is merged; tickets marked for a person are never touched.") @(_isAdmin ? "" : " An admin sets how Autopilot runs in Settings › Autopilot.")

+

Each ticket runs: @(_ticketFlowSummary.GetValueOrDefault(b.Id) ?? AgentOs.Modules.Pipeline.Agents.TicketFlowDefinition.Default.Summary()) + — Workflow › Ticket flow shows @(_isAdmin ? "and changes " : "")it.

@if (PlanLimits.Current(_tenantId, b.AutopilotUserId ?? b.CreatedByUserId ?? "") is { } hold && mode != AgentOs.Web.Services.Orchestration.AutopilotMode.Off) {
@@ -706,6 +709,9 @@ else if (_tab == Tab.Autopilot) Check now + + Ticket flow + @if (_isAdmin) { @@ -2446,9 +2452,25 @@ else if (_tab == Tab.Sessions) _autopilotLimits = await AgentOs.Modules.Pipeline.Agents.AutopilotSettings.ReadLimitsAsync(SettingsStore, _tenantId); _autopilotAgentDecides = AgentOs.Modules.Pipeline.Agents.AutopilotSettings.AgentDecides( (await SettingsStore.GetAsync(AgentOs.Modules.Pipeline.Agents.AutopilotSettings.DecidedByKey, _tenantId)).Value); + foreach (var b in _boards ?? []) + { + _ticketFlowSummary[b.Id] = (await TicketFlows.ResolveAsync(_tenantId, b.Id)).Definition.Summary(); + } await LoadAutopilotLogAsync(); } + // The ticket flow each board runs, in one line ("Code → Checks → Review → Test → Merge"), read when the tab opens. + private readonly Dictionary _ticketFlowSummary = new(); + + // Board › Autopilot → Workflow › Ticket flow, opened on this project's flow. + private void OpenTicketFlow(Guid boardId) + { + var app = AgentOs.Web.Services.AppCatalog.Find("workflow"); + if (app is null) { return; } + WM.RequestLaunchTab("workflow", $"{OrchestrationStudio.TicketFlowTab}:{boardId:D}"); + WM.OpenApp(app.Key, app.Title, app.Icon, app.W, app.H); + } + private static string AtATime(int n) => n switch { 1 => "one at a time", diff --git a/src/AgentOs.Web/Components/Pages/OrchestrationStudio.razor b/src/AgentOs.Web/Components/Pages/OrchestrationStudio.razor index 330fe9d9..318a957d 100644 --- a/src/AgentOs.Web/Components/Pages/OrchestrationStudio.razor +++ b/src/AgentOs.Web/Components/Pages/OrchestrationStudio.razor @@ -27,6 +27,15 @@
+ @* Two views: the workflows you build and run here, and the ticket flow Autopilot runs for every ticket. *@ + + @if (_studioView == TicketFlowTab) + { + + } + else + { @* ---- slim toolbar: workflow switch, name, live provider, Save, Run, overflow ---- *@
@@ -347,6 +356,7 @@ }
+ }
@* Command palette — centred quick-add over the whole app (⌘K or the toolbar "+ Add"). *@ @@ -393,6 +403,36 @@ private const int DefaultQaCap = 3; private bool _isAdmin; + private string? _userName; + + // The studio's two views. "ticket-flow" (optionally "ticket-flow:") is also the launch tab Board › + // Autopilot requests to open the Ticket flow view on its project. + internal const string WorkflowsTab = "workflows"; + internal const string TicketFlowTab = "ticket-flow"; + private static readonly TabStrip.TabDef[] StudioViews = [new(WorkflowsTab, "Workflows"), new(TicketFlowTab, "Ticket flow")]; + private string _studioView = WorkflowsTab; + private Guid? _ticketFlowBoard; + + private void ShowView(string view) => _studioView = view == TicketFlowTab ? TicketFlowTab : WorkflowsTab; + + // A launch-tab request for the Ticket flow view (null when the request is something else). + private bool ApplyTicketFlowRequest(string? tab) + { + if (tab is null || !tab.StartsWith(TicketFlowTab, StringComparison.Ordinal)) { return false; } + _studioView = TicketFlowTab; + _ticketFlowBoard = Guid.TryParse(tab.AsSpan(TicketFlowTab.Length).TrimStart(':'), out var board) ? board : null; + return true; + } + + // Board › Autopilot → "Ticket flow" while the Workflow window is already open. + private void OnWindowsChanged() + { + if (WM.ConsumeLaunchTab("workflow") is { } tab) + { + if (!ApplyTicketFlowRequest(tab)) { WM.RequestLaunchTab("workflow", tab); return; } + _ = InvokeAsync(StateHasChanged); + } + } private List _all = []; private OrchestrationGraph _graph = null!; @@ -567,7 +607,10 @@ _runInput = prefill; } // Home's "Run now" also asks to start: one click from a story to a running pipeline. - _autoRun = WM.ConsumeLaunchTab("workflow") == HomeApp.WorkflowAutoRun && !string.IsNullOrWhiteSpace(_runInput); + var launchTab = WM.ConsumeLaunchTab("workflow"); + _autoRun = launchTab == HomeApp.WorkflowAutoRun && !string.IsNullOrWhiteSpace(_runInput); + ApplyTicketFlowRequest(launchTab); + WM.Changed += OnWindowsChanged; BuildDiagram(); } @@ -577,6 +620,7 @@ if (AuthState is null) { return string.Empty; } var user = (await AuthState).User; _userId = user.GetUserId(); + _userName = user.Identity?.Name; _isAdmin = user.IsAdmin(); return user.GetTenantId() ?? string.Empty; } @@ -1220,6 +1264,7 @@ public void Dispose() { + WM.Changed -= OnWindowsChanged; _humanTcs?.TrySetResult(new GraphHumanReply(false, "Editor closed.")); // Best-effort: drop the global key listener and the .NET ref so a closed window stops receiving ⌘K. if (_selfRef is not null) diff --git a/src/AgentOs.Web/wwwroot/orchestration.css b/src/AgentOs.Web/wwwroot/orchestration.css index 36fbcc70..90b56d4f 100644 --- a/src/AgentOs.Web/wwwroot/orchestration.css +++ b/src/AgentOs.Web/wwwroot/orchestration.css @@ -511,3 +511,70 @@ body.panel-resizing, body.panel-resizing * { cursor: row-resize !important; user .syn-usage-key.out { background: var(--syn-green-border); } .syn-usage-bar .cr, .syn-usage-key.cr { background: var(--syn-eval); } .syn-usage-bar .cw, .syn-usage-key.cw { background: var(--warn, #c98a0a); } + +/* ===== Ticket flow (Workflow › Ticket flow, ADR-0016) ===== + The per-ticket graph Autopilot runs, drawn with the studio's .step-node cards. Tokens only. Nothing animates at rest: + the only motion is the .sn-status spinner on a step a ticket is running now, and it stops under + prefers-reduced-motion or System → Appearance → Animations off. */ +.syn-viewtabs { flex: none; background: var(--syn-head); } +.tf-root { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; } +.tf-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-2) var(--space-5); border-bottom: 1px solid var(--syn-border); } +.tf-scope { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--fs-sm); color: var(--syn-text-soft); } +.tf-source { font-size: var(--fs-xs); color: var(--syn-muted); font-variant-numeric: tabular-nums; } +.tf-viewswitch { display: inline-flex; gap: var(--space-1); } +.tf-viewswitch .syn-btn.is-on { border-color: var(--syn-blue); color: var(--syn-blue); } +.tf-note, .tf-alert { margin: var(--space-2) var(--space-5) 0; padding: var(--space-2) var(--space-3); border-radius: var(--r-3); font-size: var(--fs-sm); display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.tf-note { background: var(--syn-panel-2); color: var(--syn-text-soft); border: 1px solid var(--syn-border); } +.tf-alert { background: var(--syn-danger-bg); color: var(--syn-danger-fg); border: 1px solid var(--syn-danger-border); } +.tf-alert ul { margin: 0; padding-left: var(--space-5); flex-basis: 100%; } +.tf-work { flex: 1; display: flex; min-height: 0; margin-top: var(--space-2); } +.tf-main { flex: 1; min-width: 0; overflow: auto; padding: 0 var(--space-5) var(--space-5); } +.tf-canvas { + background-color: var(--syn-bg); border: 1px solid var(--syn-border); border-radius: var(--r-4); + background-image: radial-gradient(var(--syn-canvas-dot) 1px, transparent 1px); background-size: 26px 26px; + padding: var(--space-6) var(--space-5) var(--space-4); overflow-x: auto; +} +.tf-chain { list-style: none; margin: 0; padding: 0; display: flex; align-items: stretch; gap: 0; } +.tf-step { display: flex; align-items: center; flex: none; } +.tf-node { padding: 0; font: inherit; text-align: left; color: inherit; cursor: pointer; display: block; min-height: 132px; } +.tf-node:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.tf-node .sn-type, .tf-node .sn-desc, .tf-node .sn-meta { display: block; } +.tf-node.tf-has-problem { border-color: var(--syn-danger-border); } +.tf-off { margin-left: auto; font-size: var(--fs-xs); font-weight: var(--fw-semibold); color: var(--syn-muted); border: 1px dashed var(--syn-faint); border-radius: 999px; padding: 0 var(--space-2); } +.tf-arrow { display: inline-flex; flex-direction: column; align-items: center; gap: var(--space-1); padding: 0 var(--space-2); color: var(--syn-muted); } +.tf-edge { font-size: var(--fs-xs); color: var(--syn-green-fg); } +.tf-back { margin: var(--space-4) 0 0; display: flex; align-items: flex-start; gap: var(--space-2); font-size: var(--fs-sm); color: var(--syn-text-soft); border-top: 1px dashed var(--syn-border-2); padding-top: var(--space-3); } +.tf-back strong { color: var(--syn-danger-fg); font-weight: var(--fw-semibold); } +.tf-tablewrap { overflow-x: auto; } +.tf-table caption { text-align: left; font-size: var(--fs-sm); color: var(--syn-muted); padding: var(--space-1) 0 var(--space-2); } +.tf-table th[scope="row"] { font-weight: var(--fw-semibold); text-align: left; } +.tf-rowbtn { background: transparent; border: 0; padding: 0; font: inherit; color: var(--syn-blue); cursor: pointer; text-decoration: underline; } +.tf-rowbtn:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-1); } +.tf-livelist { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: var(--fs-sm); margin: var(--space-3) 0 0; } +.tf-livechip { border: 1px solid var(--syn-blue); color: var(--syn-blue); border-radius: 999px; padding: 0 var(--space-2); font-variant-numeric: tabular-nums; } +.tf-history { margin-top: var(--space-5); } +.tf-history h3 { margin: 0 0 var(--space-2); font-size: var(--fs-sm); font-weight: var(--fw-semibold); color: var(--syn-text-strong); } +.tf-versions { list-style: none; margin: 0; padding: 0; } +.tf-versions li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--syn-border); font-size: var(--fs-sm); } +.tf-vnum { font-weight: var(--fw-semibold); color: var(--syn-text-strong); font-variant-numeric: tabular-nums; min-width: 32px; } +.tf-vwhat { color: var(--syn-text); flex: 1; min-width: 200px; } +.tf-vwho { color: var(--syn-muted); font-variant-numeric: tabular-nums; } +.tf-vcur { font-size: var(--fs-xs); color: var(--syn-green-fg); border: 1px solid var(--syn-green-border); border-radius: 999px; padding: 0 var(--space-2); } +.tf-muted { color: var(--syn-muted); font-size: var(--fs-sm); margin: 0; } +.tf-desc { margin: 0; font-size: var(--fs-sm); color: var(--syn-text-soft); line-height: 1.4; } +.tf-problem { margin: 0; font-size: var(--fs-sm); color: var(--syn-danger-fg); } +.tf-switch { background: transparent; border: 0; padding: 0; font: inherit; text-align: left; } +.tf-switch:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--r-2); } +.syn-dock .dock-f select { + width: 100%; margin-top: var(--space-1); background: var(--syn-input-bg); color: var(--syn-text); + border: 1px solid var(--syn-border); border-radius: var(--r-3); padding: var(--space-2); font-family: inherit; font-size: var(--fs-sm); +} +.syn-dock .dock-f input:disabled, .syn-dock .dock-f select:disabled { opacity: .7; cursor: not-allowed; } +@media (prefers-reduced-motion: reduce) { + .step-node .sn-status.stat-running { animation: none; border-top-color: var(--syn-blue); } +} +:root[data-agent-motion="off"] .step-node .sn-status.stat-running { animation: none; border-top-color: var(--syn-blue); } +@media (max-width: 1024px) { + .tf-work { flex-direction: column; } + .tf-work .syn-dock { width: auto; flex: none; border-left: 0; border-top: 1px solid var(--syn-border); } +} From 63d66d6630abaa2e0a3d42506aea0ab7c83a74a8 Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 16:55:42 +0700 Subject: [PATCH 4/8] test(autopilot): ticket flow invariants, MAF build from custom flows, versioning, tenants, planner Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Web/AutopilotPlannerTests.cs | 70 +++++ .../Web/TicketFlowDefinitionTests.cs | 241 ++++++++++++++++++ .../Web/TicketFlowServiceTests.cs | 188 ++++++++++++++ .../Web/TicketFlowStylesheetTests.cs | 71 ++++++ .../AgentOs.Tests/Web/TicketWorkflowTests.cs | 84 ++++++ 5 files changed, 654 insertions(+) create mode 100644 tests/AgentOs.Tests/Web/TicketFlowDefinitionTests.cs create mode 100644 tests/AgentOs.Tests/Web/TicketFlowServiceTests.cs create mode 100644 tests/AgentOs.Tests/Web/TicketFlowStylesheetTests.cs diff --git a/tests/AgentOs.Tests/Web/AutopilotPlannerTests.cs b/tests/AgentOs.Tests/Web/AutopilotPlannerTests.cs index 314fed89..70c80acb 100644 --- a/tests/AgentOs.Tests/Web/AutopilotPlannerTests.cs +++ b/tests/AgentOs.Tests/Web/AutopilotPlannerTests.cs @@ -86,6 +86,76 @@ public void CutShortByPlanLimit_WaitsForASlot_AndAPlainFailureIsLeftAlone() private static PlanTask Task(int issue, string status, params PlanChange[] changes) => new(Guid.NewGuid(), issue, status, changes, 0); + // ── The task's ticket flow (ADR-0016): the rules make no move for a step it leaves out ── + + private static TicketFlowPlan Plan(string skip, string label) => + TicketFlowDefinition.Default.With(TicketFlowDefinition.Default.Find(skip)! with { SkipLabels = [label] }).Resolve([label]); + + [Fact] + public void ReviewSkipped_AFreshGreenChange_GoesStraightToTesting() + { + var fresh = Task(5, "Done", new PlanChange(Guid.NewGuid(), 40, PrChecksState.Passed, null, Review: ChangeReview.NotReviewed)) + with { Flow = Plan("review", "type:chore") }; + + var actions = AutopilotPlanner.Decide(AutopilotMode.BuildAndMerge, [], [fresh]); + + actions.OfType().ShouldBeEmpty(); + actions.ShouldHaveSingleItem().ShouldBeOfType().IssueNumber.ShouldBe(5); + } + + [Fact] + public void ReviewOn_AFreshGreenChange_IsReviewedFirst() + { + var fresh = Task(5, "Done", new PlanChange(Guid.NewGuid(), 40, PrChecksState.Passed, null, Review: ChangeReview.NotReviewed)); + + AutopilotPlanner.Decide(AutopilotMode.BuildAndMerge, [], [fresh]).ShouldHaveSingleItem().ShouldBeOfType(); + } + + [Fact] + public void TestSkipped_AReviewedChange_Merges_SayingWhatItPassed() + { + var reviewed = Task(6, "Done", new PlanChange(Guid.NewGuid(), 41, PrChecksState.Passed, null, ChangeTesting.Untested, Review: ChangeReview.Approved)) + with { Flow = Plan("test", "type:docs") }; + + var merge = AutopilotPlanner.Decide(AutopilotMode.BuildAndMerge, [], [reviewed]).ShouldHaveSingleItem().ShouldBeOfType(); + + merge.Why.ShouldContain("checks green and review approved"); + merge.Why.ShouldNotContain("acceptance"); + } + + [Fact] + public void DefaultFlow_AReviewedButUntestedChange_IsTested_NotMerged() + { + var reviewed = Task(6, "Done", new PlanChange(Guid.NewGuid(), 41, PrChecksState.Passed, null, ChangeTesting.Untested, Review: ChangeReview.Approved)); + + AutopilotPlanner.Decide(AutopilotMode.BuildAndMerge, [], [reviewed]).ShouldHaveSingleItem().ShouldBeOfType(); + var accepted = reviewed with { OpenChanges = [reviewed.OpenChanges[0] with { Testing = ChangeTesting.Accepted }] }; + AutopilotPlanner.Decide(AutopilotMode.BuildAndMerge, [], [accepted]).ShouldHaveSingleItem().ShouldBeOfType() + .Why.ShouldContain("checks green, review approved and acceptance testing passed"); + } + + [Fact] + public void WithinCaps_StaysTheOuterGuard_ForFlowMoves() + { + var a = Task(1, "Done", new PlanChange(Guid.NewGuid(), 40, PrChecksState.Passed, null)) with { Flow = Plan("review", "type:chore") }; + var b = Task(2, "Done", new PlanChange(Guid.NewGuid(), 41, PrChecksState.Passed, null)) with { Flow = Plan("review", "type:chore") }; + var moves = AutopilotPlanner.Decide(AutopilotMode.Build, [], [a, b], limits: AutopilotLimits.Default.Uncapped); + + moves.OfType().Count().ShouldBe(2); + AutopilotPlanner.WithinCaps(moves, 0, 0, AutopilotLimits.Default).OfType().ShouldHaveSingleItem(); // 1 acceptance run at once + } + + [Fact] + public void LabelsOf_ReadsTheTicketsLabels_ElseTheTypeItWasCreatedWith() + { + var tickets = new[] { new BoardTicket("n1", 9, "t", "OPEN", BoardTicketKind.Issue, "o", "r", "u", ["type:chore", "ai:ready"], null, null) }; + var labels = ProjectOrchestrator.TicketLabels(tickets); + + ProjectOrchestrator.LabelsOf(new() { IssueNumber = 9, RepoOwner = "o", RepoName = "r" }, labels).ShouldBe(["type:chore", "ai:ready"]); + ProjectOrchestrator.LabelsOf(new() { IssueNumber = 10, RepoOwner = "o", RepoName = "r", TicketType = "type:docs" }, labels).ShouldBe(["type:docs"]); + ProjectOrchestrator.LabelsOf(new() { IssueNumber = 11 }, labels).ShouldBeEmpty(); + } + private static PlanChange Pr(PrChecksState checks, int number = 30, string? report = "backend failed") => new(Guid.NewGuid(), number, checks, checks == PrChecksState.Failed ? report : null); diff --git a/tests/AgentOs.Tests/Web/TicketFlowDefinitionTests.cs b/tests/AgentOs.Tests/Web/TicketFlowDefinitionTests.cs new file mode 100644 index 00000000..38aa11de --- /dev/null +++ b/tests/AgentOs.Tests/Web/TicketFlowDefinitionTests.cs @@ -0,0 +1,241 @@ +// The ticket flow as data (ADR-0016): the default definition is today's flow, every safety invariant refuses a +// definition that breaks it with a plain-language reason, label filters resolve per ticket, and the JSON round-trips. + +using System.Linq; +using AgentOs.Modules.Pipeline.Agents; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Web; + +public sealed class TicketFlowDefinitionTests +{ + private static TicketFlowDefinition Default => TicketFlowDefinition.Default; + + private static TicketFlowDefinition WithNode(string id, System.Func change) => + Default.With(change(Default.Find(id)!)); + + private static string Reasons(TicketFlowDefinition d) => string.Join(" | ", TicketFlowValidator.Validate(d).Select(p => p.Message)); + + [Fact] + public void Default_IsTodaysFlow_AndValid() + { + Default.Summary().ShouldBe("Code → Checks → Review → Test → Merge"); + TicketFlowValidator.Validate(Default).ShouldBeEmpty(); + Default.Edges.Where(e => e.When == TicketFlowEdgeWhen.Fail).Select(e => e.From).ShouldBe(["checks", "review", "test"]); + Default.Edges.Where(e => e.When == TicketFlowEdgeWhen.Fail).ShouldAllBe(e => e.To == "code"); + TicketFlowPlan.Default.Summary.ShouldBe("Code → Checks → Review → Test → Merge"); + TicketFlowPlan.Default.SafetyProblem().ShouldBeNull(); + } + + [Fact] + public void Json_RoundTrips_WithStepKindsByName() + { + var custom = WithNode("review", n => n with { SkipLabels = ["type:chore"] }); + var json = custom.ToJson(); + + json.ShouldContain("\"kind\":\"Review\""); + var back = TicketFlowDefinition.FromJson(json)!; + back.ToJson().ShouldBe(json); + back.Find("review")!.Skip.ShouldBe(["type:chore"]); + } + + [Theory] + [InlineData("")] + [InlineData("{not json")] + [InlineData("{\"nodes\":[{\"id\":\"x\",\"kind\":\"Deploy\"}],\"edges\":[]}")] + public void FromJson_Unreadable_IsNull_AndRefused(string json) + { + TicketFlowDefinition.FromJson(json).ShouldBeNull(); + TicketFlowValidator.Validate(TicketFlowDefinition.FromJson(json)).ShouldNotBeEmpty(); + } + + // ── Invariant: starts at Code ── + [Fact] + public void MissingCode_IsRefused() + { + var d = TicketFlowDefinition.Chain(Default.Nodes.Where(n => n.Kind != TicketStepKind.Code).ToList()); + Reasons(d).ShouldContain("must start with Code"); + } + + [Fact] + public void AStepBeforeCode_IsRefused() + { + var d = Default with { Edges = [.. Default.Edges, new("merge", "code", TicketFlowEdgeWhen.Pass)] }; + Reasons(d).ShouldContain("Nothing may come before Code"); + } + + // ── Invariant: ends at Merge ── + [Fact] + public void MissingMerge_IsRefused() + { + var d = TicketFlowDefinition.Chain(Default.Nodes.Where(n => n.Kind != TicketStepKind.Merge).ToList()); + Reasons(d).ShouldContain("must end with Merge"); + } + + // ── Invariant: Checks always runs before Merge ── + [Fact] + public void ChecksRemoved_TurnedOff_OrLimited_IsRefused() + { + Reasons(TicketFlowDefinition.Chain(Default.Nodes.Where(n => n.Kind != TicketStepKind.Checks).ToList())) + .ShouldContain("Checks must run before Merge"); + Reasons(WithNode("checks", n => n with { Enabled = false })).ShouldContain("Checks always runs"); + Reasons(WithNode("checks", n => n with { SkipLabels = ["type:docs"] })).ShouldContain("can't be limited by labels"); + } + + [Fact] + public void CodeAndMerge_CantBeTurnedOff() + { + Reasons(WithNode("code", n => n with { Enabled = false })).ShouldContain("Code always runs"); + Reasons(WithNode("merge", n => n with { Enabled = false })).ShouldContain("Merge always runs"); + } + + // ── Invariant: at least one of Review / Test for every ticket ── + [Fact] + public void BothGatesOff_IsRefused() + { + var d = WithNode("review", n => n with { Enabled = false }); + d = d.With(d.Find("test")! with { Enabled = false }); + Reasons(d).ShouldContain("Turn on Review or Test"); + } + + [Fact] + public void OneGateOff_IsAllowed() + { + TicketFlowValidator.Validate(WithNode("review", n => n with { Enabled = false })).ShouldBeEmpty(); + TicketFlowValidator.Validate(WithNode("test", n => n with { Enabled = false })).ShouldBeEmpty(); + } + + [Fact] + public void Filters_ThatLeaveATicketWithNoGate_AreRefused_NamingTheTicket() + { + // Review skipped for chores, Test skipped for docs: a ticket labelled both would reach Merge unreviewed and untested. + var d = WithNode("review", n => n with { SkipLabels = ["type:chore"] }); + d = d.With(d.Find("test")! with { SkipLabels = ["type:docs"] }); + + Reasons(d).ShouldBe("A ticket labelled type:chore and type:docs would reach Merge with neither Review nor Test."); + } + + [Fact] + public void OnlyFilters_ThatLeaveAnUnlabelledTicketWithNoGate_AreRefused() + { + var d = WithNode("review", n => n with { Enabled = false }); + d = d.With(d.Find("test")! with { OnlyLabels = ["type:feature"] }); + + Reasons(d).ShouldContain("A ticket with none of the labels type:feature would reach Merge"); + } + + [Fact] + public void Filters_ThatAlwaysLeaveAGate_AreAllowed() + { + TicketFlowValidator.Validate(WithNode("review", n => n with { SkipLabels = ["type:chore"] })).ShouldBeEmpty(); + var d = WithNode("review", n => n with { SkipLabels = ["type:docs"] }); + d = d.With(d.Find("test")! with { OnlyLabels = ["type:docs"] }); + TicketFlowValidator.Validate(d).ShouldBeEmpty(); // docs → tested, everything else → reviewed + } + + // ── Invariant: no cycles except fail → Code ── + [Fact] + public void APassEdgeLoopingBack_IsRefused() + { + var d = Default with + { + Edges = [.. Default.Edges.Where(e => !(e.From == "test" && e.When == TicketFlowEdgeWhen.Pass)), new("test", "checks", TicketFlowEdgeWhen.Pass)], + }; + Reasons(d).ShouldNotBeEmpty(); + } + + [Fact] + public void AFailEdgeNotToCode_IsRefused() + { + var d = Default with { Edges = [.. Default.Edges.Where(e => !(e.From == "test" && e.When == TicketFlowEdgeWhen.Fail)), new("test", "review", TicketFlowEdgeWhen.Fail)] }; + Reasons(d).ShouldContain("A failed Test can only go back to Code"); + } + + [Fact] + public void AStepThatCanFailWithoutAWayBack_IsRefused() + { + var d = Default with { Edges = Default.Edges.Where(e => !(e.From == "review" && e.When == TicketFlowEdgeWhen.Fail)).ToList() }; + Reasons(d).ShouldContain("Review needs its way back to Code"); + } + + // ── Invariant: every node reachable ── + [Fact] + public void AnUnreachableStep_IsRefused() + { + var d = Default with + { + Edges = [.. Default.Edges.Where(e => !(e.When == TicketFlowEdgeWhen.Pass && e.From is "checks" or "review")), new("checks", "test", TicketFlowEdgeWhen.Pass), + new("review", "test", TicketFlowEdgeWhen.Pass)], + }; + Reasons(d).ShouldContain("Review can't be reached from Code"); + } + + // ── Structure ── + [Fact] + public void StepsOutOfOrder_AreRefused() + { + var n = Default.Nodes; + var d = TicketFlowDefinition.Chain([n[0], n[1], n[3], n[2], n[4]]); + Reasons(d).ShouldContain("Review must come before Test"); + } + + [Fact] + public void DuplicateKinds_AndIds_AreRefused() + { + Reasons(TicketFlowDefinition.Chain([.. Default.Nodes.Take(3), new("review2", TicketStepKind.Review), .. Default.Nodes.Skip(3)])) + .ShouldContain("only one Review step"); + Reasons(Default with { Nodes = [.. Default.Nodes, Default.Nodes[0]] }).ShouldContain("share the id code"); + } + + [Fact] + public void ARoleThatCantRunTheStep_IsRefused() + { + Reasons(WithNode("review", n => n with { Role = AgentRoles.Acceptance })).ShouldContain("Review can't be run by Testing (tickets) — pick Reviewer"); + Reasons(WithNode("review", n => n with { Role = "Nobody" })).ShouldContain("unknown agent"); + Reasons(WithNode("checks", n => n with { Role = AgentRoles.Reviewer })).ShouldContain("isn't run by an agent"); + TicketFlowValidator.Validate(WithNode("review", n => n with { Role = AgentRoles.Reviewer })).ShouldBeEmpty(); + } + + [Fact] + public void BadLabels_AreRefused() + { + Reasons(WithNode("review", n => n with { SkipLabels = [" type:chore"] })).ShouldContain("stray spaces"); + Reasons(WithNode("review", n => n with { SkipLabels = ["a", "b", "c", "d", "e", "f", "g", "h", "i"] })).ShouldContain("at most 8 labels"); + Reasons(WithNode("review", n => n with { SkipLabels = ["x"], OnlyLabels = ["X"] })).ShouldContain("both requires and skips x"); + } + + // ── Resolution per ticket ── + [Fact] + public void Resolve_SkipsWhatTheLabelsSay_CaseInsensitive() + { + var d = WithNode("review", n => n with { SkipLabels = ["type:chore"] }); + + d.Resolve(["Type:Chore"]).Summary.ShouldBe("Code → Checks → Test → Merge"); + d.Resolve(["type:chore"]).Skipped.ShouldHaveSingleItem().Id.ShouldBe("review"); + d.Resolve(["type:feature"]).Summary.ShouldBe("Code → Checks → Review → Test → Merge"); + d.Resolve(null).Runs(TicketStepKind.Review).ShouldBeTrue(); + d.Summary().ShouldBe("Code → Checks → Review (not type:chore) → Test → Merge"); + } + + [Fact] + public void Plan_WithoutAGate_IsUnsafe() + { + var unsafePlan = new TicketFlowPlan([Default.Nodes[0], Default.Nodes[1], Default.Nodes[4]], []); + unsafePlan.SafetyProblem()!.ShouldContain("neither Review nor Test"); + new TicketFlowPlan([Default.Nodes[0], Default.Nodes[2], Default.Nodes[4]], []).SafetyProblem()!.ShouldContain("Checks"); + } + + [Fact] + public void EveryStepKind_IsRegistered_WithRolesFromTheAgentRegistry() + { + foreach (var kind in System.Enum.GetValues()) + { + var info = TicketStepKinds.Of(kind); + info.Roles.ShouldAllBe(r => AgentRoles.Find(r) != null); + } + TicketStepKinds.Of(TicketStepKind.Code).DefaultRole.ShouldBe(AgentRoles.IssueWork); + TicketStepKinds.Of(TicketStepKind.Review).DefaultRole.ShouldBe(AgentRoles.Reviewer); + TicketStepKinds.Of(TicketStepKind.Test).DefaultRole.ShouldBe(AgentRoles.Acceptance); + } +} diff --git a/tests/AgentOs.Tests/Web/TicketFlowServiceTests.cs b/tests/AgentOs.Tests/Web/TicketFlowServiceTests.cs new file mode 100644 index 00000000..ac4116b3 --- /dev/null +++ b/tests/AgentOs.Tests/Web/TicketFlowServiceTests.cs @@ -0,0 +1,188 @@ +// Ticket flow storage + the service on top (ADR-0016): versions per tenant (who / when), revert as a new version, the +// board's own flow wins over the tenant's, a stored flow that breaks a safety rule is refused at save AND at run time +// (the built-in flow runs), only admins change it, and two tenants never see each other's flows — on the in-memory +// store (standalone Web) and the EF store (EF Core InMemory; the real-Postgres RLS check is RowLevelSecurityRealDbTests). + +using System.Linq; +using System.Threading.Tasks; +using AgentOs.Modules.Pipeline.Agents; +using AgentOs.Modules.Workspaces.Persistence; +using AgentOs.Modules.Workspaces.Persistence.Repositories; +using AgentOs.Tests.Identity; +using AgentOs.Web.Services.Orchestration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Web; + +public sealed class TicketFlowServiceTests +{ + private static readonly TicketFlowActor Admin = new("u-admin", "Ada Admin", IsAdmin: true); + private static readonly TicketFlowActor Member = new("u-member", "Mo Member", IsAdmin: false); + + public static TheoryData Stores => ["memory", "ef"]; + + private static TicketFlowService Service(string store) + { + var services = new ServiceCollection(); + if (store == "ef") + { + var db = $"tf-{Guid.NewGuid()}"; + services.AddDbContext(o => o.UseInMemoryDatabase(db)); + services.AddSingleton(new TestTenantContext()); + services.AddScoped(); + } + else + { + services.AddSingleton(); + } + return new TicketFlowService(services.BuildServiceProvider().GetRequiredService(), NullLogger.Instance); + } + + private static TicketFlowDefinition NoReviewForChores() => + TicketFlowDefinition.Default.With(TicketFlowDefinition.Default.Find("review")! with { SkipLabels = ["type:chore"] }); + + private static TicketFlowDefinition TestOff() => + TicketFlowDefinition.Default.With(TicketFlowDefinition.Default.Find("test")! with { Enabled = false }); + + [Theory] + [MemberData(nameof(Stores))] + public async Task NothingSaved_RunsTheBuiltInFlow(string store) + { + var flow = await Service(store).ResolveAsync("t1", Guid.NewGuid()); + + flow.Source.ShouldBe(TicketFlowSource.BuiltIn); + flow.Version.ShouldBe(0); + flow.Definition.Summary().ShouldBe("Code → Checks → Review → Test → Merge"); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task Save_AppendsVersions_WithWhoAndWhen_AndRevertBringsOneBack(string store) + { + var svc = Service(store); + var changed = 0; + svc.Changed += _ => changed++; + + var v1 = await svc.SaveAsync("t1", null, NoReviewForChores(), Admin); + var v2 = await svc.SaveAsync("t1", null, TestOff(), Admin); + v1.Saved.ShouldBeTrue(); + v2.Version!.Version.ShouldBe(2); + (await svc.ResolveAsync("t1", null)).Definition.Find("test")!.Enabled.ShouldBeFalse(); + + var reverted = await svc.RevertAsync("t1", null, 1, Admin); + + reverted.Version!.Version.ShouldBe(3); + reverted.Version.Note.ShouldBe("Reverted to v1"); + var now = await svc.ResolveAsync("t1", null); + now.Version.ShouldBe(3); + now.Source.ShouldBe(TicketFlowSource.Tenant); + now.Definition.Find("review")!.Skip.ShouldBe(["type:chore"]); + var history = await svc.HistoryAsync("t1", null); + history.Select(h => h.Version).ShouldBe([3, 2, 1]); + history.ShouldAllBe(h => h.CreatedByName == "Ada Admin" && h.CreatedByUserId == "u-admin"); + changed.ShouldBe(3); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task InvalidFlow_IsRefusedOnSave_WithTheReason_AndNothingIsStored(string store) + { + var svc = Service(store); + var bothOff = TestOff().With(TicketFlowDefinition.Default.Find("review")! with { Enabled = false }); + + var result = await svc.SaveAsync("t1", null, bothOff, Admin); + + result.Saved.ShouldBeFalse(); + result.Reason.ShouldContain("Turn on Review or Test"); + (await svc.HistoryAsync("t1", null)).ShouldBeEmpty(); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task AMember_CantChangeTheFlow(string store) + { + var svc = Service(store); + (await svc.SaveAsync("t1", null, TestOff(), Member)).Reason.ShouldContain("Only an admin"); + await svc.SaveAsync("t1", null, TestOff(), Admin); + (await svc.RevertAsync("t1", null, 1, Member)).Saved.ShouldBeFalse(); + (await svc.HistoryAsync("t1", null)).Count.ShouldBe(1); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task TheBoardsOwnFlow_Wins_AndUsingTheDefaultAgainFallsBack(string store) + { + var svc = Service(store); + var board = Guid.NewGuid(); + var other = Guid.NewGuid(); + await svc.SaveAsync("t1", null, NoReviewForChores(), Admin); + await svc.SaveAsync("t1", board, TestOff(), Admin); + + (await svc.ResolveAsync("t1", board)).Source.ShouldBe(TicketFlowSource.Board); + (await svc.ResolveAsync("t1", board)).Definition.Find("test")!.Enabled.ShouldBeFalse(); + (await svc.ResolveAsync("t1", other)).Source.ShouldBe(TicketFlowSource.Tenant); + + await svc.UseTenantFlowAsync("t1", board, Admin); + + var back = await svc.ResolveAsync("t1", board); + back.Source.ShouldBe(TicketFlowSource.Tenant); + back.Version.ShouldBe(1); + (await svc.RevertAsync("t1", board, 1, Admin)).Reason.ShouldContain("isn't one of this flow's versions"); // v1 is the tenant's + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task TwoTenants_NeverSeeEachOthersFlows(string store) + { + var svc = Service(store); + await svc.SaveAsync("tenant-a", null, TestOff(), Admin); + + (await svc.ResolveAsync("tenant-b", null)).Source.ShouldBe(TicketFlowSource.BuiltIn); + (await svc.HistoryAsync("tenant-b", null)).ShouldBeEmpty(); + (await svc.ForTaskAsync("tenant-b", Guid.NewGuid(), 1)).Refused!.ShouldContain("no longer exists"); + (await svc.RevertAsync("tenant-b", null, 1, Admin)).Saved.ShouldBeFalse(); + // Each tenant numbers its own versions. + (await svc.SaveAsync("tenant-b", null, NoReviewForChores(), Admin)).Version!.Version.ShouldBe(1); + (await svc.ResolveAsync("tenant-a", null)).Definition.Find("test")!.Enabled.ShouldBeFalse(); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task ATask_KeepsTheVersionItStartedWith(string store) + { + var svc = Service(store); + var board = Guid.NewGuid(); + var started = (await svc.SaveAsync("t1", null, TestOff(), Admin)).Version!.Version; + await svc.SaveAsync("t1", null, NoReviewForChores(), Admin); + + var flow = await svc.ForTaskAsync("t1", board, started); + flow.Version.ShouldBe(started); + flow.Definition.Find("test")!.Enabled.ShouldBeFalse(); + (await svc.ForTaskAsync("t1", board, started)).Version.ShouldBe(started); // cached path, same answer + (await svc.ForTaskAsync("t1", board, 0)).Source.ShouldBe(TicketFlowSource.BuiltIn); + (await svc.ForTaskAsync("t1", board, null)).Version.ShouldBe(2); // a task from before ticket flows: the current one + } + + [Fact] + public async Task AStoredFlowThatBreaksARule_IsRefusedAtRunTime_AndTheBuiltInFlowRuns() + { + var services = new ServiceCollection(); + var store = new InMemoryTicketFlowStore(); + services.AddSingleton(store); + var svc = new TicketFlowService(services.BuildServiceProvider().GetRequiredService(), NullLogger.Instance); + var broken = TicketFlowDefinition.Chain(TicketFlowDefinition.Default.Nodes.Where(n => n.Kind != TicketStepKind.Checks).ToList()); + await store.AppendAsync("t1", Guid.Empty, broken.ToJson(), "u", "u", null); // written behind the validator's back + + var flow = await svc.ResolveAsync("t1", null); + + flow.Source.ShouldBe(TicketFlowSource.BuiltIn); + flow.Definition.ShouldBeSameAs(TicketFlowDefinition.Default); + flow.Refused!.ShouldContain("v1 was refused (Checks must run before Merge"); + (await svc.ForTaskAsync("t1", Guid.NewGuid(), 1)).Refused.ShouldNotBeNull(); + (await svc.RevertAsync("t1", null, 1, Admin)).Saved.ShouldBeFalse(); + } +} diff --git a/tests/AgentOs.Tests/Web/TicketFlowStylesheetTests.cs b/tests/AgentOs.Tests/Web/TicketFlowStylesheetTests.cs new file mode 100644 index 00000000..19a7509b --- /dev/null +++ b/tests/AgentOs.Tests/Web/TicketFlowStylesheetTests.cs @@ -0,0 +1,71 @@ +// Performance + a11y guards for Workflow › Ticket flow (owner directive 8): nothing in its block loops at rest (the only +// motion is the step spinner of a ticket running now, which stops under reduced motion / Animations off), and every +// class the view uses is defined (no phantom classes). + +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Web; + +public sealed partial class TicketFlowStylesheetTests +{ + private static string Find(params string[] parts) + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Join([dir.FullName, .. parts]); + if (File.Exists(candidate)) { return candidate; } + } + throw new FileNotFoundException(string.Join('/', parts)); + } + + private static string Block() => File.ReadAllText(Find("src", "AgentOs.Web", "wwwroot", "orchestration.css")) is var css + ? css[css.IndexOf("/* ===== Ticket flow", StringComparison.Ordinal)..] + : ""; + + [Fact] + public void TicketFlowBlock_HasNoAnimationOfItsOwn_AndStopsTheRunningSpinnerWhenMotionIsOff() + { + var block = Block(); + block.ShouldNotContain("infinite"); + block.ShouldNotContain("@keyframes"); + block.ShouldContain("@media (prefers-reduced-motion: reduce)"); + block.ShouldContain(":root[data-agent-motion=\"off\"] .step-node .sn-status.stat-running { animation: none;"); + } + + [Fact] + public void TicketFlowBlock_UsesTokensOnly() + { + Block().ShouldNotMatch("#[0-9a-fA-F]{3,8}\\b"); + } + + [Fact] + public void TicketFlowView_UsesOnlyDefinedClasses() + { + var razor = File.ReadAllText(Find("src", "AgentOs.Web", "Components", "Orchestration", "TicketFlowView.razor")); + var css = File.ReadAllText(Find("src", "AgentOs.Web", "wwwroot", "orchestration.css")) + File.ReadAllText(Find("src", "AgentOs.Web", "wwwroot", "app.css")); + var used = ClassAttr().Matches(razor).SelectMany(m => m.Groups[1].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Concat(QuotedClass().Matches(razor).SelectMany(m => m.Groups[1].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))) + .Where(c => Token().IsMatch(c)) + .Distinct().ToList(); + used.ShouldContain("tf-root"); + // The card's classes are composed in CardClass/CatOf — every piece must exist too. + razor.ShouldContain("$\"step-node tf-node cat-{CatOf(node.Kind)} {run}{sel}{bad}\""); + used.AddRange(["step-node", "tf-node", "tf-has-problem", "is-selected", "run-running", "run-idle", "run-skipped", "cat-agent", "cat-logic", "cat-terminal"]); + var missing = used.Where(c => !Regex.IsMatch(css, @"\." + Regex.Escape(c) + @"(?![\w-])")).ToList(); + missing.ShouldBeEmpty($"phantom classes: {string.Join(", ", missing)}"); + } + + [GeneratedRegex("class=\"([^\"]+)\"")] + private static partial Regex ClassAttr(); + + [GeneratedRegex("^[a-z][a-z0-9-]*[a-z0-9]$")] + private static partial Regex Token(); + + // Class strings built in code ("syn-btn is-on", "step-node tf-node cat-…"). + [GeneratedRegex("=> [^;]*?\"((?:syn|tf|step|run|cat)-[a-z -]+)\"")] + private static partial Regex QuotedClass(); +} diff --git a/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs b/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs index 8dd35b83..61e89aed 100644 --- a/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs +++ b/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs @@ -251,6 +251,90 @@ public async Task PlanLimitBeforeTheFirstStep_StartsNothing() steps.CodeRequests.ShouldBeEmpty(); } + // ── The graph is built from the ticket flow (ADR-0016) ── + + private static Task RunPlan(ScriptedSteps steps, TicketFlowPlan plan, List? entered = null) => + TicketWorkflow.RunAsync(steps, new TicketFlow("t", Guid.NewGuid(), Guid.NewGuid(), 15, true) + { + Plan = plan, + FlowVersion = 4, + OnStep = f => entered?.Add(f.StepId!), + }, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(1), CancellationToken.None); + + private static TicketFlowDefinition ReviewSkippedForChores() => + TicketFlowDefinition.Default.With(TicketFlowDefinition.Default.Find("review")! with { SkipLabels = ["type:chore"] }); + + [Fact] + public async Task CustomFlow_ReviewSkippedForAChore_GoesFromChecksStraightToTest() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue(Green); + steps.Tests.Enqueue("Accepted"); + var entered = new List(); + + var flow = await RunPlan(steps, ReviewSkippedForChores().Resolve(["type:chore"]), entered); + + flow.Stage.ShouldBe(TicketStage.Merged); + flow.Trail.ShouldBe(["coded", "checks green", "acceptance passed", "merged"]); + entered.ShouldBe(["code", "checks", "test", "merge"]); + steps.Reviews.ShouldBeEmpty(); + } + + [Fact] + public async Task CustomFlow_ReviewSkippedForChores_StillReviewsAFeature() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + (await RunPlan(steps, ReviewSkippedForChores().Resolve(["type:feature"]))).Trail + .ShouldBe(["coded", "checks green", "review approved", "acceptance passed", "merged"]); + } + + [Fact] + public async Task CustomFlow_TestOffForDocs_MergesAfterReview_AndFailedReviewStillGoesBackToCode() + { + var def = TicketFlowDefinition.Default.With(TicketFlowDefinition.Default.Find("test")! with { SkipLabels = ["type:docs"] }); + TicketFlowValidator.Validate(def).ShouldBeEmpty(); + var steps = new ScriptedSteps(); + steps.Checks.Enqueue(Green); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("ChangesRequested"); + steps.Reviews.Enqueue("Reviewed"); + + var flow = await RunPlan(steps, def.Resolve(["type:docs"])); + + flow.Stage.ShouldBe(TicketStage.Merged); + flow.Round.ShouldBe(1); + flow.Trail.ShouldBe(["coded", "checks green", "code review asked for changes → back to Coding (try 1 of 3)", "coded", "checks green", "review approved", "merged"]); + steps.CodeRequests[1]!.ShouldContain("off-by-one"); + } + + [Fact] + public async Task UnsafePlan_IsNeverBuilt_TheDefaultFlowRuns() + { + var d = TicketFlowDefinition.Default; + var unsafePlan = new TicketFlowPlan([d.Nodes[0], d.Nodes[1], d.Nodes[4]], [d.Nodes[2], d.Nodes[3]]); // no Review, no Test + var steps = new ScriptedSteps(); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + (await RunPlan(steps, unsafePlan)).Trail.ShouldBe(["coded", "checks green", "review approved", "acceptance passed", "merged"]); + } + + [Fact] + public void Seed_SnapshotsThePlanAndVersion() + { + var plan = ReviewSkippedForChores().Resolve(["type:chore"]); + var flow = TicketWorkflowRunner.Seed("t", Guid.NewGuid(), Guid.NewGuid(), 15, true, 1, AutopilotLimits.Default, plan, 7); + + flow.Plan.ShouldBeSameAs(plan); + flow.FlowVersion.ShouldBe(7); + flow.Round.ShouldBe(1); + } + [Fact] public async Task BoardWithoutAutoMerge_EndsReadyForAPerson() { From ba7a87ec682162c97bfe0f10efebc0fec7858d3c Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 16:57:10 +0700 Subject: [PATCH 5/8] =?UTF-8?q?test(e2e):=20Workflow=20=E2=80=BA=20Ticket?= =?UTF-8?q?=20flow=20renders,=20saves,=20refuses=20an=20unsafe=20flow=20an?= =?UTF-8?q?d=20reverts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../AgentOsUi/TicketFlowTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/AgentOs.E2E.Tests/AgentOsUi/TicketFlowTests.cs diff --git a/tests/AgentOs.E2E.Tests/AgentOsUi/TicketFlowTests.cs b/tests/AgentOs.E2E.Tests/AgentOsUi/TicketFlowTests.cs new file mode 100644 index 00000000..68530585 --- /dev/null +++ b/tests/AgentOs.E2E.Tests/AgentOsUi/TicketFlowTests.cs @@ -0,0 +1,115 @@ +// Workflow › Ticket flow (ADR-0016) on the standalone Web (DevAutoLogin = an admin; ticket flows kept in memory): the +// view renders the flow Autopilot runs as step cards and as a table, a step can be turned off and saved as a new +// version, a flow that breaks a safety rule is refused with the reason, and an earlier version can be brought back. +// Zero residue: the test ends with the built-in flow's content saved again. Set AGENTOS_E2E_SCREENSHOTS to a folder to +// capture the view in light and dark (never committed). + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Playwright; +using Xunit; + +namespace AgentOs.E2E.Tests.AgentOsUi; + +public sealed class TicketFlowTests : IClassFixture +{ + private readonly AgentOsPageFixture _fx; + + public TicketFlowTests(AgentOsPageFixture fx) => _fx = fx; + + private async Task OpenAsync() + { + await _fx.GotoDesktopAsync(); + await Assertions.Expect(_fx.Page.Locator("#components-reconnect-modal")).Not.ToBeVisibleAsync(new() { Timeout = 30_000 }); + await _fx.Page.OpenAppAsync("Workflow"); + var win = _fx.Page.Locator(".appwin.focused"); + await Assertions.Expect(win.Locator(".syn-select").First).ToBeVisibleAsync(); + await win.GetByRole(AriaRole.Tab, new() { Name = "Ticket flow" }).ClickAsync(); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow]")).ToBeVisibleAsync(); + return win; + } + + private static ILocator Card(ILocator win, string step) => win.Locator($".tf-node[data-step=\"{step}\"]"); + + private static async Task SetRunsAsync(ILocator win, string step, bool on) + { + await Card(win, step).ClickAsync(); + var toggle = win.Locator("[data-testid=ticket-flow-runs]"); + if ((await toggle.GetAttributeAsync("aria-checked") == "true") != on) { await toggle.ClickAsync(); } + await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-checked", on ? "true" : "false"); + } + + private async Task ShotAsync(string name) + { + var dir = Environment.GetEnvironmentVariable("AGENTOS_E2E_SCREENSHOTS"); + if (string.IsNullOrWhiteSpace(dir)) { return; } + Directory.CreateDirectory(dir); + foreach (var theme in new[] { "light", "dark" }) + { + await _fx.Page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme); + await _fx.Page.ScreenshotAsync(new() { Path = Path.Join(dir, $"ticket-flow-{name}-{theme}.png") }); + } + } + + [Fact] + public async Task TicketFlow_Renders_SavesAStepOff_RefusesAnUnsafeFlow_AndReverts() + { + if (!AgentOsPageFixture.IsEnabled) { Assert.Skip(AgentOsPageFixture.SkipReason); } + var win = await OpenAsync(); + try + { + // Render: the five steps in order, the way back to Code, and the table alternative. + await Assertions.Expect(win.Locator(".tf-node")).ToHaveCountAsync(5); + await Assertions.Expect(win.Locator(".tf-chain")).ToHaveAttributeAsync("aria-label", new System.Text.RegularExpressions.Regex("Code → Checks → Review → Test → Merge")); + await Assertions.Expect(win.Locator(".tf-back")).ToContainTextAsync("Fail → back to Code"); + await ShotAsync("graph"); + await win.Locator("[data-testid=ticket-flow-table-view]").ClickAsync(); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow-table] tbody tr")).ToHaveCountAsync(5); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow-table]")).ToContainTextAsync("Back to Code (a fix round)"); + await ShotAsync("table"); + await win.GetByRole(AriaRole.Button, new() { Name = "Graph" }).ClickAsync(); + + // Required steps can't be turned off. + await Card(win, "checks").ClickAsync(); + await Assertions.Expect(win.Locator(".syn-dock")).ToContainTextAsync("Always runs, for every ticket"); + + // Toggle a step and save: a new version. + await SetRunsAsync(win, "review", on: false); + await Assertions.Expect(Card(win, "review")).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("run-skipped")); + await win.Locator("[data-testid=ticket-flow-save]").ClickAsync(); + await Assertions.Expect(_fx.Page.Locator(".toast").Last).ToContainTextAsync("Saved v"); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow-source]")).ToContainTextAsync("the default flow for all projects"); + var saved = await win.Locator(".tf-versions li").First.GetAttributeAsync("data-version"); + + // An unsafe flow: the problem shows inline, and Save is refused with the reason. + await SetRunsAsync(win, "test", on: false); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow-problems]")).ToContainTextAsync("Turn on Review or Test"); + await ShotAsync("refused"); + await win.Locator("[data-testid=ticket-flow-save]").ClickAsync(); + await Assertions.Expect(win.Locator("[data-testid=ticket-flow-refused]")).ToContainTextAsync("Turn on Review or Test"); + await Assertions.Expect(win.Locator(".tf-versions li").First).ToHaveAttributeAsync("data-version", saved!); + await win.GetByRole(AriaRole.Button, new() { Name = "Discard" }).ClickAsync(); + + // Save Review back on, then revert to the version without it. + await SetRunsAsync(win, "review", on: true); + await win.Locator("[data-testid=ticket-flow-save]").ClickAsync(); + await Assertions.Expect(Card(win, "review")).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("run-idle")); + await win.GetByRole(AriaRole.Button, new() { Name = $"Revert to version {saved}" }).ClickAsync(); + await Assertions.Expect(_fx.Page.Locator(".toast").Last).ToContainTextAsync($"Reverted to v{saved}"); + await Assertions.Expect(Card(win, "review")).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("run-skipped")); + await Assertions.Expect(win.Locator(".tf-versions li").First).ToContainTextAsync($"Reverted to v{saved}"); + } + finally + { + // Zero residue: every step back on and saved (the built-in flow's content). + var discard = win.GetByRole(AriaRole.Button, new() { Name = "Discard" }); + if (await discard.IsEnabledAsync()) { await discard.ClickAsync(); } + if (await Card(win, "review").GetAttributeAsync("class") is { } css && css.Contains("run-skipped", StringComparison.Ordinal)) + { + await SetRunsAsync(win, "review", on: true); + await win.Locator("[data-testid=ticket-flow-save]").ClickAsync(); + } + } + await Assertions.Expect(win.Locator(".tf-chain")).ToHaveAttributeAsync("aria-label", new System.Text.RegularExpressions.Regex("Code → Checks → Review → Test → Merge")); + } +} From 93a50aab8bba64acd4bc323c3fc3a83699720dc2 Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 17:04:42 +0700 Subject: [PATCH 6/8] fix(autopilot): Ticket flow cards fit the window and state aria-pressed as true/false Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Orchestration/TicketFlowView.razor | 2 +- src/AgentOs.Web/wwwroot/orchestration.css | 8 ++- .../AgentOsUi/TicketFlowTests.cs | 67 ++++++++++++++----- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor b/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor index fc530769..70004241 100644 --- a/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor +++ b/src/AgentOs.Web/Components/Orchestration/TicketFlowView.razor @@ -108,7 +108,7 @@ { var here = LiveAt(node.Id);
  • -