Files
OpenNest/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs
T
ajandClaude Opus 5.5 81ec6a8125 feat(server): list, reopen and keep server nest jobs once
A server Auto Nest result could only be reviewed in the session that sent it,
and nothing stopped two desktops from keeping the same result. Phase 3 adds
My Server Jobs, so results that finished while OpenNest was closed are easy
to find, and records each review on the server.

- Server: GET /api/nest-jobs lists jobs newest first with state, engine,
  submitter, times, queue position, review and the worker's summary (parts,
  sheets, utilization, unplaced). It filters to active, finished or
  unreviewed jobs and to a submitter (a bare user name matches that user on
  any workstation). An ETag turns unchanged polls into 304s. Schema v17 adds
  nestId, review and summary columns, only where missing.
- Keep once: POST .../keep and .../discard take If-Match on the job revision
  and change an unreviewed result only once. A second desktop gets 409 with
  who kept it. DELETE removes finished jobs. A succeeded job keeps its
  package until it is reviewed, so a later session can rebuild the job.
- Retention: OPENNEST_JOB_RETENTION_DAYS (default 7) deletes finished jobs
  with their results. It runs on claims and list reads. A malformed value
  fails startup instead of deleting results early.
- Worker: reports the result's summary with the transcript. A job cancelled
  while its result arrived keeps no summary (found in the Docker smoke run).
- Desktop: Nest > My Server Jobs polls the list every 3 s; polling rather
  than per-job SSE because the list must show other desktops' jobs and
  removals. Open rebuilds the job from job.json against the open nest's own
  drawings. New per-drawing geometry fingerprints make the freshness check
  work across sessions. Cancel Job and Remove act on the selected job. A
  status-bar item counts this user's results waiting for review; there is
  no popup. Auto Nest's review now records Keep on the server before it
  commits, and records Discard too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:11 -05:00

167 lines
6.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Engine.Jobs.Remote;
/// <summary>
/// The parts of a <see cref="NestPipelineRequest"/> that are not drawings: the engine, each
/// item's quantity, priority and rotation with the index of its drawing, the stock and the job
/// options. Drawings travel beside it in a <c>.nest</c> archive (<c>OpenNest.Api.NestJobPackage</c>).
/// A remote solve never runs with <see cref="NestJobOptions.SearchUntilCancelled"/>: it has no
/// time limit, so a slow server finds the same nest a fast one does, later.
/// </summary>
public sealed class NestJobDocument
{
public const int CurrentVersion = 1;
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public int Version { get; set; } = CurrentVersion;
public string EngineName { get; set; }
/// <summary>Each drawing's name in list order, so a reader can tell the list arrived in that order.</summary>
public List<string> DrawingNames { get; set; } = new();
/// <summary>
/// Each drawing's <see cref="PartGeometrySnapshot.Fingerprint"/> as sent, in list order (empty
/// for a drawing without supported geometry). A desktop reopening the job in a later session
/// checks its drawings against these (<see cref="RemoteNestSubmission.Reopen"/>). Null from
/// clients that predate it; workers ignore it.
/// </summary>
public List<string> DrawingFingerprints { get; set; }
public List<ItemJson> Items { get; set; } = new();
public List<NestStockJson> Stock { get; set; } = new();
public OptionsJson JobOptions { get; set; }
/// <summary>
/// Describes <paramref name="request"/>, naming each item's drawing by its position in
/// <paramref name="drawings"/>, which must hold every item's drawing.
/// </summary>
public static NestJobDocument From(NestPipelineRequest request, IReadOnlyList<Drawing> drawings)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(drawings);
var document = new NestJobDocument
{
EngineName = request.EngineName,
DrawingNames = drawings.Select(d => d?.Name ?? "").ToList(),
DrawingFingerprints = drawings.Select(RemoteNestSubmission.Fingerprint).ToList(),
Stock = request.Stock.Select(NestStockJson.From).ToList(),
JobOptions = request.Options is { } o ? new OptionsJson
{
PlacementStrategy = o.PlacementStrategy,
MaxPlates = o.MaxPlates,
SalvageRate = o.SalvageRate,
MinimumSalvageDimension = o.MinimumSalvageDimension,
OffcutCut = o.OffcutCut,
} : null,
};
foreach (var item in request.Items)
{
var index = item?.Drawing == null ? -1 : IndexOf(drawings, item.Drawing);
if (item != null && index < 0)
throw new ArgumentException("Every item's drawing must be in the drawing list.", nameof(drawings));
document.Items.Add(item == null ? null : new ItemJson
{
Drawing = index,
Quantity = item.Quantity,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
return document;
}
/// <summary>The request this describes, with drawings taken from <paramref name="drawings"/> by position.</summary>
public NestPipelineRequest ToRequest(IReadOnlyList<Drawing> drawings)
{
ArgumentNullException.ThrowIfNull(drawings);
if (Version != CurrentVersion)
throw new FormatException($"Nest job version {Version} is not supported; this build reads {CurrentVersion}.");
if (string.IsNullOrWhiteSpace(EngineName))
throw new FormatException("The nest job names no engine.");
if (DrawingNames == null || DrawingNames.Count != drawings.Count
|| DrawingNames.Where((name, i) => !string.Equals(name, drawings[i]?.Name ?? "", StringComparison.Ordinal)).Any())
throw new FormatException("The nest job's drawings do not match the drawings sent with it.");
var items = new List<NestItem>(Items?.Count ?? 0);
foreach (var item in Items ?? new())
{
if (item == null)
{
items.Add(null);
continue;
}
if (item.Drawing < 0 || item.Drawing >= drawings.Count)
throw new FormatException($"A nest job item names drawing {item.Drawing}, which the job does not hold.");
items.Add(new NestItem
{
Drawing = drawings[item.Drawing],
Quantity = item.Quantity,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
var options = JobOptions is { } o
? new NestJobOptions(o.PlacementStrategy ?? "Lattice", o.MaxPlates, o.SalvageRate, o.MinimumSalvageDimension,
searchUntilCancelled: false, o.OffcutCut)
: null;
return new NestPipelineRequest(EngineName, items,
(Stock ?? new()).Select(s => s?.ToStock() ?? throw new FormatException("A nest job stock entry is empty.")).ToList(),
options);
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static NestJobDocument FromJson(string json)
{
try
{
return JsonSerializer.Deserialize<NestJobDocument>(json, Options)
?? throw new FormatException("The nest job is empty.");
}
catch (JsonException ex) { throw new FormatException("The nest job is not valid JSON.", ex); }
}
private static int IndexOf(IReadOnlyList<Drawing> drawings, Drawing drawing)
{
for (var i = 0; i < drawings.Count; i++)
{
if (ReferenceEquals(drawings[i], drawing))
return i;
}
return -1;
}
public sealed class ItemJson
{
public int Drawing { get; set; }
public int Quantity { get; set; }
public int Priority { get; set; }
public double StepAngle { get; set; }
public double RotationStart { get; set; }
public double RotationEnd { get; set; }
}
public sealed class OptionsJson
{
public string PlacementStrategy { get; set; }
public int? MaxPlates { get; set; }
public double SalvageRate { get; set; }
public double MinimumSalvageDimension { get; set; }
public CutOffAxis? OffcutCut { get; set; }
}
}