Jacob Schmidt 0d77300c22 feat: Project Journal backend monorepo
Monorepo with centralized build props, npm workspaces, LlamaSharp AI,
SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-02 20:56:26 -06:00

265 lines
6.8 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Journal.AI;
using Journal.Core;
using Microsoft.Extensions.FileProviders;
var gatewayJsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var repoRoot = ResolveRepoRoot();
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot);
var webDistPath = ResolveWebDist(repoRoot);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFragmentServices();
builder.Services.AddLlamaSharpServices();
builder.Services.AddSingleton<Entry>();
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
builder.Services.AddSingleton(new WebUiState(webDistPath));
builder.Services.AddCors(options =>
{
options.AddPolicy("GatewayCors", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.PropertyNameCaseInsensitive = true;
});
var app = builder.Build();
app.UseCors("GatewayCors");
app.MapGet("/api/health", () => Results.Ok(new
{
ok = true,
service = "Journal.WebGateway"
}));
app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new
{
distPath = webUiState.DistPath,
exists = webUiState.Exists
}));
app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) =>
{
if (command is null || string.IsNullOrWhiteSpace(command.Action))
{
return Results.Content(ErrorResponse("Missing action"), "application/json");
}
var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions);
var responseJson = await entry.HandleCommandAsync(inputJson);
return Results.Content(responseJson, "application/json");
});
app.MapGet("/api/sidecar/root", (SidecarRootState rootState) =>
{
var (root, isCustom) = rootState.Get();
return Results.Ok(new
{
root,
isCustom
});
});
app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) =>
{
var path = request?.Path ?? "";
if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path))
{
return Results.BadRequest($"Directory '{path}' does not exist.");
}
rootState.Set(path);
var (root, isCustom) = rootState.Get();
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root);
return Results.Ok(new
{
root,
isCustom
});
});
if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html")))
{
var fileProvider = new PhysicalFileProvider(webDistPath);
var indexPath = Path.Combine(webDistPath, "index.html");
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = fileProvider,
RequestPath = ""
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = fileProvider,
RequestPath = ""
});
app.MapGet("/", async context =>
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
app.MapFallback(async context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(indexPath);
});
}
else
{
app.MapGet("/", () => Results.Ok(new
{
name = "Journal.WebGateway",
status = "ok",
uiAvailable = false,
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
expectedDist = webDistPath
}));
}
app.Run();
string ResolveRepoRoot()
{
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
{
return Path.GetFullPath(fromEnv);
}
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
{
var resolved = FindRepoRoot(start);
if (resolved is not null)
{
return resolved;
}
}
return Path.GetFullPath(Directory.GetCurrentDirectory());
}
string? FindRepoRoot(string start)
{
var cursor = Path.GetFullPath(start);
while (!string.IsNullOrWhiteSpace(cursor))
{
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) ||
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
{
return cursor;
}
var parent = Directory.GetParent(cursor);
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
{
return null;
}
cursor = parent.FullName;
}
return null;
}
string ResolveWebDist(string repoRootPath)
{
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return Path.GetFullPath(fromEnv);
}
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (Directory.Exists(packagedWwwRoot))
{
return packagedWwwRoot;
}
return Path.Combine(repoRootPath, "Journal.App", "build");
}
string ErrorResponse(string message)
=> JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions);
sealed class WebUiState(string distPath)
{
public string DistPath { get; } = distPath;
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
}
sealed class SidecarRootState(string autoRoot)
{
private readonly object _sync = new();
private readonly string _autoRoot = autoRoot;
private string _currentRoot = autoRoot;
private bool _isCustom;
public (string Root, bool IsCustom) Get()
{
lock (_sync)
{
return (_currentRoot, _isCustom);
}
}
public void Set(string? path)
{
lock (_sync)
{
if (string.IsNullOrWhiteSpace(path))
{
_currentRoot = _autoRoot;
_isCustom = false;
return;
}
_currentRoot = Path.GetFullPath(path.Trim());
_isCustom = true;
}
}
}
sealed class SetSidecarRootRequest
{
public string? Path { get; set; }
}
sealed class CommandEnvelope
{
public string Action { get; set; } = "";
public string? CorrelationId { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string? Tag { get; set; }
public JsonElement? Payload { get; set; }
}