From 4341cb5994ec81dd40df2bb539f7b3055d3315bc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:13:30 +0800 Subject: [PATCH] feat(console): paginate audit events and expose runtime readback Co-Authored-By: Codex --- .github/workflows/validate.yml | 2 + docs/ARCHITECTURE.md | 8 ++ docs/console-api.md | 32 ++++++ tests/audit_pagination_validation.mjs | 45 ++++++++ tests/decision_cockpit_api_validation.mjs | 49 +++++++- tests/runtime_readback_validation.mjs | 79 +++++++++++++ .../console_api/audit_pagination.js | 37 ++++++ .../console_api/runtime_readback.js | 51 +++++++++ .../contracts/console-api.schema.json | 90 +++++++++++++++ web/strategy-switch-console/worker.js | 107 ++++++++++++------ 10 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 tests/audit_pagination_validation.mjs create mode 100644 tests/runtime_readback_validation.mjs create mode 100644 web/strategy-switch-console/application/console_api/audit_pagination.js create mode 100644 web/strategy-switch-console/application/console_api/runtime_readback.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9cf68be0..515845ea 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -236,6 +236,8 @@ jobs: node tests/console_binance_current_holdings_validation.mjs node tests/console_api_worker_validation.mjs node tests/decision_cockpit_api_validation.mjs + node tests/runtime_readback_validation.mjs + node tests/audit_pagination_validation.mjs node tests/console_design_api_validation.mjs node web/strategy-switch-console/frontend/node_modules/typescript/bin/tsc -p tests/tsconfig.console-gaps.json node --experimental-strip-types tests/console_gap_contract_validation.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41aecc02..84106bea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -147,6 +147,14 @@ A Cloudflare Workers-based strategy switch console. The daily pages are the Reac **Dependency boundary:** The web app consumes generated assets (`config.js`, `strategy_profiles_asset.js`) and reads `platform-config.json` indirectly via the Worker API. It does **not** depend on Python scripts at runtime. +The cockpit's audit reader merges database keyset pages with the retained KV +activity log. Its cursor fixes the first page's time ceiling and carries the +last event's time and ID. Runtime readback projects existing lifecycle source +snapshots through an explicit account-to-target binding; it neither queries a +cloud control plane nor dispatches a runtime change. Missing and stale evidence +retain unknown effective state. See [Console API](console-api.md) for pagination, +coverage and error contracts. + --- ## CI/CD: Independent Validation diff --git a/docs/console-api.md b/docs/console-api.md index 4389fa3a..24925ae8 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -20,6 +20,8 @@ draft saves and conflicts. All responses use `Cache-Control: no-store`. | GET `/api/account-settings/history` | `account`, optional `platform`, `limit`, `cursor` | `SettingsHistoryResponse` / `settings_history` | | POST `/api/account-settings/draft` | `CombinedDraftRequest` | `CombinedDraftResponse` / `draft` | | GET `/api/runtime/today` | none | `TodayRunSummary` / `today_runs` | +| GET `/api/runtime/readback` | required `scope=platform:account-alias` | `runtime_readback` | +| GET `/api/audit-events` | optional `limit` (1–100, default 25), opaque `cursor` | `audit_events` | | GET `/api/strategies/summary` | none | `StrategySummary` / `strategy_summary` | | GET `/api/notifications/send-log` | `limit`, `cursor` | `NotificationSendLog` / `notification_log` | | GET `/api/accounts/run-controls` | `account`, optional `platform` | `RunControlAvailability` / `run_controls` | @@ -31,6 +33,36 @@ draft saves and conflicts. All responses use `Cache-Control: no-store`. | GET `/api/performance/trades` | `scope`, `range`, optional `platform` | `TradeStatsResponse` / `trades` | `account` and `scope` are configured account aliases, never broker account numbers. +The runtime readback route uses the explicit `platform:account-alias` pair and +does not accept `all`. It returns only saved deployment observations for the +uniquely bound runtime target. `server_time` is the response clock; +`observed_at` is the producer's deployment observation time. Missing, future, +stale, conflicting or incomplete evidence never yields a known runtime switch. +Stale observations retain their timestamp with `runtime_enabled: null` and +`scheduler_state: unknown`. Missing evidence returns `unavailable`; storage or +source discovery failures return HTTP 503. A missing scope returns 404, invalid +parameters 400, and an ambiguous configured scope 409. The endpoint makes no +external calls, dispatches no operations, and grants no trading authority. + +Audit pages are ordered by observation time descending and event ID ascending. +Pass `next_cursor` unchanged; `null` ends the available traversal. The cursor +contains a versioned position and the first page's time ceiling. Newer events +cannot shift later pages. Refresh starts a new traversal. Numeric offset +cursors from the preliminary API are no longer accepted; clients must restart +from the first page after upgrading. Duplicate or unknown query parameters, +invalid cursors and out-of-range limits return 400. Unreadable sources return +503 rather than an apparently complete empty page. Both routes require an +allowed reader session and reject non-GET methods. + +Audit coverage includes durable decisions, current application records, and the +existing bounded activity log. Durable records are paged at the database; +there is no fixed 1,000-record export ceiling. Application updates and activity +log retention can change what remains available between requests: this is a +read projection, not an immutable historical export. The server preserves the +`id`, `actor_alias`, structured `subject`, nullable string `revision`, +`next_cursor` and `coverage` fields for typed clients. It never includes raw +decision payloads or account configuration. + `scope=all` reuses the overview cohort: it excludes known paper accounts and conflicting or duplicate facts. An unspecified environment remains eligible, as in the existing charts; this does not certify a live identity. Single-account diff --git a/tests/audit_pagination_validation.mjs b/tests/audit_pagination_validation.mjs new file mode 100644 index 00000000..ca4dd5e1 --- /dev/null +++ b/tests/audit_pagination_validation.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import { RuntimeInstances } from "../web/strategy-switch-console/worker.js"; +import { auditPageQuery, auditNextCursor } from "../web/strategy-switch-console/application/console_api/audit_pagination.js"; + +// Exercise the production query against SQLite, including all three durable +// sources and enough rows to cross the old fixed projection ceiling. +const db = new DatabaseSync(":memory:"); +try { + db.exec(`CREATE TABLE human_decision_record (kind, subject_id, material_key, action, actor, decided_at, request_key); + CREATE TABLE research_promotion_applications (ticket_id, status, updated_at, application_id); + CREATE TABLE account_setting_profile_application (platform, account_key, status, approved_by, approved_at, request_key);`); + const at = "2026-11-01T00:00:00.000Z", now = Date.parse("2026-11-01T01:00:00.000Z"); + const insert = db.prepare("INSERT INTO human_decision_record VALUES ('owner', 'demo', '', 'keep_parked', 'demo', ?, ?)"); + for (let index = 0; index < 1005; index++) insert.run(at, `demo-${String(index).padStart(4, "0")}`); + db.prepare("INSERT INTO research_promotion_applications VALUES ('demo', 'recorded', ?, 'demo-promotion')").run(at); + db.prepare("INSERT INTO account_setting_profile_application VALUES ('longbridge', 'demo', 'recorded', 'demo', ?, 'demo-account')").run(at); + const sql = { exec(query, ...parameters) { return { toArray: () => db.prepare(query).all(...parameters) }; } }; + const read = page => RuntimeInstances.prototype.consoleReadCommand.call({ sql }, { + action: "console_audit_events", actor: "demo", limit: page.limit + 1, snapshot: page.snapshot, before: page.before, + }).rows; + let page = auditPageQuery(new URL("https://example.invalid/api/audit-events?limit=100"), now); + const seen = []; + for (let index = 0; index < 20; index++) { + const rows = read(page), current = rows.slice(0, page.limit); + seen.push(...current.map(row => row.event_id)); + if (rows.length <= page.limit) break; + const last = current.at(-1); + const cursor = auditNextCursor({ occurred_at: last.occurred_at, id: last.event_id }, page); + page = auditPageQuery(new URL(`https://example.invalid/api/audit-events?limit=100&cursor=${cursor}`), now); + if (index === 0) insert.run("2026-11-01T02:00:00.000Z", "newer"); + } + assert.equal(seen.length, 1007); + assert.equal(new Set(seen).size, 1007); + assert.deepEqual(seen, [...seen].sort()); + assert.ok(seen.includes("research_promotion_application:demo-promotion")); + assert.ok(seen.includes("account_settings_application:demo-account")); + assert.ok(!seen.includes("durable_human_decision:newer")); + const end = auditPageQuery(new URL("https://example.invalid/api/audit-events?cursor=" + auditNextCursor({ occurred_at: at, id: seen.at(-1) }, page)), now); + assert.deepEqual(read(end), []); + for (const value of ["1", "", "bad!", btoa(JSON.stringify({ v: 9, snapshot: at, at, id: "demo" }))]) { + assert.throws(() => auditPageQuery(new URL(`https://example.invalid/api/audit-events?cursor=${value}`), now), /invalid_pagination/); + } + console.log("Audit pagination validation passed: 1007 durable rows, three sources, ties, concurrent insert, terminal cursor"); +} finally { db.close(); } diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 916c3611..5fcfd203 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -365,7 +365,7 @@ assert.ok(audit.body.events.some((event) => event.source === "durable_human_deci assert.ok(audit.body.events.every((event) => !("login" in event) && "actor_alias" in event)); const auditPage = await call("/api/audit-events?limit=1"); assert.equal(auditPage.body.events.length, 1); -assert.equal(auditPage.body.next_cursor, "1"); +assert.match(auditPage.body.next_cursor, /^[A-Za-z0-9_-]+$/); assert.equal((await call("/api/audit-events?cursor=bad")).status, 400); // Event types are derived from the normalized source for all three classes. assert.equal(__test.auditEventAction("durable_human_decision", "promotion"), "record_promotion_decision"); @@ -570,5 +570,52 @@ const beyondAction = await call(`/api/decisions/${encodeURIComponent(beyondId)}/ assert.equal(beyondAction.status, 409, "the action route reaches revision validation instead of reporting a missing decision"); assert.equal(beyondAction.body.error, "decision_revision_conflict"); +// Audit pages merge the durable and bounded sources, with stable ties and no offset drift. +const kv = await mf.getKVNamespace("STRATEGY_SWITCH_CONFIG"); +const auditTime = new Date(Date.now() - 60_000).toISOString(); +const auditRows = Array.from({ length: 63 }, (_, index) => ({ ts: auditTime, action: `fixture_${index}`, candidate_id: `demo-${index}` })); +await kv.put("audit_log", JSON.stringify(auditRows)); +const expectedAudit = await call("/api/audit-events?limit=100"); +validate("audit_events", expectedAudit.body); +let page = await call("/api/audit-events?limit=7"); +const seenEvents = [...page.body.events]; +assert.ok(page.body.next_cursor); +// A newer event cannot shift the cursor or join an in-progress traversal. +await new Promise(resolve => setTimeout(resolve, 5)); +await kv.put("audit_log", JSON.stringify([{ ts: new Date().toISOString(), action: "newer_event" }, ...auditRows])); +assert.ok((await call("/api/audit-events")).body.events.some(event => event.action === "newer_event")); +let pageCount = 1; +while (page.body.next_cursor) { + assert.ok(pageCount++ < 30, "pagination terminates"); + page = await call(`/api/audit-events?limit=7&cursor=${encodeURIComponent(page.body.next_cursor)}`); + assert.equal(page.status, 200); + validate("audit_events", page.body); + seenEvents.push(...page.body.events); +} +assert.deepEqual(seenEvents.map(event => event.id), expectedAudit.body.events.map(event => event.id)); +assert.equal(new Set(seenEvents.map(event => event.id)).size, seenEvents.length); +for (const query of ["limit=0", "limit=101", "limit=1.5", "limit=", "limit=1&limit=2", "cursor=", "cursor=1", "cursor=bad!", "extra=1", "cursor=a&cursor=b"]) { + assert.equal((await call(`/api/audit-events?${query}`)).status, 400); +} +assert.equal((await call("/api/audit-events", { method: "POST" })).status, 405); +await kv.put("audit_log", "{}"); +assert.equal((await call("/api/audit-events")).status, 503, "invalid optional-source storage cannot look complete"); +await kv.put("audit_log", "[]"); + +// Runtime readback is authenticated, GET-only, and does not dispatch anything. +assert.equal((await call("/api/runtime/readback?scope=longbridge:paper", { cookie: "" })).status, 401); +assert.equal((await call("/api/runtime/readback?scope=longbridge:paper", { method: "POST" })).status, 405); +for (const query of ["", "scope=", "scope=bad", "scope=longbridge:paper&scope=longbridge:paper", "scope=longbridge:paper&extra=1"]) { + assert.equal((await call(`/api/runtime/readback?${query}`)).status, 400); +} +assert.equal((await call("/api/runtime/readback?scope=longbridge:missing")).status, 404); +const missingRuntime = await call("/api/runtime/readback?scope=longbridge:paper"); +assert.equal(missingRuntime.status, 200); +validate("runtime_readback", missingRuntime.body); +assert.equal(missingRuntime.body.data_status, "unavailable"); +assert.equal(missingRuntime.body.runtime_enabled, null); +assert.equal(missingRuntime.body.observed_at, null); +assert.equal(missingRuntime.body.policy.execution_authority_granted, false); + await mf.dispose(); console.log("decision_cockpit_api_validation: PASS"); diff --git a/tests/runtime_readback_validation.mjs b/tests/runtime_readback_validation.mjs new file mode 100644 index 00000000..93b4edac --- /dev/null +++ b/tests/runtime_readback_validation.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import worker from "./helpers/directory_worker.mjs"; +import { __test } from "./helpers/directory_worker.mjs"; +import { runtimeReadbackProjection } from "../web/strategy-switch-console/application/console_api/runtime_readback.js"; + +const now = Date.now(), observedAt = new Date(now - 1000).toISOString(); +const account = { platform: "longbridge", key: "demo", runtime_status_target_id: "demo.target" }; +const entry = { observed_at: observedAt, + target: { target_id: "demo.target", target: { platform: "longbridge" }, deployment: { + observed_at: observedAt, runtime_enabled: false, scheduler_state: "paused", strategy_profile: "demo_strategy", + } }, + freshness: { data_status: "ready" }, deployment_freshness: { data_status: "ready" }, evidence_valid_for_seconds: 60, +}; +const snapshot = { targets: [entry], errors: [] }; +const project = (source = snapshot, accounts = [account]) => runtimeReadbackProjection(account, accounts, source, now); +assert.equal(project().runtime_enabled, false); +assert.equal(project().scheduler_state, "paused"); +assert.equal(project().observed_at, observedAt); +assert.equal(project().data_status, "ready"); +assert.equal(project(snapshot, [account, { ...account, key: "other" }]).reason, "runtime_binding_conflict"); +for (const mutate of [ + item => { item.freshness.data_status = "stale"; }, + item => { item.observed_at = new Date(now + 1000).toISOString(); }, + item => { item.deployment_freshness.data_status = "stale"; }, + item => { item.target.deployment.observed_at = new Date(now - 120_000).toISOString(); }, + item => { delete item.target.deployment.observed_at; }, + item => { item.target.deployment.observed_at = new Date(now + 1000).toISOString(); }, + item => { delete item.target.deployment; }, + item => { item.target.target.platform = "ibkr"; }, + item => { item.freshness.data_status = "unavailable"; }, + item => { delete item.evidence_valid_for_seconds; }, +]) { + const changed = structuredClone(entry); mutate(changed); + const result = project({ targets: [changed], errors: [] }); + assert.notEqual(result.data_status, "ready"); + assert.equal(result.runtime_enabled, null); + assert.equal(result.scheduler_state, "unknown"); +} +assert.equal(project({ targets: [], errors: [] }).reason, "runtime_observation_missing"); +assert.equal(project({ targets: [entry, entry], errors: [] }).reason, "runtime_binding_conflict"); + +// Exercise the real handler with an in-memory source and no outbound service. +const source = { + schema_version: "qsl_runtime_target_lifecycle_source_snapshot.v1", source_id: "demo", + generated_at: observedAt, computed_at: observedAt, data_status: "ready", errors: [], + targets: [{ target_id: "demo.target", target: { platform: "longbridge", configured_state: "disabled", execution_mode: "paper" }, + monitoring: { runtime_guard: "pass", execution_heartbeat: "not_applicable" }, + disposition: { code: "continue_disabled_validation", reason_code: "target_intentionally_disabled" }, + no_order: true, deployment: { ...entry.target.deployment, execution_mode: "paper" } }], +}; +const env = { + SESSION_SECRET: "synthetic-session-only", ALLOWED_GITHUB_LOGINS: "demo-reader", + STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({ longbridge: [{ key: "demo", label: "Demo", target_name: "demo", runtime_status_target_id: "demo.target", account_selector: "DEMO", deployment_selector: "demo", account_scope: "DEMO", service_name: "demo-service", supported_domains: ["us_equity"], broker_environment: "paper", default_execution_mode: "paper" }] }), + STRATEGY_SWITCH_CONFIG: { get: async key => key.startsWith("runtime_target_lifecycle_source:") ? JSON.stringify(source) : null, put: async () => { throw Error("write attempted"); }, list: async () => ({ keys: [{ name: "runtime_target_lifecycle_source:demo" }] }) }, +}; +const cookie = `qsl_switch_session=${await __test.makeSession("demo-reader", [], env)}`; +const request = () => new Request("https://example.invalid/api/runtime/readback?scope=longbridge:demo", { headers: { Cookie: cookie } }); +const originalFetch = globalThis.fetch; +globalThis.fetch = async () => { throw Error("outbound attempted"); }; +try { + const sourceCheck = await __test.aggregateRuntimeTargetLifecycleSources(env); + assert.deepEqual(sourceCheck.errors, [], "fixture source must validate"); + let response = await worker.fetch(request(), env, {}); + let body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.data_status, "ready"); + assert.equal(body.runtime_enabled, false); + assert.equal(body.observed_at, observedAt); + assert.equal(JSON.stringify(body).includes("DEMO"), false); + source.targets[0].deployment.observed_at = new Date(now - 30 * 86_400_000).toISOString(); + response = await worker.fetch(request(), env, {}); body = await response.json(); + assert.equal(body.data_status, "stale"); + assert.equal(body.runtime_enabled, null); + env.STRATEGY_SWITCH_CONFIG.get = async key => { if (key.startsWith("runtime_target_lifecycle_source:")) throw Error("read failure"); return null; }; + assert.equal((await worker.fetch(request(), env, {})).status, 503); + env.STRATEGY_SWITCH_CONFIG.list = async () => { throw Error("list failure"); }; + assert.equal((await worker.fetch(request(), env, {})).status, 503); +} finally { globalThis.fetch = originalFetch; } +console.log("Runtime readback validation passed"); diff --git a/web/strategy-switch-console/application/console_api/audit_pagination.js b/web/strategy-switch-console/application/console_api/audit_pagination.js new file mode 100644 index 00000000..b6dc81e0 --- /dev/null +++ b/web/strategy-switch-console/application/console_api/audit_pagination.js @@ -0,0 +1,37 @@ +const instant = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; +const encode = value => btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +// The cursor is a position, never authority. All pages require the same session checks. +export function auditPageQuery(url, now = Date.now()) { + for (const key of url.searchParams.keys()) { + if (!["limit", "cursor"].includes(key) || url.searchParams.getAll(key).length !== 1) throw Error("invalid_pagination"); + } + const raw = url.searchParams.get("limit"); + if (raw !== null && !/^[1-9]\d{0,2}$/.test(raw)) throw Error("invalid_pagination"); + const limit = raw === null ? 25 : Number(raw); + if (limit > 100) throw Error("invalid_pagination"); + const cursor = url.searchParams.get("cursor"); + if (cursor === null) return { limit, snapshot: new Date(now).toISOString(), before: null }; + try { + if (!/^[A-Za-z0-9_-]{1,2048}$/.test(cursor)) throw Error(); + const value = JSON.parse(atob(cursor.replace(/-/g, "+").replace(/_/g, "/"))); + if (Object.keys(value).sort().join() !== "at,id,snapshot,v" || value.v !== 1 + || !instant(value.at) || !instant(value.snapshot) || value.at > value.snapshot + || Date.parse(value.snapshot) > now || typeof value.id !== "string" + || !/^[\x20-\x7e]{1,512}$/.test(value.id) || encode(value) !== cursor) throw Error(); + return { limit, snapshot: value.snapshot, before: { at: value.at, id: value.id } }; + } catch { throw Error("invalid_pagination"); } +} + +export function auditEventInPage(event, page) { + return event.occurred_at <= page.snapshot && (!page.before || event.occurred_at < page.before.at + || event.occurred_at === page.before.at && event.id > page.before.id); +} +export function auditEventOrder(left, right) { + return left.occurred_at === right.occurred_at ? (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) + : left.occurred_at > right.occurred_at ? -1 : 1; +} +export function auditNextCursor(event, page) { + return encode({ v: 1, snapshot: page.snapshot, at: event.occurred_at, id: event.id }); +} diff --git a/web/strategy-switch-console/application/console_api/runtime_readback.js b/web/strategy-switch-console/application/console_api/runtime_readback.js new file mode 100644 index 00000000..b36d160b --- /dev/null +++ b/web/strategy-switch-console/application/console_api/runtime_readback.js @@ -0,0 +1,51 @@ +// A read-only projection of producer observations. Configuration is not evidence. +export function runtimeReadbackProjection(account, accounts, snapshot, now = Date.now()) { + const result = { + schema_version: "qsl.runtime_readback.v1", + server_time: new Date(now).toISOString(), + scope: `${account.platform}:${account.key}`, + data_status: "unavailable", + observed_at: null, + runtime_enabled: null, + scheduler_state: "unknown", + strategy_profile: null, + source: "stored_runtime_target_lifecycle", + reason: "runtime_binding_missing", + policy: { read_only: true, no_order: true, execution_authority_granted: false }, + }; + const targetId = account.runtime_status_target_id; + if (!targetId) return result; + const bindings = accounts.filter(row => row.runtime_status_target_id === targetId); + if (bindings.length !== 1 || snapshot.errors?.includes("runtime_target_lifecycle_duplicate_target")) { + return { ...result, reason: "runtime_binding_conflict" }; + } + const matches = snapshot.targets.filter(entry => entry.target.target_id === targetId); + if (matches.length !== 1) return { ...result, reason: matches.length ? "runtime_binding_conflict" : "runtime_observation_missing" }; + if (snapshot.errors?.some(code => code !== "runtime_target_lifecycle_source_stale")) return { ...result, reason: "runtime_source_incomplete" }; + const entry = matches[0]; + if (entry.target.target.platform !== account.platform) return { ...result, reason: "runtime_binding_conflict" }; + const deployment = entry.target.deployment; + if (!deployment) return { ...result, reason: "runtime_deployment_missing" }; + const sourceTime = Date.parse(entry.observed_at); + if (!Number.isFinite(sourceTime) || sourceTime > now) return { ...result, reason: "runtime_observation_time_invalid" }; + const observed = Date.parse(deployment.observed_at); + // Require the producer's deployment timestamp, not the HTTP response clock or source fallback. + if (!Number.isFinite(observed) || observed > now) return { ...result, reason: "runtime_observation_time_invalid" }; + result.observed_at = deployment.observed_at; + if (entry.freshness?.data_status === "stale" || entry.deployment_freshness?.data_status === "stale" + || now - observed > entry.evidence_valid_for_seconds * 1000) { + return { ...result, data_status: "stale", reason: "runtime_observation_stale" }; + } + if (entry.freshness?.data_status !== "ready" || entry.deployment_freshness?.data_status !== "ready" + || !Number.isFinite(entry.evidence_valid_for_seconds) || entry.evidence_valid_for_seconds <= 0) { + return { ...result, reason: "runtime_observation_unavailable" }; + } + if (typeof deployment.runtime_enabled !== "boolean" + || !["enabled", "paused", "mixed", "missing", "unknown", "not_applicable"].includes(deployment.scheduler_state)) { + return { ...result, reason: "runtime_observation_incomplete" }; + } + return { ...result, data_status: "ready", reason: null, + runtime_enabled: deployment.runtime_enabled, + scheduler_state: deployment.scheduler_state, + strategy_profile: deployment.strategy_profile ?? null }; +} diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index 7fa1af33..7e73bbf5 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -4595,6 +4595,96 @@ "no_order", "execution_authority_granted" ] + }, + "runtime_readback": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.runtime_readback.v1" + }, + "server_time": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "data_status": { + "enum": [ + "ready", + "stale", + "unavailable" + ] + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "runtime_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "scheduler_state": { + "enum": [ + "enabled", + "paused", + "mixed", + "missing", + "unknown", + "not_applicable" + ] + }, + "strategy_profile": { + "type": [ + "string", + "null" + ] + }, + "source": { + "const": "stored_runtime_target_lifecycle" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "policy": { + "type": "object", + "properties": { + "read_only": { + "const": true + }, + "no_order": { + "const": true + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "read_only", + "no_order", + "execution_authority_granted" + ] + } + }, + "required": [ + "schema_version", + "server_time", + "scope", + "data_status", + "observed_at", + "runtime_enabled", + "scheduler_state", + "strategy_profile", + "source", + "reason", + "policy" + ] } } } diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 7f8f67fe..8c00c8cf 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -1,4 +1,6 @@ import { binanceDetailCommand } from "./binance_detail_archive.js"; +import { auditPageQuery, auditEventInPage, auditEventOrder, auditNextCursor } from "./application/console_api/audit_pagination.js"; +import { runtimeReadbackProjection } from "./application/console_api/runtime_readback.js"; import { initialEnrollmentBindingsMatch, initialEnrollmentDigest } from "./application/console_api/initial_enrollment.js"; import { readNotificationLog } from "./application/console_api/notification_receipts.js"; import { readRuntimeControlReceipt } from "./application/console_api/runtime_control_receipts.js"; @@ -192,7 +194,7 @@ const DECISION_ACTION_SCHEMA_VERSION = "qsl.decision_action_result.v1"; const OPERATION_SCHEMA_VERSION = "qsl.operation.v1"; const AUDIT_EVENTS_SCHEMA_VERSION = "qsl.audit_events.v1"; const DECISION_ACTION_MAX_BODY_BYTES = 8 * 1024; -const AUDIT_EVENTS_MAX_ROWS = 1000; +const AUDIT_EVENTS_MAX_ROWS = 101; const AUDIT_LOG_KEY = "audit_log"; const AUDIT_LOG_LIMIT = 50; const CURRENT_STRATEGIES_TIMEOUT_MS = 25000; @@ -953,6 +955,10 @@ export default { if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); return await operationResponse(request, env, url.pathname.slice("/api/operations/".length)); } + if (url.pathname === "/api/runtime/readback") { + if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); + return await runtimeReadbackResponse(request, env, url); + } if (url.pathname === "/api/audit-events") { if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); return await auditEventsResponse(request, env, url); @@ -4670,18 +4676,26 @@ export class RuntimeInstances { if (command.action === "console_audit_events") { const limit = command.limit; if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUDIT_EVENTS_MAX_ROWS) throw new HttpError("invalid_pagination", 400); + const { snapshot, before } = command; + // Normalize time once, and use the same binary id tie-break as the HTTP merge. return { rows: this.sql.exec(` - SELECT 'decision' AS source, kind AS kind, subject_id AS subject_id, material_key AS revision, - action AS action, actor AS actor, decided_at AS occurred_at, request_key AS ref, 'decided' AS status - FROM human_decision_record - UNION ALL - SELECT 'promotion_application', 'promotion', ticket_id, '', status, '', updated_at, application_id, status - FROM research_promotion_applications - UNION ALL - SELECT 'account_settings_application', 'account_settings', platform || ':' || account_key, '', - status, approved_by, approved_at, request_key, status - FROM account_setting_profile_application - ORDER BY occurred_at DESC, ref DESC LIMIT ?`, limit).toArray() }; + WITH events AS ( + SELECT 'decision' AS source, kind, subject_id, material_key AS revision, + action, actor, strftime('%Y-%m-%dT%H:%M:%fZ', decided_at) AS occurred_at, + request_key AS ref, 'decided' AS status, 'durable_human_decision:' || request_key AS event_id + FROM human_decision_record + UNION ALL + SELECT 'promotion_application', 'promotion', ticket_id, '', status, '', + strftime('%Y-%m-%dT%H:%M:%fZ', updated_at), application_id, status, + 'research_promotion_application:' || application_id FROM research_promotion_applications + UNION ALL + SELECT 'account_settings_application', 'account_settings', platform || ':' || account_key, '', + status, approved_by, strftime('%Y-%m-%dT%H:%M:%fZ', approved_at), request_key, status, + 'account_settings_application:' || request_key FROM account_setting_profile_application + ) SELECT * FROM events WHERE occurred_at <= ? + AND (? IS NULL OR occurred_at < ? OR (occurred_at = ? AND event_id > ?)) + ORDER BY occurred_at DESC, event_id ASC LIMIT ?`, snapshot, + before?.at ?? null, before?.at ?? null, before?.at ?? null, before?.id ?? null, limit).toArray() }; } if (command.action === "console_decision_history") { const { limit, offset } = command; @@ -10247,6 +10261,29 @@ async function syncRuntimeTargetLifecycleSourceResponse(request, env) { }); } +async function runtimeReadbackResponse(request, env, url) { + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); + if ([...url.searchParams.keys()].some(key => key !== "scope") || url.searchParams.getAll("scope").length !== 1 + || !/^[a-z0-9_-]+:[a-zA-Z0-9_.-]+$/.test(url.searchParams.get("scope") || "")) + return json({ ok: false, error: "invalid_runtime_scope" }, 400); + const scope = url.searchParams.get("scope"); + try { + const config = await loadAccountOptionsConfig(env); + const accounts = Object.entries(config.options || {}).flatMap(([platform, rows]) => rows.map(row => ({ ...row, platform }))); + const matches = accounts.filter(row => `${row.platform}:${row.key}` === scope); + if (!matches.length) return json({ ok: false, error: "runtime_scope_not_found" }, 404); + if (matches.length !== 1) return json({ ok: false, error: "runtime_binding_conflict" }, 409); + if (!hasConfigStore(env)) return json({ ok: false, error: "runtime_readback_unavailable" }, 503); + const snapshot = await aggregateRuntimeTargetLifecycleSources(env); + if (snapshot.errors?.some(code => /source_list_unavailable|source_invalid/.test(code))) + return json({ ok: false, error: "runtime_readback_failed" }, 503); + return json(runtimeReadbackProjection(matches[0], accounts, snapshot)); + } catch { + return json({ ok: false, error: "runtime_readback_failed" }, 503); + } +} + async function runtimeTargetLifecycleResponse(request, env) { const session = await readSession(request, env); if (!session?.allowed) return json({ ok: false, error: "login required" }, 401); @@ -13389,7 +13426,7 @@ async function auditEventsResponse(request, env, url) { if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); let page; try { - page = pageQuery(url); + page = auditPageQuery(url); } catch { return json({ ok: false, error: "invalid_pagination" }, 400); } @@ -13400,9 +13437,10 @@ async function auditEventsResponse(request, env, url) { let rows; try { const result = await runtimeInstanceCommand(env, { - action: "console_audit_events", actor: session.login, limit: AUDIT_EVENTS_MAX_ROWS, + action: "console_audit_events", actor: session.login, limit: page.limit + 1, snapshot: page.snapshot, before: page.before, }); - rows = Array.isArray(result.rows) ? result.rows : []; + if (!Array.isArray(result.rows)) throw Error("invalid_audit_rows"); + rows = result.rows; } catch { return json({ ok: false, error: "audit_projection_unavailable", server_time: serverTime }, 503); } @@ -13410,18 +13448,18 @@ async function auditEventsResponse(request, env, url) { for (const row of rows) { const occurredAt = typeof row.occurred_at === "string" && Number.isFinite(Date.parse(row.occurred_at)) ? row.occurred_at : null; - if (!occurredAt) continue; + if (!occurredAt) return json({ ok: false, error: "audit_projection_invalid" }, 503); const source = row.source === "decision" ? "durable_human_decision" : row.source === "promotion_application" ? "research_promotion_application" : row.source === "account_settings_application" ? "account_settings_application" : null; - if (!source) continue; + if (!source) return json({ ok: false, error: "audit_projection_invalid" }, 503); const subject = source === "durable_human_decision" ? { kind: `${row.kind}_decision`, id: row.subject_id } : source === "research_promotion_application" ? { kind: "strategy_promotion", id: row.subject_id } : { kind: "account_settings", id: row.subject_id }; - if (!subject.id || subject.id.length > 256) continue; + if (typeof subject.id !== "string" || !subject.id || subject.id.length > 256) return json({ ok: false, error: "audit_projection_invalid" }, 503); const revision = /^[0-9a-f]{64}$/.test(row.revision || "") ? row.revision : null; events.push({ id: `${source}:${row.ref}`, @@ -13439,22 +13477,27 @@ async function auditEventsResponse(request, env, url) { : null, }); } - let dataStatus = "ready"; - const errors = []; try { - const log = await loadAuditLog(env); + if (!hasConfigStore(env)) throw Error("audit_log_unavailable"); + const storedLog = await readConfigJson(env, AUDIT_LOG_KEY); + if (storedLog !== null && !Array.isArray(storedLog)) throw Error("audit_log_invalid"); + const log = storedLog || []; + const ids = await Promise.all(log.map(row => cycleHealthSha256(row))); + const occurrences = new Map(); const aliases = await Promise.all(log.map((row) => actorAlias(typeof row?.login === "string" ? row.login : null))); log.forEach((row, index) => { - if (!row || typeof row !== "object") return; + if (!row || typeof row !== "object" || Array.isArray(row)) throw Error("audit_log_invalid"); const occurredAt = typeof row.ts === "string" && Number.isFinite(Date.parse(row.ts)) ? row.ts : null; const action = typeof row.action === "string" && row.action.length <= 64 ? row.action : null; - if (!occurredAt || !action) return; + if (!occurredAt || !action) throw Error("audit_log_invalid"); const subject = auditEventSubjectId(row); + const occurrence = occurrences.get(ids[index]) || 0; + occurrences.set(ids[index], occurrence + 1); events.push({ - id: `bounded_audit_log:${occurredAt}:${action}:${subject?.id || index}`, + id: `bounded_audit_log:${ids[index]}:${occurrence}`, source: "bounded_audit_log", - occurred_at: occurredAt, + occurred_at: new Date(occurredAt).toISOString(), action, actor_alias: aliases[index] || null, subject, @@ -13464,8 +13507,7 @@ async function auditEventsResponse(request, env, url) { }); }); } catch { - dataStatus = "partial"; - errors.push("audit_log_unavailable"); + return json({ ok: false, error: "audit_log_unavailable", server_time: serverTime }, 503); } const seen = new Set(); const unique = events @@ -13474,17 +13516,18 @@ async function auditEventsResponse(request, env, url) { seen.add(event.id); return true; }) - .sort((left, right) => right.occurred_at.localeCompare(left.occurred_at) || left.id.localeCompare(right.id)); - const pageEvents = unique.slice(page.offset, page.offset + page.limit); + .filter(event => auditEventInPage(event, page)) + .sort(auditEventOrder); + const pageEvents = unique.slice(0, page.limit); return json({ schema_version: AUDIT_EVENTS_SCHEMA_VERSION, server_time: serverTime, - data_status: dataStatus, + data_status: "ready", events: pageEvents, - next_cursor: unique.length > page.offset + page.limit ? String(page.offset + page.limit) : null, + next_cursor: unique.length > page.limit ? auditNextCursor(pageEvents.at(-1), page) : null, coverage: "durable_human_decisions_and_application_records_and_bounded_audit_log", policy: DECISION_POLICY, - errors, + errors: [], }); }