Skip to content
Draft
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
10 changes: 2 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,8 @@ jobs:
with:
dotnet-version: "10.0.302"

- name: 验证生产项目无外部 NuGet 包
shell: bash
run: |
if grep -RIn --include='*.csproj' '<PackageReference' backend; then
echo "::error::生产 .NET 项目必须保持零外部 NuGet PackageReference"
exit 1
fi
echo "OK: backend 中无外部 NuGet PackageReference"
- name: 验证生产 NuGet 包命中允许清单
run: node tools/verify-dotnet-packages.js

- name: Restore(空 NuGet 源)
run: npm run dotnet:restore
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@

## [Unreleased]

### Stage 8.3:分析任务规则腿计算迁 C#
- ForgeX.Api 新增 `POST /api/v1/analysis-tasks`(仅 `AnalysisTasks:Provider=postgres` 时挂载):
接受与 `/api/v1/analytics/reports` 完全一致的归一化行契约加 `datasourceId`,以确定性
`ForgeX.Analytics` 报告引擎就地执行规则腿分析,并按 Node 存储的节奏(初始快照 + 每个
进度事件一次 UPSERT + 终态)写入共享 `forgex.node_analysis_tasks` 行——UPSERT 语句、
事件/快照字段语义与 `server/services/postgres-analysis.js` 逐字段对齐,RLS GUC 契约不变。
终态快照与全量事件随 201 返回;Stage 8.1 的历史/SSE 读取端点无需任何改动即可服务
C# 计算的任务。`AnalysisTasks:TaskTtlMs`(默认 3600000)对齐 Node `TASK_TTL_MS`。
- Node 新增迁移期双向开关 `ANALYSIS_AUTHORITY=node|csharp`(默认 node,行为与迁移前
完全一致,即回滚开关;csharp 需先配置 `GCODE_AUTHORITY_URL`,超时由
`ANALYSIS_AUTHORITY_TIMEOUT_MS` 控制)。csharp 模式下 `POST /api/analyze` 对**不使用
AI 的规则腿任务**退化为迁移代理:身份、限流与数据源归属校验留在 Node,归一化行连同
哈希后的 tenant/owner 上下文转发给 sidecar,返回的终态快照经 `TaskStore.adopt` 收编,
既有 result / 轮询 / SSE 重放路由原样服务;AI 叙述腿(Partner SSO / OpenAI 兼容)与
额度降级路径始终留在 Node,provider 密钥不出本进程。报告继续携带
`engine: server-rules` 与 `statsBy: csharp-analytics-authority`,与既有 C# Analytics
权威路由的任务逐字段同形,Stage 4 金样本对照口径不变。
- 新增 `tests/analysis-authority.test.js`(28 项:csharp 代理契约、凭据剥离、匿名化
上下文头、收编后三条读取路由、node 默认零 sidecar 调用、sidecar 不可用 502、
配置守卫)并纳入主测试链。

### 修复(CI 守卫对齐 Stage 8.1 Npgsql 特批)
- CI「零外部 NuGet 包」grep 与 `tools/security-audit.js` 同名检查自 Stage 8.1 引入特批
Npgsql 后一直红:改为逐项目锁版本的允许清单(`config/dependency-policy.json`
`allowedDotnetPackages`,当前仅 `ForgeX.Infrastructure` 的 Npgsql 9.0.3),新增
`tools/verify-dotnet-packages.js` 供 CI 与安全审计共用,清单之外的任何引用仍然失败。
- `tools/capture-readme-screenshots.js` 含 `page.evaluate()` 浏览器回调,纳入 ESLint
双运行环境放行块,消除 `npm run lint` 存量报错。

### 修复(React 引擎机型状态回归)
- 修复前端引擎 TS 迁移引入的 ES2022 类字段回归:四个机型子类的字段声明会在基类构造期
`_buildMachine()` 赋值之后重新 define,把 `zCarriage` / `zGantry` / `beam` / `_arms` /
Expand Down
33 changes: 33 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,39 @@ verify, then clear the previous value. The full procedure and alert response are
`deploy/RUNBOOK.md`; capacity assumptions and SLOs are in `deploy/capacity-plan.md` and
`deploy/SLO.md`.

## Stage 8 authority migration (shares, analysis tasks)

