Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions src/Polyphony/Commands/PlanCommands.DeriveAncestorChain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Text.Json;
using ConsoleAppFramework;
using Twig.Domain.Aggregates;

namespace Polyphony.Commands;

/// <summary>
/// Phase 3 P7a: derive the plan-tree ancestor chain for a single work item.
/// Routing-style verb consumed by <c>plan-level.yaml</c> to compute the
/// <c>--parent-item-id</c> and <c>--ancestor-ids</c> flags for the plan-PR
/// verbs (<c>branch ensure-plan</c>, <c>pr open-plan-pr</c>, <c>pr merge-plan-pr</c>).
/// Walks <see cref="WorkItem.ParentId"/> from <paramref name="itemId"/> up
/// to <paramref name="rootId"/>; emits an error if the item is not a
/// descendant of the root, the chain is broken, or a cycle is detected.
/// </summary>
public sealed partial class PlanCommands
{
/// <summary>
/// Maximum ancestors to walk before declaring a cycle. The plan-tree
/// recursion is independently capped at <c>max_depth</c> in the workflow,
/// so 50 is comfortably above any realistic hierarchy.
/// </summary>
private const int AncestorWalkLimit = 50;

/// <summary>
/// Walks the parent chain of <paramref name="itemId"/> up to (but not
/// past) <paramref name="rootId"/> and emits <see cref="PlanDeriveAncestorChainResult"/>.
/// Always exits 0; consumers branch on <c>error</c> or the structured fields.
/// </summary>
/// <param name="rootId">Run-root work-item id (positive).</param>
/// <param name="itemId">Item being planned (positive). May equal <paramref name="rootId"/> for the root plan.</param>
/// <param name="ct">Cancellation token.</param>
[Command("derive-ancestor-chain")]
public async Task<int> DeriveAncestorChain(
int rootId,
int itemId,
CancellationToken ct = default)
{
if (rootId <= 0)
{
EmitChainError(rootId, itemId, $"--root-id must be positive (got {rootId})");
return ExitCodes.Success;
}

if (itemId <= 0)
{
EmitChainError(rootId, itemId, $"--item-id must be positive (got {itemId})");
return ExitCodes.Success;
}

if (itemId == rootId)
{
EmitChain(new PlanDeriveAncestorChainResult
{
RootId = rootId,
ItemId = itemId,
IsRootPlan = true,
ParentItemId = null,
AncestorIds = string.Empty,
AncestorChain = [],
Depth = 0,
});
return ExitCodes.Success;
}

// Walk parent chain. We need both the immediate parent (for
// --parent-item-id) and the full chain (for --ancestor-ids).
var item = await repository.GetByIdAsync(itemId, ct);
if (item is null)
{
EmitChainError(rootId, itemId, $"Work item {itemId} not found");
return ExitCodes.Success;
}

var ancestors = new List<int>();
var visited = new HashSet<int> { itemId };
var cursor = item;

for (var step = 0; step < AncestorWalkLimit; step++)
{
if (cursor.ParentId is not { } parentId)
{
EmitChainError(rootId, itemId,
$"Work item {itemId} is not a descendant of root {rootId} (parent chain ends at {cursor.Id} with no parent)");
return ExitCodes.Success;
}

if (!visited.Add(parentId))
{
EmitChainError(rootId, itemId,
$"Cycle detected in parent chain at work item {parentId}");
return ExitCodes.Success;
}

if (parentId == rootId)
{
// Reached the root. Emit the chain.
ancestors.Add(rootId);
EmitSuccess(rootId, itemId, ancestors);
return ExitCodes.Success;
}

ancestors.Add(parentId);

var parent = await repository.GetByIdAsync(parentId, ct);
if (parent is null)
{
EmitChainError(rootId, itemId,
$"Ancestor work item {parentId} not found (broken parent chain)");
return ExitCodes.Success;
}
cursor = parent;
}

EmitChainError(rootId, itemId,
$"Ancestor walk exceeded limit of {AncestorWalkLimit} steps; cycle suspected");
return ExitCodes.Success;
}

/// <summary>
/// Renders the success payload. The last entry in <paramref name="ancestorsIncludingRoot"/>
/// is always the root id; immediate parent is first.
/// </summary>
private static void EmitSuccess(int rootId, int itemId, IReadOnlyList<int> ancestorsIncludingRoot)
{
// Replace the trailing root id with the literal "root" token. The
// pr open-plan-pr verb's --ancestor-ids contract is "immediate parent
// first, ending in 'root'".
var chain = new string[ancestorsIncludingRoot.Count];
for (var i = 0; i < ancestorsIncludingRoot.Count - 1; i++)
{
chain[i] = ancestorsIncludingRoot[i].ToString(System.Globalization.CultureInfo.InvariantCulture);
}
chain[^1] = "root";

// parent_item_id is null when the item is a direct child of root
// (chain == ["root"]); otherwise it's the immediate parent's id.
int? parentItemId = ancestorsIncludingRoot.Count == 1
? null
: ancestorsIncludingRoot[0];

EmitChain(new PlanDeriveAncestorChainResult
{
RootId = rootId,
ItemId = itemId,
IsRootPlan = false,
ParentItemId = parentItemId,
AncestorIds = string.Join(",", chain),
AncestorChain = chain,
Depth = chain.Length,
});
}

private static void EmitChainError(int rootId, int itemId, string message)
{
EmitChain(new PlanDeriveAncestorChainResult
{
RootId = rootId,
ItemId = itemId,
IsRootPlan = rootId == itemId,
ParentItemId = null,
AncestorIds = string.Empty,
AncestorChain = [],
Depth = 0,
Error = message,
});
}

private static void EmitChain(PlanDeriveAncestorChainResult result)
{
Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.PlanDeriveAncestorChainResult));
}
}
65 changes: 65 additions & 0 deletions src/Polyphony/Models/PlanDeriveAncestorChainResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace Polyphony;

