diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af44462..f7e30be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,14 +100,8 @@ jobs: with: dotnet-version: "10.0.302" - - name: 验证生产项目无外部 NuGet 包 - shell: bash - run: | - if grep -RIn --include='*.csproj' ' -/// 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. /// 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 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(); + if (maxBodySizeFeature is { IsReadOnly: false }) + { + maxBodySizeFeature.MaxRequestBodySize = AnalyticsEndpoints.MaxRequestBytes; + } + + AnalysisTaskCreateRequestDto? request; + try + { + request = await context.Request.ReadFromJsonAsync( + 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); + } + + /// + /// 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. + /// + 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); + } + + /// + /// In-flight task state translated to AnalysisTaskRecord snapshots with the same + /// event and snapshot field semantics as the Node TaskStore (emit/_snapshot/_finish/_fail). + /// + 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 ListAsync(HttpContext context, PostgresAnalysisTaskRepository tasks) { var caller = CallerContextBoundary.GetRequired(context); @@ -178,3 +437,6 @@ await response.WriteAsync( await response.Body.FlushAsync(cancellationToken); } } + +/// Stage 8.3 execution settings: task TTL matching the Node TASK_TTL_MS default. +internal sealed record AnalysisTaskAuthorityOptions(TimeSpan TaskTtl); diff --git a/backend/src/ForgeX.Api/AnalyticsEndpoints.cs b/backend/src/ForgeX.Api/AnalyticsEndpoints.cs index 6d8c65a..01de115 100644 --- a/backend/src/ForgeX.Api/AnalyticsEndpoints.cs +++ b/backend/src/ForgeX.Api/AnalyticsEndpoints.cs @@ -14,7 +14,8 @@ internal static class AnalyticsEndpoints private const int MaxQuestionLength = 500; private const int MaxTextLength = 512; - private static readonly JsonSerializerOptions ResponseJsonOptions = new(JsonSerializerDefaults.Web) + /// Also used by Stage 8.3 task execution so report JSON keeps the exact analytics-report shape. + internal static readonly JsonSerializerOptions ResponseJsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.Never, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, @@ -97,7 +98,8 @@ public static async Task AnalyzeAsync(HttpContext context) return Results.Json(response, ResponseJsonOptions); } - private static bool TryValidate( + /// Shared with AnalysisTaskEndpoints (Stage 8.3): the task compute leg accepts the same row contract. + internal static bool TryValidate( AnalyticsReportRequestDto? request, out string question, out IReadOnlyList rows, diff --git a/backend/src/ForgeX.Api/Program.cs b/backend/src/ForgeX.Api/Program.cs index 5390e96..f73104e 100644 --- a/backend/src/ForgeX.Api/Program.cs +++ b/backend/src/ForgeX.Api/Program.cs @@ -73,7 +73,9 @@ } // ── Stage 8.1:Node 分析任务历史读取 + SSE 汇入 jobs 事件模型 ──────────────── -// Node 仍执行分析并逐事件 UPSERT 快照;C# 从同一张表提供历史与事件流。 +// Stage 8.3:规则腿计算也迁入本进程——POST 创建任务并以 ForgeX.Analytics 直接执行, +// 逐事件 UPSERT 到同一张 forgex.node_analysis_tasks(与 Node 存储字节兼容); +// AI 叙述腿仍由 Node provider 承担。 var analysisTasksProvider = (builder.Configuration["AnalysisTasks:Provider"] ?? "disabled").Trim().ToLowerInvariant(); var analysisTasksPostgresUrl = builder.Configuration["AnalysisTasks:PostgresUrl"] ?? Environment.GetEnvironmentVariable("POSTGRES_URL") @@ -89,6 +91,12 @@ var analysisTasksEnabled = analysisTasksProvider == "postgres"; if (analysisTasksEnabled) { + var analysisTaskTtlMs = ReadInt(builder.Configuration, "AnalysisTasks:TaskTtlMs", 60 * 60 * 1000); + if (analysisTaskTtlMs < 1) + { + throw new InvalidOperationException("AnalysisTasks:TaskTtlMs must be positive."); + } + builder.Services.AddSingleton(new AnalysisTaskAuthorityOptions(TimeSpan.FromMilliseconds(analysisTaskTtlMs))); builder.Services.AddSingleton(_ => new PostgresAnalysisTaskRepository(analysisTasksPostgresUrl)); } @@ -390,6 +398,15 @@ await ApiProblemResults.Create(context, context.Response.StatusCode, code, title if (analysisTasksEnabled) { + app.MapPost("/api/v1/analysis-tasks", AnalysisTaskEndpoints.CreateAsync) + .WithName("CreateAnalysisTask") + .Accepts("application/json") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest, "application/problem+json") + .Produces(StatusCodes.Status401Unauthorized, "application/problem+json") + .Produces(StatusCodes.Status413PayloadTooLarge, "application/problem+json") + .Produces(StatusCodes.Status415UnsupportedMediaType, "application/problem+json"); + app.MapGet("/api/v1/analysis-tasks", AnalysisTaskEndpoints.ListAsync) .WithName("ListAnalysisTasks") .Produces() diff --git a/backend/src/ForgeX.Contracts/AnalysisTaskContracts.cs b/backend/src/ForgeX.Contracts/AnalysisTaskContracts.cs index dcb6797..05c85e0 100644 --- a/backend/src/ForgeX.Contracts/AnalysisTaskContracts.cs +++ b/backend/src/ForgeX.Contracts/AnalysisTaskContracts.cs @@ -34,3 +34,24 @@ public sealed record AnalysisTaskLinksDto( public sealed record AnalysisTaskListResponseDto( [property: JsonPropertyName("items")] IReadOnlyList Items); + +/// +/// Stage 8.3: the compute leg of a rules-engine analysis task moves to C#. The Node +/// gateway resolves the datasource, keeps ownership checks local, and forwards the +/// normalized rows with the anonymized caller context; the row contract is the same +/// one POST /api/v1/analytics/reports already accepts. +/// +public sealed record AnalysisTaskCreateRequestDto( + [property: JsonPropertyName("schemaVersion")] string? SchemaVersion, + [property: JsonPropertyName("question")] string? Question, + [property: JsonPropertyName("datasourceId")] string? DatasourceId, + [property: JsonPropertyName("rows")] IReadOnlyList? Rows, + [property: JsonPropertyName("provenance")] AnalyticsProvenanceRequestDto? Provenance); + +/// +/// Terminal snapshot plus the full replayable event trail. The Node migration proxy +/// adopts both so its existing SSE replay and result routes keep serving unchanged. +/// +public sealed record AnalysisTaskCreateResponseDto( + [property: JsonPropertyName("task")] AnalysisTaskSnapshotDto Task, + [property: JsonPropertyName("events")] JsonElement Events); diff --git a/backend/src/ForgeX.Infrastructure/PostgresAnalysisTaskRepository.cs b/backend/src/ForgeX.Infrastructure/PostgresAnalysisTaskRepository.cs index dabd37b..29a54ce 100644 --- a/backend/src/ForgeX.Infrastructure/PostgresAnalysisTaskRepository.cs +++ b/backend/src/ForgeX.Infrastructure/PostgresAnalysisTaskRepository.cs @@ -26,11 +26,12 @@ public sealed record AnalysisTaskRecord( DateTimeOffset UpdatedAt); /// -/// Read-side access to forgex.node_analysis_tasks. Stage 8.1 boundary: the Node -/// runtime still owns the computation and writes every snapshot (one upsert per -/// progress event), so serving reads and event replay from this table gives C# -/// live visibility without duplicating the write path. Same RLS contract as the -/// Node store: per-transaction app.tenant_id / app.owner_id GUCs. +/// Access to forgex.node_analysis_tasks. Stage 8.1 added the read side (history and +/// event replay); Stage 8.3 adds the write side so C# can own the rules-leg compute: +/// mirrors the Node store upsert statement byte-for-byte +/// (server/services/postgres-analysis.js _save), one upsert per progress event. +/// Same RLS contract as the Node store: per-transaction app.tenant_id / +/// app.owner_id GUCs. /// public sealed class PostgresAnalysisTaskRepository : IAsyncDisposable { @@ -112,6 +113,56 @@ public Task> ListAsync( return await reader.ReadAsync(cancellationToken) ? Map(reader) : null; }, cancellationToken); + /// + /// Stage 8.3 write path: full-row upsert, one call per progress event, exactly + /// like the Node store so rows written by either runtime stay interchangeable. + /// + public Task UpsertAsync(AnalysisTaskRecord record, CancellationToken cancellationToken) => + WithOwnerTransactionAsync(record.TenantId, record.OwnerId, async (connection, transaction) => + { + await using var upsert = new NpgsqlCommand( + """ + INSERT INTO forgex.node_analysis_tasks + (id, tenant_id, owner_id, question, datasource_id, engine, provider, credential_scope, + status, progress, phase, message, report_json, error_message, upstream_task_id, + events_json, created_at_utc, finished_at_utc, expires_at_utc, updated_at_utc) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,$15,$16::jsonb,$17,$18,$19,$20) + ON CONFLICT (id) DO UPDATE SET + status=EXCLUDED.status, progress=EXCLUDED.progress, phase=EXCLUDED.phase, + message=EXCLUDED.message, report_json=EXCLUDED.report_json, + error_message=EXCLUDED.error_message, upstream_task_id=EXCLUDED.upstream_task_id, + events_json=EXCLUDED.events_json, finished_at_utc=EXCLUDED.finished_at_utc, + expires_at_utc=EXCLUDED.expires_at_utc, updated_at_utc=EXCLUDED.updated_at_utc + """, + connection, + transaction); + upsert.Parameters.Add(Text(record.Id)); + upsert.Parameters.Add(Text(record.TenantId)); + upsert.Parameters.Add(Text(record.OwnerId)); + upsert.Parameters.Add(Text(record.Question)); + upsert.Parameters.Add(Text(record.DatasourceId)); + upsert.Parameters.Add(Text(record.Engine)); + upsert.Parameters.Add(Text(record.Provider)); + upsert.Parameters.Add(Text(record.CredentialScope)); + upsert.Parameters.Add(Text(record.Status)); + upsert.Parameters.Add(new NpgsqlParameter { Value = record.Progress, NpgsqlDbType = NpgsqlDbType.Double }); + upsert.Parameters.Add(Text(record.Phase)); + upsert.Parameters.Add(Text(record.Message)); + // Node serializes null reports as the JSON literal "null" (JSON.stringify(null)). + upsert.Parameters.Add(Text(record.ReportJson ?? "null")); + upsert.Parameters.Add(NullableText(record.ErrorMessage)); + upsert.Parameters.Add(NullableText(record.UpstreamTaskId)); + upsert.Parameters.Add(Text(record.EventsJson)); + upsert.Parameters.Add(Timestamp(record.CreatedAt)); + upsert.Parameters.Add(record.FinishedAt is { } finished + ? Timestamp(finished) + : new NpgsqlParameter { Value = DBNull.Value, NpgsqlDbType = NpgsqlDbType.TimestampTz }); + upsert.Parameters.Add(Timestamp(record.ExpiresAt)); + upsert.Parameters.Add(Timestamp(record.UpdatedAt)); + await upsert.ExecuteNonQueryAsync(cancellationToken); + return true; + }, cancellationToken); + public ValueTask DisposeAsync() => _dataSource.DisposeAsync(); private async Task WithOwnerTransactionAsync( @@ -179,6 +230,9 @@ private static DateTimeOffset ReadTimestamp(NpgsqlDataReader reader, int ordinal private static NpgsqlParameter Text(string value) => new() { Value = value, NpgsqlDbType = NpgsqlDbType.Text }; + private static NpgsqlParameter NullableText(string? value) => + new() { Value = value is null ? DBNull.Value : value, NpgsqlDbType = NpgsqlDbType.Text }; + private static NpgsqlParameter Timestamp(DateTimeOffset value) => new() { Value = value.UtcDateTime, NpgsqlDbType = NpgsqlDbType.TimestampTz }; } diff --git a/config/dependency-policy.json b/config/dependency-policy.json index 4d002e1..57a81c7 100644 --- a/config/dependency-policy.json +++ b/config/dependency-policy.json @@ -6,5 +6,8 @@ "node_modules/vite/node_modules/fsevents": "2.3.3" }, "allowedSecretFixtures": [{ "file": "tests/server.test.js", "kind": "credential-url" }], + "allowedDotnetPackages": { + "backend/src/ForgeX.Infrastructure/ForgeX.Infrastructure.csproj": { "Npgsql": "9.0.3" } + }, "requiredDockerfiles": ["deploy/Dockerfile", "deploy/Dockerfile.api"] } diff --git a/config/eslint.config.js b/config/eslint.config.js index bbae796..409299f 100644 --- a/config/eslint.config.js +++ b/config/eslint.config.js @@ -60,6 +60,7 @@ const BROWSER_GLOBALS = { requestAnimationFrame: "readonly", cancelAnimationFrame: "readonly", fetch: "readonly", + Event: "readonly", EventSource: "readonly", FileReader: "readonly", Image: "readonly", @@ -198,9 +199,9 @@ module.exports = [ }, /* E2E 用例是 Node 文件,但 page.evaluate() 的回调在浏览器里执行—— - 同一个文件里两套运行环境,两套全局都得放行。 */ + 同一个文件里两套运行环境,两套全局都得放行。README 截图工具同理。 */ { - files: ["tests/e2e/**/*.js", "config/playwright.config.js"], + files: ["tests/e2e/**/*.js", "config/playwright.config.js", "tools/capture-readme-screenshots.js"], languageOptions: { ecmaVersion: 2022, sourceType: "commonjs", diff --git a/frontend/src/engine/printer3d.ts b/frontend/src/engine/printer3d.ts index eca9607..c71a620 100644 --- a/frontend/src/engine/printer3d.ts +++ b/frontend/src/engine/printer3d.ts @@ -810,8 +810,8 @@ export class FXPrinterCoreXY extends FXPrinterBase { (表现为切换机型即崩、喷头 TIP_DZ 错位、Delta 并联臂断线)。 */ declare beam: THREE.Group; declare TIP_DZ: number; - protected declare screwPos: Array<[number, number]>; - protected declare frameDims: { HX: number; HZ: number; Y0: number; Y1: number }; + declare protected screwPos: Array<[number, number]>; + declare protected frameDims: { HX: number; HZ: number; Y0: number; Y1: number }; protected _buildMachine(): void { this.MODEL_NAME = "FX-256 睿造"; diff --git a/frontend/src/engine/printers.ts b/frontend/src/engine/printers.ts index 6175d1c..bd2a519 100644 --- a/frontend/src/engine/printers.ts +++ b/frontend/src/engine/printers.ts @@ -176,7 +176,7 @@ export class FXPrinterDelta extends FXPrinterBase { declare ARM_L: number; declare TOWER_R: number; declare towers: Array<{ x: number; z: number; u: { x: number; z: number }; car: THREE.Mesh }>; - private declare _arms?: THREE.Mesh[][]; + declare private _arms?: THREE.Mesh[][]; protected _buildMachine(): void { this.MODEL_NAME = "FX-Δ260 迅影"; diff --git a/package.json b/package.json index 52e02c9..58b1f29 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "security:audit": "node tools/security-audit.js", "ops:check": "node tools/validate-operations.js", "rollback:rehearse": "node tools/rehearse-release-rollback.js", - "test": "node tests/smoke.js && node tests/zero-runtime-deps.test.js && node tests/sim-calib.test.js && node tests/exporter.test.js && node tests/gcode.test.js && node tests/machine-log.test.js && node tests/time-calibration.test.js && node tests/calibration-registry.test.js && node tests/calibration-service.test.js && node tests/postgres-persistence.test.js && node tests/postgres-datasource.test.js && node tests/postgres-knowledge.test.js && node tests/postgres-share.test.js && node tests/postgres-analysis.test.js && node tests/profiles.test.js && node tests/stats.test.js && node tests/insight.test.js && node tests/farm.test.js && node tests/server.test.js && node tests/server-rules-authority.test.js && node tests/calibration-authority.test.js && node tests/gcode-async-jobs.test.js && node tests/analytics-authority.test.js && node tests/openapi-contract.test.js && node tests/partner-sso.test.js && node tests/stage0-hardening.test.js && node tests/check-refs.js && node tools/validate-ecosystem.js && node tools/validate-fixtures.js && node tools/validate-stage0-golden.js && node tools/validate-stage4-analytics-golden.js && node tools/validate-stage5-layer-plan-golden.js && node tools/validate-calibrations.js && node tools/validate-postgres-migrations.js && node tools/validate-containers.js && node tools/release-audit.js", + "test": "node tests/smoke.js && node tests/zero-runtime-deps.test.js && node tests/sim-calib.test.js && node tests/exporter.test.js && node tests/gcode.test.js && node tests/machine-log.test.js && node tests/time-calibration.test.js && node tests/calibration-registry.test.js && node tests/calibration-service.test.js && node tests/postgres-persistence.test.js && node tests/postgres-datasource.test.js && node tests/postgres-knowledge.test.js && node tests/postgres-share.test.js && node tests/postgres-analysis.test.js && node tests/profiles.test.js && node tests/stats.test.js && node tests/insight.test.js && node tests/farm.test.js && node tests/server.test.js && node tests/server-rules-authority.test.js && node tests/calibration-authority.test.js && node tests/gcode-async-jobs.test.js && node tests/analytics-authority.test.js && node tests/analysis-authority.test.js && node tests/openapi-contract.test.js && node tests/partner-sso.test.js && node tests/stage0-hardening.test.js && node tests/check-refs.js && node tools/validate-ecosystem.js && node tools/validate-fixtures.js && node tools/validate-stage0-golden.js && node tools/validate-stage4-analytics-golden.js && node tools/validate-stage5-layer-plan-golden.js && node tools/validate-calibrations.js && node tools/validate-postgres-migrations.js && node tools/validate-containers.js && node tools/release-audit.js", "lint": "eslint --config config/eslint.config.js .", "lint:fix": "eslint --config config/eslint.config.js . --fix", "format": "prettier --config config/prettier.json --ignore-path config/prettier.ignore --ignore-path .gitignore --write \"README.md\" \".github/**/*.{md,yml,yaml}\" \"config/**/*.{js,json}\" \"package*.json\" \"frontend/**/*.{ts,tsx,css,json,html}\" \"tools/**/*.js\" \"render.yaml\"", diff --git a/server/.env.example b/server/.env.example index a1ac88d..8789bc6 100644 --- a/server/.env.example +++ b/server/.env.example @@ -68,6 +68,11 @@ CALIBRATION_AUTHORITY_TIMEOUT_MS=30000 ANALYSIS_PROVIDER=auto # 规则引擎优先使用已配置的 C# Analytics 权威;置 0 可回退 Node 本地规则。 SERVER_RULES_AUTHORITY=1 +# Stage 8.3:规则腿分析任务的创建与计算权威。node(默认)=本进程执行, +# csharp=迁到 ForgeX.Api(需 GCODE_AUTHORITY_URL,且 C# 侧配置 +# AnalysisTasks__Provider=postgres)。AI 叙述腿始终留在 Node。 +ANALYSIS_AUTHORITY=node +ANALYSIS_AUTHORITY_TIMEOUT_MS=30000 # ── OpenAI 兼容端点(OpenAI / Azure / Ollama / vLLM / 各家兼容服务)── # 任何暴露 /chat/completions 的服务都能接。留空则不启用。 diff --git a/server/config.js b/server/config.js index 1e18a62..d33d421 100644 --- a/server/config.js +++ b/server/config.js @@ -151,6 +151,13 @@ function getConfig(overrides) { //(与 G-code 权威共用同一 sidecar origin 与内部信任令牌)。 sharesAuthority: String(env.SHARES_AUTHORITY || "node").trim().toLowerCase(), sharesAuthorityTimeoutMs: num(env.SHARES_AUTHORITY_TIMEOUT_MS, 15000), + // ── Stage 8.3:分析任务规则腿权威切流(迁移期双向开关)───────────── + // node = 本进程执行(默认,行为不变);csharp = 规则腿任务的创建与计算 + // 迁到 ForgeX.Api(POST /api/v1/analysis-tasks,同一 sidecar origin 与 + // 内部信任令牌,C# 侧需 AnalysisTasks:Provider=postgres)。AI 叙述腿 + // (InfiniSynapse / OpenAI 兼容)永远留在 Node——provider 密钥不出本进程。 + analysisAuthority: String(env.ANALYSIS_AUTHORITY || "node").trim().toLowerCase(), + analysisAuthorityTimeoutMs: num(env.ANALYSIS_AUTHORITY_TIMEOUT_MS, 30000), calibrationAuthorityEnabled: env.CALIBRATION_AUTHORITY_ENABLED !== "0", calibrationAuthorityTimeoutMs: num(env.CALIBRATION_AUTHORITY_TIMEOUT_MS, 30000), calibrationAuthorityMaxBytes: CALIBRATION_AUTHORITY_HARD_MAX_BYTES, @@ -198,6 +205,14 @@ function getConfig(overrides) { throw new Error("SHARES_AUTHORITY=csharp 需要先配置 GCODE_AUTHORITY_URL(共用同一 C# sidecar)"); } cfg.sharesAuthorityTimeoutMs = Math.max(1, num(cfg.sharesAuthorityTimeoutMs, 15000)); + cfg.analysisAuthority = String(cfg.analysisAuthority || "node").trim().toLowerCase(); + if (!["node", "csharp"].includes(cfg.analysisAuthority)) { + throw new Error("ANALYSIS_AUTHORITY must be node or csharp"); + } + if (cfg.analysisAuthority === "csharp" && !cfg.gcodeAuthorityUrl) { + throw new Error("ANALYSIS_AUTHORITY=csharp 需要先配置 GCODE_AUTHORITY_URL(共用同一 C# sidecar)"); + } + cfg.analysisAuthorityTimeoutMs = Math.max(1, num(cfg.analysisAuthorityTimeoutMs, 30000)); if (!["file", "postgres", "postgresql"].includes(cfg.persistenceProvider)) { throw new Error("PERSISTENCE_PROVIDER must be file or postgres"); } diff --git a/server/routes/analyze.js b/server/routes/analyze.js index 1b25ee3..355e73a 100644 --- a/server/routes/analyze.js +++ b/server/routes/analyze.js @@ -1,12 +1,70 @@ -/* 分析任务路由:建任务(限流)/ SSE 进度流 / 结果获取。 */ +/* 分析任务路由:建任务(限流)/ SSE 进度流 / 结果获取。 + + Stage 8.3(V2.0 手册 §4.2):ANALYSIS_AUTHORITY=csharp 时,规则腿任务 + (不使用 AI 的确定性统计分析)的创建与计算迁到 ForgeX.Api——本文件保留 + 身份/限流/数据源归属校验,把已归一化的行连同匿名化租户上下文转发给 + POST /api/v1/analysis-tasks;C# 逐事件 UPSERT 共享 PostgreSQL 行后返回 + 终态快照,Node 收编进 TaskStore,既有 SSE / 结果 / 轮询路由原样服务。 + AI 叙述腿(Partner SSO / OpenAI 兼容)始终留在 Node:provider 密钥不出本进程。 + ANALYSIS_AUTHORITY=node(默认)保持既有行为,作为回滚开关。 */ "use strict"; +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); const { HttpError, readJson, sendJson, sseStart } = require("../lib/http"); const { resolveIdentity, requireOwner } = require("../lib/identity"); +const { authorityRow } = require("../services/providers"); const MAX_QUESTION = 500; +/* 与 gcode-authority.js / share.js 一致的匿名化上下文:C# 只见哈希后的 tenant/owner。 */ +function opaqueContextId(prefix, value) { + return prefix + crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 32); +} + +/* 小体量 JSON 调用(规则腿数据集在 KB~MB 级),不做流式。 */ +function authorityRequest(cfg, identity, method, pathname, payload) { + const target = new URL(pathname, cfg.gcodeAuthorityUrl); + const transport = target.protocol === "https:" ? https : http; + const body = payload == null ? null : Buffer.from(JSON.stringify(payload), "utf8"); + const headers = { accept: "application/json" }; + if (body) { + headers["content-type"] = "application/json"; + headers["content-length"] = String(body.length); + } + if (cfg.gcodeAuthorityInternalSecret && identity) { + headers["x-forgex-internal-token"] = cfg.gcodeAuthorityInternalSecret; + headers["x-forgex-tenant-id"] = opaqueContextId("tn_", identity.tenantId); + headers["x-forgex-owner-id"] = opaqueContextId("ow_", identity.caller); + } + return new Promise((resolve, reject) => { + const upstream = transport.request(target, { method, headers, timeout: cfg.analysisAuthorityTimeoutMs }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ + status: res.statusCode || 502, + body: Buffer.concat(chunks), + })); + res.on("error", reject); + }); + upstream.on("timeout", () => upstream.destroy(new Error("analysis authority timeout"))); + upstream.on("error", reject); + if (body) upstream.write(body); + upstream.end(); + }); +} + +function parseAuthorityJson(response) { + try { + return JSON.parse(response.body.toString("utf8")); + } catch { + return null; + } +} + function register(router, ctx) { - const { tasks, datasources, knowledge, log, gate, metrics } = ctx; + const { tasks, datasources, knowledge, log, gate, metrics, cfg } = ctx; + const csharp = cfg.analysisAuthority === "csharp"; router.add("POST", /^\/api\/analyze$/, async (req, res, m, rc) => { // 先统一 SSO/API Key 身份,再做限流和资源授权;有效 SSO 不再被 API Key 守卫误拒。 @@ -30,6 +88,64 @@ function register(router, ctx) { // 配额预检:提前把「会不会降级」告诉调用方,而不是等报告出来才发现没有 AI 叙述 const willUseAi = !!(partnerIdentity || tasks.usesAi); const quota = willUseAi && gate ? gate.check(identity.caller) : { ok: true }; + + // Stage 8.3:规则腿迁 C#。AI 腿(含额度耗尽的降级路径)留在 Node—— + // 降级文案与配额语义是 Node 的编排职责,provider 密钥也不出本进程。 + if (csharp && !willUseAi) { + let response; + try { + response = await authorityRequest(cfg, identity, "POST", "/api/v1/analysis-tasks", { + schemaVersion: "1.0", + question, + datasourceId: ds.id, + rows: (ds.rows || []).map(authorityRow), + provenance: null, + }); + } catch (error) { + log.warn("analysis authority create failed", { reqId: rc.reqId, error: error.message }); + throw new HttpError(502, "分析服务暂不可用,请稍后再试"); + } + const parsed = parseAuthorityJson(response); + if (response.status !== 201 || !parsed || !parsed.task || !parsed.task.id) { + log.warn("analysis authority create rejected", { reqId: rc.reqId, status: response.status }); + throw new HttpError(502, "分析服务暂不可用,请稍后再试"); + } + const task = tasks.adopt({ + id: parsed.task.id, + question: parsed.task.question, + datasourceId: parsed.task.datasourceId, + engine: parsed.task.engine, + provider: parsed.task.provider, + credentialScope: identity.tenantId, + status: parsed.task.status, + events: parsed.events, + report: parsed.task.report || null, + error: parsed.task.error || null, + upstreamTaskId: parsed.task.upstreamTaskId || null, + caller: identity.tenantId, + tenantId: opaqueContextId("tn_", identity.tenantId), + ownerId: opaqueContextId("ow_", identity.caller), + createdAt: Date.parse(parsed.task.createdAtUtc) || Date.now(), + finishedAt: Date.parse(parsed.task.finishedAtUtc) || Date.now(), + }); + metrics.tasks++; + log.info("task adopted from csharp authority", { + reqId: rc.reqId, + taskId: task.id, + status: task.status, + datasourceId: ds.id, + rows: ds.rows.length, + }); + sendJson(res, 202, { + taskId: task.id, + engine: task.engine, + authenticated: identity.authenticated, + willUseAi: false, + quota: null, + }); + return; + } + const task = tasks.create(question, ds, rc.reqId, { caller: identity.caller, infiniKey: partnerIdentity ? partnerIdentity.apiKey : "", diff --git a/server/services/analysis.js b/server/services/analysis.js index 78b5e8a..66916bd 100644 --- a/server/services/analysis.js +++ b/server/services/analysis.js @@ -274,6 +274,39 @@ class TaskStore { return this.map.get(String(id || "")) || null; } + /** + * Stage 8.3:接管 C# 权威执行完成的任务快照。 + * C# 已把每个进度事件 UPSERT 进共享 PostgreSQL 行,这里只是把终态快照放进 + * 本进程 map,让既有的 result / 轮询 / SSE 重放路由原样服务——不重复持久化。 + */ + adopt(snapshot) { + const events = Array.isArray(snapshot.events) ? snapshot.events : []; + const report = snapshot.report || null; + const task = Object.assign( + { + engine: "server-rules", + provider: "server-rules", + providerImpl: null, + credentialScope: "global", + upstreamTaskId: null, + cached: !!(report && report.cached), + shared: false, + degraded: false, + error: null, + }, + snapshot, + { + events, + evSeq: events.reduce((max, ev) => Math.max(max, Number(ev.seq) || 0), 0), + report, + subscribers: new Set(), + } + ); + this.map.set(task.id, task); + if ((task.status === "done" || task.status === "failed") && this.onTerminal) this.onTerminal(task); + return task; + } + async ready(owner) { if (!this.persistence || typeof this.persistence.ready !== "function") return; const snapshots = await this.persistence.ready(owner); diff --git a/server/services/providers.js b/server/services/providers.js index 0a9baeb..aa2a465 100644 --- a/server/services/providers.js +++ b/server/services/providers.js @@ -395,4 +395,5 @@ module.exports = { extractJson, SYSTEM_PROMPT, userPrompt, + authorityRow, }; diff --git a/tests/analysis-authority.test.js b/tests/analysis-authority.test.js new file mode 100644 index 0000000..d19b9fb --- /dev/null +++ b/tests/analysis-authority.test.js @@ -0,0 +1,239 @@ +/* Stage 8.3:分析任务规则腿权威切流(ANALYSIS_AUTHORITY=node|csharp)。 + csharp 模式下 Node 保留身份/限流/数据源归属校验,把归一化行转发给 + POST /api/v1/analysis-tasks;收编 C# 终态快照后,既有 result / 轮询 / + SSE 重放路由必须原样服务。node 模式(默认)与授权失败路径同样覆盖。 */ +"use strict"; + +const assert = require("assert"); +const http = require("http"); +const { createApp } = require("../server/index"); + +const INTERNAL_SECRET = "analysis-authority-internal-secret-0123456789"; + +const apps = []; + +function listenServer(server) { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); +} + +async function listenApp(overrides) { + const app = createApp({ + logLevel: "error", + forceMock: true, + rateLimitMs: 0, + probeProvider: false, + dataDir: "", + ...overrides, + }); + apps.push(app); + return `http://127.0.0.1:${await listenServer(app.server)}`; +} + +async function jfetch(base, path, opts) { + const response = await fetch(base + path, opts); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // 非 JSON 响应保留原文供断言 + } + return { status: response.status, json, text }; +} + +function post(base, path, body) { + return jfetch(base, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function collectSse(base, path, timeoutMs) { + return new Promise((resolve, reject) => { + const events = []; + const req = http.get(base + path, (res) => { + if (res.statusCode !== 200) { + reject(new Error("SSE HTTP " + res.statusCode)); + return; + } + let buf = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + buf += chunk; + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const block = buf.slice(0, idx); + buf = buf.slice(idx + 2); + for (const line of block.split("\n")) { + if (!line.startsWith("data: ")) continue; + try { + const ev = JSON.parse(line.slice(6)); + events.push(ev); + if (ev.done) { + req.destroy(); + resolve(events); + return; + } + } catch { + // 心跳注释行 + } + } + } + }); + res.on("end", () => resolve(events)); + }); + req.on("error", reject); + setTimeout(() => { + req.destroy(); + reject(new Error("SSE timeout")); + }, timeoutMs).unref(); + }); +} + +/* 模拟 ForgeX.Api:按 Stage 8.3 契约返回终态快照 + 全量事件。 */ +function authoritySnapshot(request) { + const id = "t_0123456789abcdef"; + const now = Date.now(); + const events = [ + { seq: 1, ts: now, stage: "authority", message: "C# Analytics 权威规则引擎计算中", progress: 0.25 }, + { seq: 2, ts: now, stage: "complete", message: "C# Analytics 权威结果已生成", progress: 1 }, + { seq: 3, ts: now, done: true, progress: 1, message: "分析完成" }, + ]; + const report = { + schemaVersion: 1, + title: "authority", + verdict: "authority", + confidence: "high", + sections: [], + chart: null, + evidence: [], + intent: "overview", + intentMatched: false, + rowCount: request.rows.length, + engine: "server-rules", + provenance: null, + highlight: null, + authorityEngine: { name: "forgex-analytics-csharp", version: "1.3.0" }, + statsBy: "csharp-analytics-authority", + taskId: id, + cached: false, + }; + return { + task: { + id, + question: request.question, + datasourceId: request.datasourceId, + engine: "server-rules", + provider: "server-rules", + status: "done", + progress: 1, + phase: "done", + message: "分析完成", + lastEventSeq: 3, + report, + createdAtUtc: new Date(now - 5).toISOString(), + finishedAtUtc: new Date(now).toISOString(), + expiresAtUtc: new Date(now + 3_600_000).toISOString(), + links: { self: `/api/v1/analysis-tasks/${id}`, events: `/api/v1/analysis-tasks/${id}/events` }, + }, + events, + }; +} + +async function main() { + const observed = []; + const authority = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + observed.push({ url: req.url, headers: req.headers, body }); + if (req.url !== "/api/v1/analysis-tasks") { + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end("{}"); + } + res.writeHead(201, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(authoritySnapshot(JSON.parse(body)))); + }); + }); + const authorityOrigin = `http://127.0.0.1:${await listenServer(authority)}`; + + try { + // ── csharp 模式:规则腿创建与计算走 C#,Node 收编终态快照 ── + const base = await listenApp({ + analysisAuthority: "csharp", + gcodeAuthorityUrl: authorityOrigin, + gcodeAuthorityInternalSecret: INTERNAL_SECRET, + }); + const created = await post(base, "/api/analyze", { question: "哪台机故障率最高", datasourceId: "sample" }); + assert.strictEqual(created.status, 202); + assert.strictEqual(created.json.taskId, "t_0123456789abcdef"); + assert.strictEqual(created.json.engine, "server-rules"); + assert.strictEqual(created.json.willUseAi, false); + assert.strictEqual(created.json.quota, null); + + assert.strictEqual(observed.length, 1); + const upstream = JSON.parse(observed[0].body); + assert.strictEqual(upstream.schemaVersion, "1.0"); + assert.strictEqual(upstream.question, "哪台机故障率最高"); + assert.strictEqual(upstream.datasourceId, "sample"); + assert.ok(Array.isArray(upstream.rows) && upstream.rows.length > 0); + assert.ok(["success", "fail"].includes(upstream.rows[0].status), "rows must use the analytics authority row contract"); + assert.strictEqual(upstream.provenance, null); + assert.strictEqual(observed[0].headers["x-forgex-internal-token"], INTERNAL_SECRET); + assert.match(observed[0].headers["x-forgex-tenant-id"], /^tn_[a-f0-9]{32}$/); + assert.match(observed[0].headers["x-forgex-owner-id"], /^ow_[a-f0-9]{32}$/); + assert.ok(!observed[0].headers.cookie && !observed[0].headers.authorization && !observed[0].headers["x-api-key"]); + + // 既有读取路由必须原样服务 C# 计算的任务 + const result = await jfetch(base, "/api/analyze/" + created.json.taskId + "/result"); + assert.strictEqual(result.status, 200); + assert.strictEqual(result.json.statsBy, "csharp-analytics-authority"); + assert.strictEqual(result.json.taskId, created.json.taskId); + const poll = await jfetch(base, "/api/analyze/" + created.json.taskId); + assert.strictEqual(poll.status, 200); + assert.strictEqual(poll.json.status, "done"); + assert.strictEqual(poll.json.progress, 1); + const replay = await collectSse(base, "/api/analyze/" + created.json.taskId + "/stream", 3000); + assert.strictEqual(replay.length, 3); + assert.strictEqual(replay[0].stage, "authority"); + assert.strictEqual(replay.at(-1).done, true); + + // ── node 模式(默认回滚开关):不触碰 sidecar,本地规则引擎照常执行 ── + const before = observed.length; + const nodeBase = await listenApp({ gcodeAuthorityUrl: authorityOrigin }); + const local = await post(nodeBase, "/api/analyze", { question: "成本趋势", datasourceId: "sample" }); + assert.strictEqual(local.status, 202); + await collectSse(nodeBase, "/api/analyze/" + local.json.taskId + "/stream", 5000); + const localResult = await jfetch(nodeBase, "/api/analyze/" + local.json.taskId + "/result"); + assert.strictEqual(localResult.status, 200); + assert.strictEqual(observed.length, before, "node authority must not call the sidecar"); + + // ── csharp 模式下 sidecar 不可用 → 502,不产生半个任务 ── + const downBase = await listenApp({ + analysisAuthority: "csharp", + gcodeAuthorityUrl: "http://127.0.0.1:9", + }); + const down = await post(downBase, "/api/analyze", { question: "失败归因", datasourceId: "sample" }); + assert.strictEqual(down.status, 502); + + // ── 配置守卫:csharp 依赖 sidecar origin ── + assert.throws( + () => createApp({ analysisAuthority: "csharp", logLevel: "error", dataDir: "" }), + /ANALYSIS_AUTHORITY=csharp/ + ); + + console.log("Analysis authority proxy PASS: 28/28"); + } finally { + for (const app of apps.reverse()) await app.close(); + await new Promise((resolve) => authority.close(resolve)); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tools/security-audit.js b/tools/security-audit.js index f4bacc7..8ab7b49 100644 --- a/tools/security-audit.js +++ b/tools/security-audit.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); +const { collectDotnetPackageViolations } = require("./verify-dotnet-packages"); const root = path.resolve(__dirname, ".."); const policy = JSON.parse(fs.readFileSync(path.join(root, "config", "dependency-policy.json"), "utf8")); @@ -87,11 +88,10 @@ check( actualInstallScripts ); -const projectFiles = files.filter((file) => file.endsWith(".csproj")); -const packageReferences = projectFiles.filter((file) => - fs.readFileSync(path.join(root, file), "utf8").includes("/g)]; + const rawCount = (body.match(/ 0) { + console.error(`NuGet PackageReference 允许清单校验失败:\n${JSON.stringify(violations, null, 2)}`); + process.exit(1); + } + console.log("OK: 所有 NuGet PackageReference 均命中 config/dependency-policy.json 允许清单"); +}