Stage 8.1 moved the shares authority to C#/PostgreSQL (`Shares:Provider=postgres`, Node switch
`SHARES_AUTHORITY=node|csharp`) and added the read side of Node analysis tasks
(`AnalysisTasks:Provider=postgres`): owner history, snapshot reads, and SSE replay from the shared
`forgex.node_analysis_tasks` table in the exact G-code jobs event wire format. Stage 8.2 added
direct caller identity (`DirectAuth:ApiKeys`) mirroring the Node auth chain.

### Stage 8.3 analysis-task compute leg

`POST /api/v1/analysis-tasks` (mapped only when `AnalysisTasks:Provider=postgres`) moves the
rules-leg compute into C#. The endpoint accepts the same normalized row contract as
`POST /api/v1/analytics/reports` plus a `datasourceId`, runs the deterministic
`ForgeX.Analytics` report engine inline, and upserts one snapshot per progress event into
`forgex.node_analysis_tasks` with the exact statement and field semantics of the Node store
(`server/services/postgres-analysis.js`), under the same RLS GUC contract. The terminal snapshot
plus the full event trail return in the `201` body. Because rows written by either runtime are
interchangeable, the Stage 8.1 read/SSE endpoints and the Node gateway serve C#-computed tasks
without any wire change.

`AnalysisTasks:TaskTtlMs` (default 3600000) matches the Node `TASK_TTL_MS` default and controls
`expires_at_utc`. The report carries `engine: server-rules`, `statsBy: csharp-analytics-authority`,
and the `authorityEngine` identity, exactly like a Node task routed through the C# analytics
authority, so dual-run comparisons against the Stage 4 golden baseline remain valid.

Node keeps the switch: `ANALYSIS_AUTHORITY=csharp` turns `POST /api/analyze` into a migration
proxy for tasks that do not use AI — identity, rate limiting, and datasource ownership checks stay
in Node, the normalized rows are forwarded with the hashed tenant/owner context, and the returned
terminal snapshot is adopted into the Node task map so the existing result/poll/SSE-replay routes
serve unchanged. AI narration legs (Partner SSO, OpenAI-compatible) always stay on the Node
providers; provider keys never reach the sidecar. `ANALYSIS_AUTHORITY=node` (default) is the
explicit rollback switch and keeps behavior identical to Stage 8.1.

## Stage 4 analytics dual-run

`ForgeX.Analytics` is the analysis migration core. It has no external package reference and ports
Expand Down
272 changes: 267 additions & 5 deletions backend/src/ForgeX.Api/AnalysisTaskEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,282 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using ForgeX.Analytics;
using ForgeX.Contracts;
using ForgeX.Infrastructure;

namespace ForgeX.Api;

/// <summary>
/// Stage 8.1: read side of Node analysis tasks served by C#. The Node runtime still
/// executes the analysis and upserts a snapshot per progress event into
/// forgex.node_analysis_tasks; these endpoints give history reads and SSE streaming
/// in the same wire format as the G-code jobs event model (id/event/data frames,
/// Last-Event-ID resume, heartbeat comments, close on terminal status).
/// Stage 8.1: read side of Node analysis tasks served by C# (history and SSE
/// streaming in the same wire format as the G-code jobs event model — id/event/data
/// frames, Last-Event-ID resume, heartbeat comments, close on terminal status).
/// Stage 8.3: the rules-leg compute moves here too. POST creates the task, runs the
/// deterministic ForgeX.Analytics report engine, and upserts one snapshot per
/// progress event into forgex.node_analysis_tasks exactly like the Node store, so
/// the existing read/SSE endpoints and the Node gateway serve C#-computed tasks
/// without any wire change. AI narration legs stay on the Node providers.
/// </summary>
internal static class AnalysisTaskEndpoints
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private const int MaxDatasourceIdLength = 128;
private const string RulesEngineId = "server-rules";
private static readonly JsonSerializerOptions EventJsonOptions = new(JsonSerializerDefaults.Web);

public static async Task<IResult> CreateAsync(
HttpContext context,
PostgresAnalysisTaskRepository tasks,
AnalysisTaskAuthorityOptions options)
{
var caller = CallerContextBoundary.GetRequired(context);
var mediaType = context.Request.ContentType?.Split(';', 2)[0].Trim();
if (!string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase))
{
return ApiProblemResults.Create(
context,
StatusCodes.Status415UnsupportedMediaType,
"unsupported_media_type",
"Unsupported media type",
"Use Content-Type: application/json.");
}