/// <summary>
/// Output of <c>polyphony plan derive-ancestor-chain</c>. Walks the work-item
/// parent chain from <see cref="ItemId"/> up to (but not past) <see cref="RootId"/>
/// and emits the inputs the Phase 3 plan-PR verbs need:
/// <list type="bullet">
/// <item><c>--root-id</c> for every verb (already known by the workflow).</item>
/// <item><c>--parent-item-id</c> for <c>branch ensure-plan</c>,
/// <c>pr open-plan-pr</c>, and <c>pr merge-plan-pr</c> — required for
/// descendants of descendants; omitted (null) for the root plan and
/// direct children of root.</item>
/// <item><c>--ancestor-ids</c> for <c>pr open-plan-pr</c> — comma-separated
/// chain (immediate parent first), with the literal <c>"root"</c>
/// token used in place of the root work-item id, e.g. <c>"5678,root"</c>
/// for a grandchild. Empty string for the root plan itself.</item>
/// </list>
/// Routing-style verb: always exits 0; consumers branch on the JSON payload.
/// </summary>
public sealed record PlanDeriveAncestorChainResult
{
/// <summary>Run-root work-item id (echo of the input).</summary>
public required int RootId { get; init; }

/// <summary>Item being planned (echo of the input).</summary>
public required int ItemId { get; init; }

/// <summary>True when <see cref="ItemId"/> equals <see cref="RootId"/>.</summary>
public required bool IsRootPlan { get; init; }

/// <summary>
/// Immediate plan-tree parent's work-item id, or null when the parent is
/// implicit (root plan, or direct child of root). Maps directly to the
/// <c>--parent-item-id</c> flag of the plan-PR verbs (omit when null).
/// </summary>
public int? ParentItemId { get; init; }

/// <summary>
/// Comma-separated ancestor chain (immediate parent first), with the
/// literal <c>"root"</c> token used in place of the root work-item id.
/// Empty string for the root plan. Maps directly to the
/// <c>--ancestor-ids</c> flag of <c>pr open-plan-pr</c>.
/// </summary>
public required string AncestorIds { get; init; }

/// <summary>
/// Convenience: same chain as <see cref="AncestorIds"/> but as a list.
/// Empty for the root plan. Useful for assertions and logs; the verbs
/// themselves consume <see cref="AncestorIds"/>.
/// </summary>
public required IReadOnlyList<string> AncestorChain { get; init; }

/// <summary>
/// Plan-tree depth: 0 for root plan, 1 for direct child of root, 2 for
/// grandchild, etc. Equal to <see cref="AncestorChain"/>.Count.
/// </summary>
public required int Depth { get; init; }

/// <summary>
/// Operator-facing error message when the chain cannot be derived
/// (item not found, item not a descendant of root, cycle detected, etc.).
/// Null on success.
/// </summary>
public string? Error { get; init; }
}
1 change: 1 addition & 0 deletions src/Polyphony/PolyphonyJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ namespace Polyphony;
[JsonSerializable(typeof(PlannableChild))]
[JsonSerializable(typeof(PlanLoadTypeResult))]
[JsonSerializable(typeof(PlanReviewResult))]
[JsonSerializable(typeof(PlanDeriveAncestorChainResult))]
[JsonSerializable(typeof(PolicyLoadResult))]
[JsonSerializable(typeof(PolicyValidateResult))]
[JsonSerializable(typeof(PolicyDomainSnapshot))]
Expand Down
Loading
Loading