Files
OpenNest/OpenNest.Server/Program.cs
T
ajandClaude Opus 5.5 54ad7ee84d feat(server): nest Auto Nest jobs on the server's worker
Large nests tie up the workstation that runs them. In Database mode, Auto Nest
can now queue the job on the nest server, where a worker container solves one
job at a time under its own CPU and memory limits. The job has no time limit,
so a throttled worker finds the same nest a fast one does, only later.

- Server: /api/nest-jobs queue (schema v15). The worker holds a job on a
  heartbeat lease. When the lease lapses, the job goes back to the queue,
  or fails after three attempts. Only one job runs at a time. Packages and
  results are opaque to the HTTP server, which stays off Engine and IO.
- Worker: OpenNest.Server.Worker in the same image (OPENNEST_ROLE=worker),
  reaching the server over HTTP only. It records each engine answer with
  RecordingNestingEngine and gives its job back on shutdown.
- Desktop: "Nest on the server" in the Auto Nest dialog, for built-in
  engines only. The client replays the recorded answers with
  RemoteNestSubmission, so validation, the review and Keep run locally
  against the client's own drawings, exactly as after a local run. A
  drawing edited after sending refuses the result. If the server cannot
  take the job, the user may nest locally instead.
- compose.server.yaml adds the throttled worker service. The Dockerfile,
  .dockerignore, CI paths and the deploy script now include the worker's
  projects.

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

247 lines
11 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
using OpenNest.Server;
var builder = WebApplication.CreateBuilder(args);
// Same wire contract as RemoteNestRepository: camelCase, enum-as-string.
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy;
options.SerializerOptions.PropertyNameCaseInsensitive = true;
foreach (var converter in jsonOptions.Converters)
options.SerializerOptions.Converters.Add(converter);
});
// Storage path: --database=<path> or --database <path>, then OPENNEST_DB, default ./data/nests.db (a Docker volume).
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// The server nesting queue's leases read this clock; tests replace it.
builder.Services.AddSingleton(TimeProvider.System);
// Desktop update feed mirrored from GitHub beside the database; OPENNEST_UPDATES=off turns it off.
var updatesEnabled = ServerOptions.UpdatesEnabled(Environment.GetEnvironmentVariable(ServerOptions.UpdatesVariable));
if (updatesEnabled)
{
builder.Services.AddSingleton(_ => new UpdateMirror(ServerOptions.UpdatesPath(databasePath), new HttpClient()));
builder.Services.AddHostedService<UpdateMirrorService>();
}
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
// Open the database now so an unusable data path fails startup, not the first request.
app.Services.GetRequiredService<NestDatabase>();
app.MapGet("/healthz", (NestDatabase db) => db.IsHealthy()
? Results.Ok(new { status = "ok" })
: Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable));
// Full enumeration (backup manifests, restore checks and the container smoke).
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
// Bounded, SQL-filtered page for browsing.
app.MapGet("/api/nests/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query))
: Results.BadRequest(error));
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id) is { } file ? Results.File(file, "application/zip", $"{id}.nest") : Results.NotFound());
// Sheet sizes and part counts the stored archive needs (inventory holds read this).
app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
{
if (db.Get(id) is not { } record || db.GetFile(id) is not { } file)
return Results.NotFound();
return NestArchive.ReadSheets(record, file, out var sheets) is { } archiveError
? Results.Problem(archiveError)
: Results.Ok(sheets);
});
// Ledger item codes on open nests' plates, so Auto Nest leaves them out until ML holds them.
// The nest being edited passes its own id as exclude.
app.MapGet("/api/nests/stock-item-codes", (Guid? exclude, NestDatabase db) =>
Results.Ok(db.StockCodesInUse(exclude)));
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
return Results.BadRequest($"year must be between {NestNames.MinYear} and {NestNames.MaxYear}.");
var number = db.AllocateNestNumber(year);
return Results.Ok(new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) });
});
// POST/PUT multipart: "metadata" JSON part + "file" part (the .nest archive).
app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
{
var (record, file, error) = await ReadMultipart(http);
if (error is not null)
return error;
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
try { return Results.Ok(db.Insert(id, record, file!)); }
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db));
app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision) is { } file ? Results.File(file, "application/zip", $"{id}-r{revision}.nest") : Results.NotFound());
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
var precondition = IfMatch.Read(http.Request, out var expected);
if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http);
if (error is not null) return error;
try
{
var updated = db.Update(id, record!, file!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (RevisionConflictException conflict)
{
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
});
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) =>
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
NestRecord? record;
try { record = await request.ReadFromJsonAsync<NestRecord>(NestMetadata.Options); }
catch (Exception ex) when (ex is JsonException or InvalidOperationException or BadHttpRequestException)
{ return Results.BadRequest("Invalid metadata JSON."); }
if (record is null) return Results.BadRequest("Missing metadata.");
if (NestMetadata.GetError(record) is { } invalid) return Results.BadRequest(invalid);
try
{
var updated = db.UpdateMetadata(id, record, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, true, request, db));
app.MapPost("/api/nests/deleted/{id:guid}/recover", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, false, request, db));
app.MapGet("/api/nests/deleted/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query, deleted: true)) : Results.BadRequest(error));
app.MapGet("/api/nests/deleted/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id, deleted: true) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db, deleted: true));
app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) =>
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
if (revision < 1) return Results.BadRequest("Invalid revision.");
try
{
var restored = db.Restore(id, revision, expected);
return restored is null ? Results.NotFound() : Results.Ok(restored);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
app.MapNestJobs();
if (updatesEnabled)
app.MapUpdates();
app.Run();
static IResult ChangeVisibility(Guid id, bool deleted, HttpRequest request, NestDatabase db)
{
var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
try
{
var changed = db.SetDeleted(id, deleted, expected);
return changed is null ? Results.NotFound() : Results.Ok(changed);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
}
static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db, bool deleted = false)
{
var offset = 0;
var limit = 100;
foreach (var parameter in request.Query)
{
if (parameter.Value.Count != 1 || !int.TryParse(parameter.Value[0], out var value))
return Results.BadRequest("Invalid history pagination.");
switch (parameter.Key)
{
case "offset": offset = value; break;
case "limit": limit = value; break;
default: return Results.BadRequest("Unknown history parameter.");
}
}
if (offset < 0 || limit is < 1 or > 200) return Results.BadRequest("Invalid history pagination.");
return db.History(id, offset, limit, deleted) is { } page ? Results.Ok(page) : Results.NotFound();
}
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.
static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http)
{
if (!http.Request.HasFormContentType)
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
IFormCollection form;
try
{
form = await http.Request.ReadFormAsync();
}
catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
{
// A truncated or malformed multipart body is a client error. BadHttpRequestException
// (for example, an oversized body) keeps the status code Kestrel assigns it.
return (null, null, Results.BadRequest("Malformed multipart body."));
}
if (!NestMetadata.TryRead(form["metadata"].FirstOrDefault(), out var record, out var metadataError))
return (null, null, Results.BadRequest(metadataError));
var filePart = form.Files["file"];
if (filePart is null || filePart.Length == 0)
return (null, null, Results.BadRequest("Missing or empty 'file' part."));
using var stream = filePart.OpenReadStream();
using var buffer = new MemoryStream((int)filePart.Length);
await stream.CopyToAsync(buffer);
var file = buffer.ToArray();
// Counts, material and thickness come from the archive itself, not the client's claim.
if (NestArchive.Apply(record, file) is { } archiveError)
return (null, null, Results.BadRequest(archiveError));
return (record, file, null);
}
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
public partial class Program { }