if (context.Request.ContentLength is > AnalyticsEndpoints.MaxRequestBytes)
{
return ApiProblemResults.Create(
context,
StatusCodes.Status413PayloadTooLarge,
"analysis_task_payload_too_large",
"Analysis task payload is too large",
$"The request body limit is {AnalyticsEndpoints.MaxRequestBytes} bytes.");
}

var maxBodySizeFeature = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
if (maxBodySizeFeature is { IsReadOnly: false })
{
maxBodySizeFeature.MaxRequestBodySize = AnalyticsEndpoints.MaxRequestBytes;
}

AnalysisTaskCreateRequestDto? request;
try
{
request = await context.Request.ReadFromJsonAsync<AnalysisTaskCreateRequestDto>(
cancellationToken: context.RequestAborted);
}
catch (JsonException exception)
{
return ApiProblemResults.Create(
context,
StatusCodes.Status400BadRequest,
"invalid_analysis_task_json",
"Analysis task request JSON is invalid",
exception.Message);
}
catch (BadHttpRequestException exception)
{
return ApiProblemResults.Create(
context,
exception.StatusCode,
exception.StatusCode == StatusCodes.Status413PayloadTooLarge
? "analysis_task_payload_too_large"
: "invalid_analysis_task_request",
exception.StatusCode == StatusCodes.Status413PayloadTooLarge
? "Analysis task payload is too large"
: "Analysis task request is invalid",
exception.Message);
}

// Rows/question/provenance ride the exact analytics-report contract.
var analyticsShape = request is null
? null
: new AnalyticsReportRequestDto(request.SchemaVersion, request.Question, request.Rows, request.Provenance);
if (!AnalyticsEndpoints.TryValidate(analyticsShape, out var question, out var rows, out var provenance, out var errors))
{
return ApiProblemResults.Create(
context,
StatusCodes.Status400BadRequest,
"invalid_analysis_task_request",
"Analysis task request is invalid",
errors.Values.SelectMany(static messages => messages).FirstOrDefault(),
errors);
}

var datasourceId = request!.DatasourceId ?? string.Empty;
if (datasourceId.Length is < 1 or > MaxDatasourceIdLength)
{
return ApiProblemResults.Create(
context,
StatusCodes.Status400BadRequest,
"invalid_datasource_id",
$"datasourceId must contain 1 to {MaxDatasourceIdLength} characters");
}

// Same id shape as the Node TaskStore: "t_" + 16 lowercase hex characters.
var id = "t_" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8));
var run = new TaskRun(caller.TenantId, caller.OwnerId, id, question, datasourceId, options.TaskTtl);

// One upsert per state change, mirroring the Node store write cadence:
// initial running snapshot, one per progress event, then the terminal event.
await tasks.UpsertAsync(run.Snapshot(), context.RequestAborted);
run.Emit("authority", "C# Analytics 权威规则引擎计算中", 0.25);
await tasks.UpsertAsync(run.Snapshot(), context.RequestAborted);

try
{
var report = AnalyticsReportEngine.AnalyzeMigratedIntent(question, rows, provenance);
run.Emit("complete", "C# Analytics 权威结果已生成", 1);
await tasks.UpsertAsync(run.Snapshot(), context.RequestAborted);
run.Finish(ComposeReportJson(report, id));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
run.Fail(exception.Message is { Length: > 0 } message ? message : "分析失败");
}

await tasks.UpsertAsync(run.Snapshot(), context.RequestAborted);
var record = await tasks.GetAsync(caller.TenantId, caller.OwnerId, id, context.RequestAborted);
if (record is null)
{
return ApiProblemResults.Create(context, 500, "analysis_task_persist_failed", "Analysis task snapshot was not persisted");
}

using var events = JsonDocument.Parse(record.EventsJson);
return Results.Json(
new AnalysisTaskCreateResponseDto(ToSnapshot(record), events.RootElement.Clone()),
statusCode: StatusCodes.Status201Created);
}

/// <summary>
/// Mirrors the report a rules-leg task carries when Node routes through the C#
/// analytics authority (csharpAnalyticsProvider + TaskStore._run): the engine
/// DTO plus engine/authorityEngine/statsBy overrides and taskId/cached fields.
/// </summary>
private static string ComposeReportJson(AnalyticsReport report, string taskId)
{
var node = JsonSerializer.SerializeToNode(report, AnalyticsEndpoints.ResponseJsonOptions)!.AsObject();
node["engine"] = RulesEngineId;
node["authorityEngine"] = new JsonObject
{
["name"] = "forgex-analytics-csharp",
["version"] = AnalyticsEndpoints.EngineVersion,
};
node["statsBy"] = "csharp-analytics-authority";
node["taskId"] = taskId;
node["cached"] = false;
return node.ToJsonString(AnalyticsEndpoints.ResponseJsonOptions);
}

/// <summary>
/// In-flight task state translated to AnalysisTaskRecord snapshots with the same
/// event and snapshot field semantics as the Node TaskStore (emit/_snapshot/_finish/_fail).
/// </summary>
private sealed class TaskRun
{
private readonly string _tenantId;
private readonly string _ownerId;
private readonly string _id;
private readonly string _question;
private readonly string _datasourceId;
private readonly DateTimeOffset _createdAt = DateTimeOffset.UtcNow;
private readonly TimeSpan _ttl;
private readonly JsonArray _events = [];
private long _sequence;
private string _status = "running";
private double _progress;
private string _phase = "running";
private string _message = string.Empty;
private string? _reportJson;
private string? _error;
private DateTimeOffset? _finishedAt;

public TaskRun(string tenantId, string ownerId, string id, string question, string datasourceId, TimeSpan ttl)
{
_tenantId = tenantId;
_ownerId = ownerId;
_id = id;
_question = question;
_datasourceId = datasourceId;
_ttl = ttl;
}

public void Emit(string stage, string message, double progress)
{
_events.Add(new JsonObject
{
["seq"] = ++_sequence,
["ts"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
["stage"] = stage,
["message"] = message,
["progress"] = progress,
});
_phase = stage.Length > 64 ? stage[..64] : stage;
_message = message;
_progress = Math.Clamp(progress, 0, 1);
}

public void Finish(string reportJson)
{
_reportJson = reportJson;
_status = "done";
_finishedAt = DateTimeOffset.UtcNow;
_events.Add(new JsonObject
{
["seq"] = ++_sequence,
["ts"] = _finishedAt.Value.ToUnixTimeMilliseconds(),
["done"] = true,
["progress"] = 1,
["message"] = "分析完成",
});
_phase = "done";
_message = "分析完成";
_progress = 1;
}

public void Fail(string error)
{
_status = "failed";
_error = error;
_finishedAt = DateTimeOffset.UtcNow;
_events.Add(new JsonObject
{
["seq"] = ++_sequence,
["ts"] = _finishedAt.Value.ToUnixTimeMilliseconds(),
["done"] = true,
["error"] = error,
["message"] = "分析失败:" + error,
});
// Node's _snapshot quirk kept for parity: the failure event carries no
// stage/progress, so phase stays "running" and progress falls back to 0.
_phase = "running";
_message = "分析失败:" + error;
_progress = 0;
}

public AnalysisTaskRecord Snapshot() => new(
_id,
_tenantId,
_ownerId,
_question,
_datasourceId,
RulesEngineId,
RulesEngineId,
_tenantId,
_status,
_progress,
_phase,
_message,
_reportJson,
_error,
null,
_events.ToJsonString(EventJsonOptions),
_createdAt,
_finishedAt,
_createdAt + _ttl,
DateTimeOffset.UtcNow);
}

public static async Task<IResult> ListAsync(HttpContext context, PostgresAnalysisTaskRepository tasks)
{
var caller = CallerContextBoundary.GetRequired(context);
Expand Down Expand Up @@ -178,3 +437,6 @@ await response.WriteAsync(
await response.Body.FlushAsync(cancellationToken);
}
}

/// <summary>Stage 8.3 execution settings: task TTL matching the Node TASK_TTL_MS default.</summary>
internal sealed record AnalysisTaskAuthorityOptions(TimeSpan TaskTtl);
Loading
Loading