diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index 86acefd84..6c9b90a63 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -227,19 +227,70 @@ contract for automation merely because the caller is non-browser TypeScript. ### Compact automation heartbeat -`cache.getHeartbeat` is a versioned greenfield query under the existing `cache:read` automation -scope. It embeds the same at-most-128-row, payload-free cache status used by `cache.getStatus`, then -adds only the process-owned Gateway phase/freshness and identity-free summaries of the latest -validated current-session and global OpenClaw-cron projections. Session keys, display names, cron +`cache.getHeartbeat` schema v4 is a dedicated declassification query under the existing +`cache:read` automation scope. It embeds the same at-most-128-row, payload-free cache status used +by `cache.getStatus`, then adds process-owned Gateway freshness, bounded task and Dashboard-job +state, and identity-free OpenClaw-cron health. Session keys, display names, cron identifiers and names, payloads, credentials, endpoints, and raw errors never cross this boundary. -The heartbeat does not issue a Gateway RPC. Before a bounded projection has been observed it says -`unavailable`; after a failed refresh or Gateway disconnect it retains the count as explicitly -`last-known-good`. Session truncation remains visible. Cron pending synchronization is `unknown` -when the cached global page cannot prove absence and `present` when any unsettled desired state is -known. This modern schema does not reproduce legacy schema-v3 task rows, Dashboard-job rows, or -payload-bearing cache envelopes, so the reviewed legacy endpoint remains planned until those -remaining consumers are deliberately migrated or removed. +Each heartbeat owns a fixed, fresh-only OpenClaw-cron inventory refresh instead of depending on +unrelated browser list traffic. The process single-flights refreshes, enforces an eight-second +aggregate deadline, admits one successful snapshot for 60 seconds, and applies a ten-second retry +gate after failure. Up to 1000 rows and 32 MiB of cumulative authenticated response-frame bytes are +admitted as one atomic candidate. The transport records exact encoded frame bytes before the +provider strips unknown fields; one already-received protocol-bounded page may cross the cumulative +limit, after which the walk stops without retry. Pages are fetched sequentially; each page must +share snapshot revision and total, advance exact offsets, and contain globally unique identifiers. +Each full row is immediately reduced to the small heartbeat-only projection, so payload and +schedule text do not accumulate across pages. Revision races receive one bounded retry. Only a +complete coherent candidate replaces state; failure retains the previous aggregate as +`last-known-good`, and truncation remains explicit. + +The global cron summary includes inspected/enabled/disabled/running/failing counts plus +intentional versus unexpected disablement, separate synchronization conflict/pending counts, and +potentially stuck runs. +For each automation-linked task, the internal cron identifier is used only for process-local +correlation. The response reports `present` runtime/synchronization health, `missing` only when a +complete fresh inventory proves absence, or `unavailable` when freshness/truncation cannot support +that conclusion. Task candidates are read in a short SQLite transaction that closes before any +Gateway I/O, then the same immutable snapshot is allowlist-projected. + +The task projection still returns at most 100 UUID-keyed open rows selected by the exact legacy +operational predicate, without task content, assignee identity, or cron identity. Dashboard jobs +enumerate every bounded code-owned definition and compact lifecycle state. Each local reader fails +independently to explicit `unavailable`, and cross-object validation prevents stale or truncated +cron state from asserting an unjustified missing task automation. + +This schema v4 summary is not declared a replacement for legacy REST heartbeat schema v3. The +legacy endpoint also exposes payload-bearing cache diagnostics and identifiable task, Dashboard-job, +and per-cron rows. Its parity entry remains `planned` until those diagnostic capabilities and the +repo-external OpenClaw consumer migration are preserved without loss; production's live consumer +must not change before that cutover gate is satisfied. + +### Authenticated health diagnostics + +`system.healthDiagnostics` is the session-only replacement for the legacy detailed health route. +It has strict empty input and no automation capability. One request reads the live application +readiness controller, verified frontend/release composition facts, the sanitized process Gateway +state, the identity-free cached Gateway-session count, and one deferred-transaction SQLite health +aggregate. The release commit is used only inside the service to require a fresh online worker from +the exact serving release; release IDs, worker IDs/PIDs, session identities, Gateway endpoints, +payloads, and raw failures never serialize. + +Application, database, frontend, verified release, and exact-release worker checks gate the +diagnostic aggregate. Gateway state, cached-session freshness, queue depth, and claim pause remain +non-gating operational data and do not alter the public readiness probe. Queue and dependency +failures become explicit `unavailable` components rather than healthy-looking zeroes or a failed +whole response. The queue reader counts only indexed active states and aggregates every fresh +worker in constant-size SQL output, independently of the bounded worker inventory used by the Jobs +UI. The authenticated header consumes this one snapshot instead of polling raw readiness, Gateway, +and Jobs separately. A failed background refresh retains the last validated snapshot but marks every +previously healthy component and the aggregate as stale; it can never leave an old green status +looking current. + +This secure replacement closes the legacy health row's readiness/dependency capability. The old +route's wider application-observability counters remain tracked by the separate planned +`GET /api/metrics` row; `system.metrics` alone does not claim that broader parity. ### Browser-managed automation security diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index 21d9d556f..0459427ba 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -282,10 +282,18 @@ restarts, or unbounded shell commands. Those operations become durable `job_runs the worker. The `cache:read` automation heartbeat is a separate sanitized projection, not a shortcut around -session or cron detail authorization. It reads only process-local validated summaries and bounded -payload-free cache status, performs no upstream refresh, and discloses no session/cron identity, -payload, credential, endpoint, or raw failure. Missing and last-known-good projection states remain -explicit so an empty count is never inferred from unavailable upstream state. +session, task, job, or cron detail authorization. It reads process-local validated Gateway +summaries plus bounded payload-free cache status and purpose-built SQLite task/Dashboard-job +projections, and requires neither `tasks:read` nor `jobs:read`. Its only upstream work is a +fixed read-only OpenClaw-cron inventory refresh with an aggregate deadline, atomic snapshot checks, +single-flight ownership, success TTL, and failure backoff. +Task content, assignee and cron identity, schedule metadata, payloads, results, events, actors, +workers, leases, credentials, endpoints, disable reasons, terminal messages, and raw failures do +not cross the boundary. Exact task count/truncation and the canonical row prefix share one short +read transaction that closes before Gateway I/O. Each local projection is structurally and semantically validated inside its own +safe reader boundary, so failure degrades only that projection to `unavailable`. Missing and +last-known-good Gateway states remain explicit so an empty count is never inferred from +unavailable upstream state. Queue behavior is explicit: diff --git a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index eece952b1..18914f622 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -77,8 +77,10 @@ Gateway client, chat, production credential cutover, and complete rewrite remain audio/text are strictly bounded, abortable, no-store, and never persisted or logged. - expose a versioned compact automation heartbeat from process-owned state: bounded payload-free cache status, sanitized Gateway phase/freshness, identity-free current-session count/truncation, - and global OpenClaw-cron count/pending-sync state. It must not perform an extra upstream refresh, - expose raw errors or identities, or claim legacy schema-v3 task/job-row parity. + and global OpenClaw-cron count/pending-sync state. It must own a bounded, fresh-only cron + inventory refresh rather than infer health from unrelated browser traffic, fetch pages + sequentially under explicit row/byte/deadline budgets, immediately retain only heartbeat fields, + never expose raw errors or identities, and not claim legacy schema-v3 task/job-row parity. **Exit gate:** recorded Gateway fixtures and live smoke tests cover every chat parity item, including restart during streaming. diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 61e76885f..9f6040142 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | -| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server/browser parity are implemented. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, its cache browser, and bounded system metrics are implemented. Root composition covers every implemented Phase 3 operator domain, and Phase 4A now supplies the OpenClaw-cron half of `/jobs`. Full root parity and privileged/external providers remain later gates, so the Phase 3 exit stays open. | -| 4 — Gateway and chat | Started | The current installed OpenClaw source is hash-pinned for the persistent sessions, cron, chat, companion, task, and media surfaces. Process-owned Gateway lifecycle, durable realtime invalidation, sessions and agent availability, OpenClaw cron/tasks, the compact heartbeat, the durable chat journal/runtime, bounded history and reconciliation, attachments/media proxy, and the `/chat` frontend are implemented. Recorded contract, protocol, service, browser, restart, load-boundary, and security tests cover the slice; live Gateway smoke/restart evidence and the Phase 4 exit gate remain open. | -| 5 — Privileged and external domains | Started | Files has closed reviewed `/files` parity with named-root browsing, ticketed raw transfer, bounded spooling, CAS writes, worker execution, and browser workflows. Logs has redacted named-source reads, durable active/latest-terminal maintenance status, worker-owned managed dry runs, separate custom app/container rotation, fixed host-logrotate policies, and closed reviewed `/logs` parity. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. Docker control, database, Moltbook, remaining settings, GitHub, deployment, backup, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | +| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server/browser parity are implemented. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, its cache browser, and bounded system metrics are implemented. Root composition covers every implemented Phase 3 operator domain, and Phase 4A now supplies the OpenClaw-cron half of `/jobs`. Full root parity and privileged/external providers remain later gates, so the Phase 3 exit stays open. | +| 4 — Gateway and chat | Started | The current installed OpenClaw source is hash-pinned for the persistent sessions, cron, chat, companion, task, and media surfaces. Process-owned Gateway lifecycle, durable realtime invalidation, sessions and agent availability, OpenClaw cron/tasks, the compact heartbeat, the durable chat journal/runtime, bounded history and reconciliation, attachments/media proxy, and the `/chat` frontend are implemented. Recorded contract, protocol, service, browser, restart, load-boundary, and security tests cover the slice; live Gateway smoke/restart evidence and the Phase 4 exit gate remain open. | +| 5 — Privileged and external domains | Started | Files and Logs have closed reviewed `/files` and `/logs` parity. Moltbook now has a fixed-host worker-only provider, one claim-fenced durable last-known-good snapshot, four session-only read procedures, and the reviewed `/moltbook` browser workflow. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. Docker control, database, remaining settings, GitHub, deployment, backup, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -1359,3 +1359,97 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o Replacement-only OpenClaw targets must already exist at cutover, because neither the web nor worker Files boundary may create a missing manifest file. The separate OpenClaw media inventory at `GET /api/media` remains planned, as do the remaining Phase 5 domains and aggregate exit gate. + +### 2026-08-11 — Phase 5 Moltbook parity closed + +- The worker is the only process that receives the redacted `MOLTBOOK_API_KEY`. It performs four + concurrent requests against the fixed `https://www.moltbook.com/api/v1` origin with redirects + forbidden, bounded streamed JSON bodies, a caller-composed timeout, and strict normalization + that discards provider fields not rendered by the Dashboard. +- One immediate 30-minute durable job commits the home, hot/new feeds, profile, posts, and comments + as one claim-fenced `moltbook.dashboard` snapshot. Failed attempts preserve the prior aggregate + and persist only a fixed operator-safe failure; readers expose stale last-known-good state + explicitly without exposing credentials, response bodies, or raw provider failures. +- The session-only `moltbook.home`, `moltbook.feed`, `moltbook.profile`, and + `moltbook.listMyPosts` procedures require `cache:read`. The `/moltbook` browser retains the + reviewed Feed/Posts/Comments and Hot/New workflows, loading/error/retry and empty states, and + uses only encoded fixed-origin external links. The four legacy endpoint rows and reviewed route + are now recorded as implemented. The aggregate Phase 5 exit remains open. +- The production route and its validation contracts remain lazy. Against the parent Files slice, + Moltbook adds 2,202 gzip bytes to the initial graph and 8,485 gzip bytes across all JavaScript; + the enforced limits advance by only 3 KiB and 10 KiB respectively, while the largest-chunk and + stylesheet limits remain unchanged. + +### 2026-08-11 — Jobs and OpenClaw cron parity closed + +- The reviewed `/jobs` route and all fourteen legacy Jobs/Cron mappings are now recorded as + implemented. The existing typed `jobs.*`, `schedules.*`, and `openClawCron.*` procedures are the + parity replacement for Dashboard schedules, durable execution state, and OpenClaw cron inventory + and controls; this evidence-only closure adds no runtime behavior. +- The replacement intentionally does not restore the legacy arbitrary JSON round-trip. Privileged + command and script bodies remain redacted and non-editable, delivery destinations remain + write-only, and OpenClaw cron updates accept only the reviewed typed fields. This secure narrowing + is the accepted parity behavior rather than an open route gap. + +### 2026-08-11 — Realtime transport parity closed + +- The legacy browser live-update row `WebSocket /ws` is now recorded as implemented by the existing + `events.stream` procedure. This evidence-only closure adds no runtime, browser, configuration, or + generated-contract behavior. +- Behavioral parity intentionally narrows the transport: live updates use one-way typed tracked SSE, + while queries and actions use typed tRPC procedures. Arbitrary browser-to-Gateway WebSocket method + forwarding is not restored. +- The existing stream enforces topic authorization, renewable and revocable authorization leases, + bounded buffering, durable replay, and schema validation. + +### 2026-08-11 — Heartbeat operational summary advanced to schema v4 + +- `cache.getHeartbeat` schema v4 retains bounded payload-free cache, Gateway, task, and + Dashboard-job projections and adds an owned fresh-only OpenClaw-cron inventory refresh. The + refresh is process-single-flighted, has an eight-second aggregate deadline, 60-second success + TTL, ten-second failure retry gate, one bounded revision-race retry, and a 1000-row inspection + ceiling. Pages are read sequentially under a 32 MiB cumulative authenticated response-frame + admission budget and reduced immediately to heartbeat-only fields. Exact encoded bytes are + captured before provider projection can strip unknown fields; one bounded overflow page may + arrive, then the walk stops without retry. Public browser list reads no longer mutate heartbeat + state. +- All cron pages must have one snapshot revision and total, exact offsets, complete page lengths, + and globally unique IDs before anything commits. A failed, raced, duplicate, incomplete, or + timed-out candidate preserves the whole previous projection as last-known-good. Mutations + invalidate the success TTL immediately; ordered server shutdown aborts and awaits any owned + refresh. +- Identity-free aggregate health now distinguishes enabled/disabled, intentional/unexpected + disablement, running/potentially stuck, last-run failures, synchronization conflicts versus + pending reconciliation, and truncation. Automation-linked tasks receive `present`, `missing`, or + `unavailable` cron health; + `missing` requires fresh complete authority. The internal task-to-cron ID map never serializes, + and the task SQLite transaction closes before Gateway I/O. +- Contract consistency accepts future `nextRunAtMs`, clamps only historical observations, and + forbids stale/truncated global state from asserting an unjustified missing linked cron. Focused + coverage locks cold refresh, TTL/backoff, single-flight/disposal, mutation invalidation, + 0/100/101/1000/>1000 rows, sequential pagination, cumulative byte overflow, pagination defects, + whole-snapshot LKG, task correlation, and no-identity serialization. +- This work does **not** claim that the legacy `GET /api/cache/heartbeat` schema-v3 contract is + replaced. Legacy still carries payload-bearing cache diagnostics and identifiable task, + Dashboard-job, and per-cron rows. Its parity entry is returned to `planned` until those + diagnostic capabilities and the repo-external consumer migration are preserved without loss. + +### 2026-08-11 — Authenticated health diagnostics parity closed + +- `system.healthDiagnostics` now returns one strict session-only, identity-free snapshot of live + application readiness, database access, immutable frontend/release verification, an online + worker from the exact serving release, sanitized Gateway state, cached session-count freshness, + and bounded queue aggregates. Anonymous callers are unauthorized and automation principals are + forbidden before any dependency read. +- One dedicated deferred-transaction repository read counts only queued/running jobs and computes + constant-size aggregates across every fresh worker. It therefore neither groups retained + terminal history nor inherits the Jobs UI's 32-worker response cap. Database, Gateway, session, + and queue failures remain explicit unavailable data without leaking identities or raw errors. +- Database, frontend, verified release, application state, and exact-release worker gate the + diagnostic status. Gateway degradation, stale session data, and claim pause remain visible but + do not alter the public readiness probe. The authenticated header now uses this single query + instead of separate readiness, Gateway, and Jobs requests; background failures retain the last + validated rows with an explicit stale marker instead of leaving an old green state current. +- The legacy `GET /api/health/diagnostics` row is recorded as implemented by this secure + replacement. The legacy route's wider application counters remain part of the separately planned + `GET /api/metrics` capability and are not claimed by this slice. diff --git a/greenfield/docs/development/local-development.md b/greenfield/docs/development/local-development.md index 1abd1b6fd..2afa21ca8 100644 --- a/greenfield/docs/development/local-development.md +++ b/greenfield/docs/development/local-development.md @@ -9,8 +9,9 @@ isolated. Use the Bun revision selected by `.bun-version` and install the frozen dependency graph. Before starting the stack, provide its Gateway credential through either `OPENCLAW_GATEWAY_TOKEN` or the -absolute owner-only file named by `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE`. Then start the loopback -stack: +absolute owner-only file named by `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE`, and export the worker-only +`MOLTBOOK_API_KEY`. `MOLTBOOK_AGENT_NAME` remains optional and defaults to `mira_2026`. Then start +the loopback stack: ```bash bun install --frozen-lockfile @@ -18,8 +19,9 @@ bun run dev ``` The plain command has no secret-manager dependency. On the owner host, the explicit -`bun run dev:doppler` convenience wrapper loads the Gateway token and optional session durations -from the configured Doppler project before invoking the same stack entrypoint. +`bun run dev:doppler` convenience wrapper loads the required Gateway and Moltbook credentials plus +optional session durations from the configured Doppler project before invoking the same stack +entrypoint. The default listeners are: @@ -64,8 +66,8 @@ For a stable WebAuthn origin and access from another Tailscale device: bun run dev:remote ``` -This command uses the same exported-token or token-file contract as `bun run dev` and does not -require Doppler. The corresponding owner-host convenience wrapper is +This command uses the same exported Gateway-token or token-file and `MOLTBOOK_API_KEY` contract as +`bun run dev` and does not require Doppler. The corresponding owner-host convenience wrapper is `bun run dev:remote:doppler`. The command verifies that port `3445` is free or already maps exactly to the loopback remote bridge, diff --git a/greenfield/docs/generated/configuration.md b/greenfield/docs/generated/configuration.md index 9f0784bd9..ae037fcbd 100644 --- a/greenfield/docs/generated/configuration.md +++ b/greenfield/docs/generated/configuration.md @@ -19,6 +19,8 @@ Configuration metadata is generated from the immutable application registry. For | `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | `webAuthnRelyingParty.rpId` | `domain-name` | Lowercase canonical domain name at most 253 code units. | Required | `web` | No | Value | Binds every WebAuthn credential and ceremony to one RP ID. | Required | Development and test | Stable WebAuthn relying-party domain identifier. | | `MIRA_DASHBOARD_WEBAUTHN_RP_NAME` | `webAuthnRelyingParty.rpName` | `relying-party-name` | Trimmed NFC text without control characters, at most 128 code units. | `Mira Dashboard` | `web` | No | Value | Changes the relying-party label in registration ceremonies. | Required | Development and test | Human-readable relying-party name shown by authenticators. | | `MIRA_DASHBOARD_WORKSPACE_ROOT` | `workspaceRoot` | `absolute-path` | Non-root normalized absolute path, at most 4096 code units; startup requires a canonical directory disjoint from Dashboard production state. | Required | `web`, `worker` | No | None | Selects the descriptor-rooted Files tree and Terminal's initial directory; it does not sandbox the interactive shell. | Required | Development and test | Explicit reviewed workspace root exposed through bounded Files operations and as Terminal's initial working location. | +| `MOLTBOOK_AGENT_NAME` | `moltbookAgentName` | `identifier` | Trimmed nonblank control-safe identity at most 128 code units. | `mira_2026` | `worker` | No | None | Selects the fixed, URL-encoded profile read used for profile, posts, and comments. | Required | Development and test | Moltbook agent identity projected into the Dashboard cache. | +| `MOLTBOOK_API_KEY` | `moltbookApiKey` | `opaque-secret`; values withheld | Trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Required; value withheld | `worker` | Yes | None | Authenticates four fixed-host Moltbook snapshot requests from the worker. | Required | Development and test | Worker-only Moltbook API credential used by the fixed read-only cache provider. | | `NODE_ENV` | `nodeEnvironment` | `environment-mode`; `development`, `production`, `test` | Exactly one enumerated runtime mode. | `production` | `web`, `worker`, `build`, `script` | No | Value | Controls production-only security and diagnostic behavior. | Required | Development and test | Runtime mode used for fail-closed production trust policy. | | `OPENCLAW_GATEWAY_TOKEN` | `gatewayToken` | `opaque-secret`; values withheld | Trimmed nonblank control-safe token at most 4096 code units; values are never persisted or browser-exposed. | Required; value withheld | `web`, `worker` | Yes | None | Authenticates the web and worker processes to the direct-loopback OpenClaw Gateway. | Required | Development and test | Server-only OpenClaw Gateway operator token for the persistent native connection. | | `OPENCLAW_GATEWAY_URL` | `gatewayUrl` | `websocket-url` | Canonical direct-loopback WebSocket URL at most 2048 code units. | `ws://127.0.0.1:18789` | `web`, `worker` | No | None | Selects the native Gateway endpoint used by credential verification and persistent operator traffic. | Required | Development and test | Direct-loopback OpenClaw Gateway endpoint for bootstrap verification and the persistent operator connection. | diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index 6936d1bd1..4c4caf2ab 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -46,7 +46,7 @@ | `automationSecurity.revokeCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.revokeCredential.input.schema.json) | [output](./schemas/automationSecurity.revokeCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Explicitly revokes one automation credential after client cutover. | | `automationSecurity.rotateCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.rotateCredential.input.schema.json) | [output](./schemas/automationSecurity.rotateCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Stages a linked replacement credential without revoking its predecessor. | | `cache.getEntry` | query | cache | Authenticated: cache:read | [input](./schemas/cache.getEntry.input.schema.json) | [output](./schemas/cache.getEntry.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Loads one cache projection with last-known-good data and freshness. | -| `cache.getHeartbeat` | query | cache | Authenticated: cache:read | [input](./schemas/cache.getHeartbeat.input.schema.json) | [output](./schemas/cache.getHeartbeat.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns compact cache status plus sanitized process-owned Gateway projections. | +| `cache.getHeartbeat` | query | cache | Authenticated: cache:read | [input](./schemas/cache.getHeartbeat.input.schema.json) | [output](./schemas/cache.getHeartbeat.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns compact cache status plus sanitized operational projections. | | `cache.getStatus` | query | cache | Authenticated: cache:read | [input](./schemas/cache.getStatus.input.schema.json) | [output](./schemas/cache.getStatus.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists bounded cache freshness and attempt state with an exact total. | | `cache.refreshEntry` | mutation | cache | Authenticated: cache:write | [input](./schemas/cache.refreshEntry.input.schema.json) | [output](./schemas/cache.refreshEntry.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Enqueues one caller-scoped idempotent cache refresh. | | `chat.abort` | mutation | chat | Authenticated: chat:write | [input](./schemas/chat.abort.input.schema.json) | [output](./schemas/chat.abort.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `operation_outcome_unknown` | Cancels one exact durable or observed provider chat run without ambiguous session-wide aborts. | @@ -84,6 +84,11 @@ | `logs.requestMaintenance` | mutation | logs | Authenticated browser session: logs:write; MFA enrollment required; recent MFA when enabled | [input](./schemas/logs.requestMaintenance.input.schema.json) | [output](./schemas/logs.requestMaintenance.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Queues one audited worker-owned invocation of an exact reviewed log policy. | | `logs.search` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.search.input.schema.json) | [output](./schemas/logs.search.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Searches only a bounded redacted tail window of one named source. | | `logs.tail` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.tail.input.schema.json) | [output](./schemas/logs.tail.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads one redacted bounded tail from an exact named source. | +| `moltbook.feed` | query | moltbook | Authenticated browser session: cache:read | [input](./schemas/moltbook.feed.input.v1.schema.json) | [output](./schemas/moltbook.feed.result.v1.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads one sorted feed from the bounded Moltbook snapshot. | +| `moltbook.home` | query | moltbook | Authenticated browser session: cache:read | [input](./schemas/system.empty.v1.schema.json) | [output](./schemas/moltbook.home.result.v1.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads bounded Moltbook activity counts and notification status. | +| `moltbook.listMyPosts` | query | moltbook | Authenticated browser session: cache:read | [input](./schemas/system.empty.v1.schema.json) | [output](./schemas/moltbook.own-content.result.v1.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads bounded posts and comments authored by the configured agent. | +| `moltbook.profile` | query | moltbook | Authenticated browser session: cache:read | [input](./schemas/system.empty.v1.schema.json) | [output](./schemas/moltbook.profile.result.v1.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads the configured agent's bounded Moltbook profile. | +| `moltbook.snapshot` | query | moltbook | Authenticated browser session: cache:read | [input](./schemas/moltbook.feed.input.v1.schema.json) | [output](./schemas/moltbook.snapshot.result.v1.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads the complete bounded Moltbook page projection in one request. | | `monitoring.submitCompleteSnapshot` | mutation | monitoring | Authenticated automation principal: monitoring:write | [input](./schemas/monitoring.submitCompleteSnapshot.input.schema.json) | [output](./schemas/monitoring.submitCompleteSnapshot.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Atomically ingests one complete monitor snapshot. | | `notifications.clearRead` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.clearRead.input.schema.json) | [output](./schemas/notifications.clearRead.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Deletes one bounded page of matching read notifications. | | `notifications.delete` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.delete.input.schema.json) | [output](./schemas/notifications.delete.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Deletes one exact Dashboard notification. | @@ -111,6 +116,7 @@ | `schedules.run` | mutation | schedules | Authenticated: jobs:write | [input](./schemas/schedules.run.input.schema.json) | [output](./schemas/schedules.run.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Enqueues one caller-scoped idempotent manual schedule run. | | `schedules.update` | mutation | schedules | Authenticated browser session: jobs:write | [input](./schemas/schedules.update.input.schema.json) | [output](./schemas/schedules.update.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Updates one schedule or its explicit disable intent by version. | | `securityAudit.listEvents` | query | securityAudit | Authenticated browser session | [input](./schemas/securityAudit.listEvents.input.schema.json) | [output](./schemas/securityAudit.listEvents.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists redacted immutable security events in stable newest-first order. | +| `system.healthDiagnostics` | query | system | Authenticated browser session | [input](./schemas/system.healthDiagnostics.input.schema.json) | [output](./schemas/system.healthDiagnostics.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns bounded readiness, dependency, and queue diagnostics without operational identities. | | `system.metrics` | query | system | Authenticated browser session | [input](./schemas/system.metrics.input.schema.json) | [output](./schemas/system.metrics.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Returns bounded host gauges and throughput without host identity or control authority. | | `system.runtimeIdentity` | query | system | Public | [input](./schemas/system.runtimeIdentity.input.schema.json) | [output](./schemas/system.runtimeIdentity.output.schema.json) | None | None | Returns the Bun runtime identity of the serving process. | | `tasks.addUpdate` | mutation | tasks | Authenticated: tasks:write | [input](./schemas/tasks.addUpdate.input.schema.json) | [output](./schemas/tasks.addUpdate.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Appends one authenticated progress update to a task. | diff --git a/greenfield/docs/generated/routes-and-features.md b/greenfield/docs/generated/routes-and-features.md index 348c1bdda..1ad8e751c 100644 --- a/greenfield/docs/generated/routes-and-features.md +++ b/greenfield/docs/generated/routes-and-features.md @@ -13,6 +13,7 @@ | `/jobs` | Browser session | Jobs | `jobs` | Shows Dashboard jobs, schedules, worker state, and OpenClaw cron. | | `/login` | Public | Hidden | `security` | Authenticates a browser session and completes pending MFA login. | | `/logs` | Browser session | Logs | `logs` | Reads redacted named log sources and queues fixed maintenance policies. | +| `/moltbook` | Browser session | Moltbook | `moltbook` | Reads the bounded worker-owned Moltbook profile, feeds, posts, and comments snapshot. | | `/reports` | Browser session | Reports | `monitoring` | Lists and renders durable bounded monitoring reports. | | `/sessions` | Browser session | Sessions | `gateway-sessions` | Shows and controls the bounded current Gateway session projection. | | `/tasks` | Browser session | Tasks | `tasks` | Manages the durable task board, updates, labels, and assignments. | diff --git a/greenfield/docs/generated/schemas/cache.getHeartbeat.output.schema.json b/greenfield/docs/generated/schemas/cache.getHeartbeat.output.schema.json index fbb3f3455..eb6c1ba72 100644 --- a/greenfield/docs/generated/schemas/cache.getHeartbeat.output.schema.json +++ b/greenfield/docs/generated/schemas/cache.getHeartbeat.output.schema.json @@ -162,6 +162,215 @@ "additionalProperties": false, "$comment": "Live Valibot validation additionally requires cache totals, truncation, snapshot timestamps, and freshness relative to the snapshot clock to agree." }, + "dashboardJobs": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "defaultEnabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "state": { + "const": "missing" + } + }, + "required": [ + "defaultEnabled", + "id", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "activeRun": { + "type": "object", + "properties": { + "firstStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "queued", + "running" + ], + "type": "string" + }, + "updatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "queuedAtMs", + "state", + "updatedAtMs" + ], + "additionalProperties": false + }, + "defaultEnabled": { + "type": "boolean" + }, + "disableIntent": { + "type": "object", + "properties": { + "expiresAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "valid": { + "type": "boolean" + } + }, + "required": [ + "valid" + ], + "additionalProperties": false + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "latestRun": { + "type": "object", + "properties": { + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "firstStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + }, + "terminalCode": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._/-]*$" + }, + "triggerType": { + "enum": [ + "manual", + "schedule", + "startup", + "system" + ], + "type": "string" + }, + "updatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "queuedAtMs", + "state", + "triggerType", + "updatedAtMs" + ], + "additionalProperties": false + }, + "nextRunAtMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + { + "type": "null" + } + ] + }, + "state": { + "const": "present" + } + }, + "required": [ + "defaultEnabled", + "enabled", + "id", + "nextRunAtMs", + "state" + ], + "additionalProperties": false + } + ] + }, + "maxItems": 32 + }, + "state": { + "const": "available" + } + }, + "required": [ + "items", + "state" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires the bounded code-owned Dashboard-job inventory and compact run lifecycle to remain canonical." + }, "gateway": { "type": "object", "properties": { @@ -321,6 +530,79 @@ "minimum": 0, "maximum": 9007199254740991 }, + "health": { + "type": "object", + "properties": { + "disabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inspectedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "intendedDisabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastRunErrorCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runningCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "staleRunningCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "synchronizationConflictCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "synchronizationPendingCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "truncated": { + "type": "boolean" + }, + "unexpectedDisabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "disabledCount", + "enabledCount", + "inspectedCount", + "intendedDisabledCount", + "lastRunErrorCount", + "runningCount", + "staleRunningCount", + "synchronizationConflictCount", + "synchronizationPendingCount", + "truncated", + "unexpectedDisabledCount" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires OpenClaw-cron health categories to form consistent inspected, disabled, running, and synchronization subsets." + }, "observedAtMs": { "type": "integer", "minimum": 0, @@ -340,6 +622,7 @@ }, "required": [ "count", + "health", "observedAtMs", "pendingSync", "state" @@ -354,6 +637,79 @@ "minimum": 0, "maximum": 9007199254740991 }, + "health": { + "type": "object", + "properties": { + "disabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inspectedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "intendedDisabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastRunErrorCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runningCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "staleRunningCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "synchronizationConflictCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "synchronizationPendingCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "truncated": { + "type": "boolean" + }, + "unexpectedDisabledCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "disabledCount", + "enabledCount", + "inspectedCount", + "intendedDisabledCount", + "lastRunErrorCount", + "runningCount", + "staleRunningCount", + "synchronizationConflictCount", + "synchronizationPendingCount", + "truncated", + "unexpectedDisabledCount" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires OpenClaw-cron health categories to form consistent inspected, disabled, running, and synchronization subsets." + }, "observedAtMs": { "type": "integer", "minimum": 0, @@ -378,28 +734,223 @@ }, "required": [ "count", + "health", "observedAtMs", "pendingSync", "staleSinceMs", "state" ], - "additionalProperties": false, - "$comment": "Live Valibot validation additionally requires compact OpenClaw-cron staleness to begin at or after the last observation." + "additionalProperties": false } - ] + ], + "$comment": "Live Valibot validation additionally requires OpenClaw-cron coverage, truncation, pending synchronization, and freshness to agree." }, "schemaVersion": { - "const": 1 + "const": 4 + }, + "tasks": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "automation": { + "type": "object", + "properties": { + "cron": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "state": { + "const": "missing" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "desiredEnabled": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastDurationMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastRunStatus": { + "enum": [ + "error", + "ok", + "skipped", + "unknown" + ], + "type": "string" + }, + "nextRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runningAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "present" + }, + "synchronization": { + "enum": [ + "confirmed", + "conflict", + "pending" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "state", + "synchronization" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires linked-cron actual, desired, and synchronization state to agree." + }, + "recurring": { + "type": "boolean" + } + }, + "required": [ + "cron", + "recurring" + ], + "additionalProperties": false + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "priority": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "relevance": { + "type": "array", + "items": { + "enum": [ + "automation-linked", + "agent-priority", + "owner-blocked" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 3 + }, + "status": { + "enum": [ + "todo", + "in-progress", + "blocked", + "done" + ], + "type": "string" + } + }, + "required": [ + "id", + "priority", + "relevance", + "status" + ], + "additionalProperties": false + }, + "maxItems": 100 + }, + "state": { + "const": "available" + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "items", + "state", + "totalCount", + "truncated" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires bounded heartbeat tasks to use strict canonical ID and relevance order with exact totals and truncation." } }, "required": [ "cache", + "dashboardJobs", "gateway", "generatedAtMs", "openClawCron", - "schemaVersion" + "schemaVersion", + "tasks" ], "additionalProperties": false, - "$comment": "Live Valibot validation additionally requires nested heartbeat observations not to exceed the clamped response clock and cached projections not to remain fresh while Gateway is disconnected.", + "$comment": "Live Valibot validation additionally requires nested heartbeat observations not to exceed the clamped response clock, disable-intent validity to match expiry, linked cron detail to follow global freshness and coverage, and cached projections not to remain fresh while Gateway is disconnected.", "$schema": "https://json-schema.org/draft/2020-12/schema" } diff --git a/greenfield/docs/generated/schemas/moltbook.feed.input.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.feed.input.v1.schema.json new file mode 100644 index 000000000..57254bb54 --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.feed.input.v1.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "urn:mira-dashboard:moltbook.feed.input.v1", + "type": "object", + "properties": { + "sort": { + "enum": [ + "hot", + "new" + ], + "type": "string", + "default": "hot" + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/moltbook.feed.result.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.feed.result.v1.schema.json new file mode 100644 index 000000000..62fc4610d --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.feed.result.v1.schema.json @@ -0,0 +1,247 @@ +{ + "$id": "urn:mira-dashboard:moltbook.feed.result.v1", + "type": "object", + "properties": { + "feed": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "maxLength": 80, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "hasMore": { + "type": "boolean" + }, + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "author": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "commentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "contentPreview": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "youFollowAuthor": { + "type": "boolean" + } + }, + "required": [ + "author", + "commentCount", + "contentPreview", + "createdAtMs", + "downvotes", + "id", + "submoltName", + "title", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 25 + }, + "sort": { + "enum": [ + "hot", + "new" + ], + "type": "string" + }, + "tip": { + "type": "string", + "maxLength": 1000, + "pattern": "^[^\\u0000]*$" + } + }, + "required": [ + "hasMore", + "posts", + "sort" + ], + "additionalProperties": false + }, + "status": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "failed" + }, + "refreshFailureMessage": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus", + "refreshFailureMessage" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "succeeded" + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "feed", + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/moltbook.home.result.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.home.result.v1.schema.json new file mode 100644 index 000000000..a9b32967b --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.home.result.v1.schema.json @@ -0,0 +1,201 @@ +{ + "$id": "urn:mira-dashboard:moltbook.home.result.v1", + "type": "object", + "properties": { + "home": { + "type": "object", + "properties": { + "activityOnYourPostsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "exploreCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "latestAnnouncement": { + "type": "object", + "properties": { + "authorName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "postId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "previewText": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "nextActions": { + "type": "array", + "items": { + "type": "string", + "maxLength": 300, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "maxItems": 8 + }, + "pendingRequestCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "postsFromAccountsYouFollowCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unreadMessageCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unreadNotificationCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activityOnYourPostsCount", + "exploreCount", + "nextActions", + "pendingRequestCount", + "postsFromAccountsYouFollowCount", + "unreadMessageCount", + "unreadNotificationCount" + ], + "additionalProperties": false + }, + "status": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "failed" + }, + "refreshFailureMessage": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus", + "refreshFailureMessage" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "succeeded" + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "home", + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/moltbook.own-content.result.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.own-content.result.v1.schema.json new file mode 100644 index 000000000..79c564889 --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.own-content.result.v1.schema.json @@ -0,0 +1,285 @@ +{ + "$id": "urn:mira-dashboard:moltbook.own-content.result.v1", + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "post": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "id", + "submoltName", + "title" + ], + "additionalProperties": false + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "content", + "createdAtMs", + "downvotes", + "id", + "post", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 50 + }, + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "commentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "contentPreview": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "commentCount", + "contentPreview", + "createdAtMs", + "downvotes", + "id", + "submoltName", + "title", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 25 + } + }, + "required": [ + "comments", + "posts" + ], + "additionalProperties": false + }, + "status": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "failed" + }, + "refreshFailureMessage": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus", + "refreshFailureMessage" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "succeeded" + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "content", + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/moltbook.profile.result.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.profile.result.v1.schema.json new file mode 100644 index 000000000..51bb47caf --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.profile.result.v1.schema.json @@ -0,0 +1,160 @@ +{ + "$id": "urn:mira-dashboard:moltbook.profile.result.v1", + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "commentsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "description": { + "type": "string", + "maxLength": 4000, + "pattern": "^[^\\u0000]*$" + }, + "displayName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "followerCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "followingCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "karma": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "postsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "commentsCount", + "description", + "displayName", + "followerCount", + "followingCount", + "karma", + "name", + "postsCount" + ], + "additionalProperties": false + }, + "status": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "failed" + }, + "refreshFailureMessage": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus", + "refreshFailureMessage" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "succeeded" + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/moltbook.snapshot.result.v1.schema.json b/greenfield/docs/generated/schemas/moltbook.snapshot.result.v1.schema.json new file mode 100644 index 000000000..22d7c02e5 --- /dev/null +++ b/greenfield/docs/generated/schemas/moltbook.snapshot.result.v1.schema.json @@ -0,0 +1,623 @@ +{ + "$id": "urn:mira-dashboard:moltbook.snapshot.result.v1", + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "post": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "id", + "submoltName", + "title" + ], + "additionalProperties": false + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "content", + "createdAtMs", + "downvotes", + "id", + "post", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 50 + }, + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "commentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "contentPreview": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "commentCount", + "contentPreview", + "createdAtMs", + "downvotes", + "id", + "submoltName", + "title", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 25 + } + }, + "required": [ + "comments", + "posts" + ], + "additionalProperties": false + }, + "feed": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "maxLength": 80, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "hasMore": { + "type": "boolean" + }, + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "author": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "commentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "contentPreview": { + "type": "string", + "maxLength": 8000, + "pattern": "^[^\\u0000]*$" + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "submoltName": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "upvotes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "youFollowAuthor": { + "type": "boolean" + } + }, + "required": [ + "author", + "commentCount", + "contentPreview", + "createdAtMs", + "downvotes", + "id", + "submoltName", + "title", + "upvotes" + ], + "additionalProperties": false + }, + "maxItems": 25 + }, + "sort": { + "enum": [ + "hot", + "new" + ], + "type": "string" + }, + "tip": { + "type": "string", + "maxLength": 1000, + "pattern": "^[^\\u0000]*$" + } + }, + "required": [ + "hasMore", + "posts", + "sort" + ], + "additionalProperties": false + }, + "home": { + "type": "object", + "properties": { + "activityOnYourPostsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "exploreCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "latestAnnouncement": { + "type": "object", + "properties": { + "authorName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "postId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "previewText": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "nextActions": { + "type": "array", + "items": { + "type": "string", + "maxLength": 300, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "maxItems": 8 + }, + "pendingRequestCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "postsFromAccountsYouFollowCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unreadMessageCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unreadNotificationCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activityOnYourPostsCount", + "exploreCount", + "nextActions", + "pendingRequestCount", + "postsFromAccountsYouFollowCount", + "unreadMessageCount", + "unreadNotificationCount" + ], + "additionalProperties": false + }, + "profile": { + "type": "object", + "properties": { + "commentsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "description": { + "type": "string", + "maxLength": 4000, + "pattern": "^[^\\u0000]*$" + }, + "displayName": { + "type": "string", + "maxLength": 200, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "followerCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "followingCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "karma": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "postsCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "commentsCount", + "description", + "displayName", + "followerCount", + "followingCount", + "karma", + "name", + "postsCount" + ], + "additionalProperties": false + }, + "status": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "failed" + }, + "refreshFailureMessage": { + "type": "string", + "maxLength": 2000, + "pattern": "^[^\\u0000]*$", + "allOf": [ + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus", + "refreshFailureMessage" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "lastAttemptAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastSuccessAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAttemptStatus": { + "const": "succeeded" + } + }, + "required": [ + "freshness", + "lastAttemptAtMs", + "lastSuccessAtMs", + "lastAttemptStatus" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "content", + "feed", + "home", + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/system.empty.v1.schema.json b/greenfield/docs/generated/schemas/system.empty.v1.schema.json new file mode 100644 index 000000000..1b6fcf0ba --- /dev/null +++ b/greenfield/docs/generated/schemas/system.empty.v1.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:system.empty.v1", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/system.healthDiagnostics.input.schema.json b/greenfield/docs/generated/schemas/system.healthDiagnostics.input.schema.json new file mode 100644 index 000000000..0f4618806 --- /dev/null +++ b/greenfield/docs/generated/schemas/system.healthDiagnostics.input.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:system.healthDiagnostics.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/system.healthDiagnostics.output.schema.json b/greenfield/docs/generated/schemas/system.healthDiagnostics.output.schema.json new file mode 100644 index 000000000..912a682a7 --- /dev/null +++ b/greenfield/docs/generated/schemas/system.healthDiagnostics.output.schema.json @@ -0,0 +1,353 @@ +{ + "$id": "urn:mira-dashboard:system.healthDiagnostics.output", + "type": "object", + "properties": { + "checkedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "checks": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "status": { + "enum": [ + "not-ready", + "ready" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "database": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "frontend": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "release": { + "type": "object", + "properties": { + "status": { + "enum": [ + "unavailable", + "verified" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "worker": { + "type": "object", + "properties": { + "status": { + "enum": [ + "not-ready", + "ready", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "application", + "database", + "frontend", + "release", + "worker" + ], + "additionalProperties": false + }, + "dependencies": { + "type": "object", + "properties": { + "gateway": { + "oneOf": [ + { + "type": "object", + "properties": { + "freshness": { + "enum": [ + "fresh", + "stale", + "unavailable" + ], + "type": "string" + }, + "phase": { + "enum": [ + "connected", + "connecting", + "degraded", + "stopped", + "stopping" + ], + "type": "string" + }, + "status": { + "const": "observed" + } + }, + "required": [ + "freshness", + "phase", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "const": "unavailable" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires connected Gateway phase and fresh state to agree." + }, + "sessions": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 200 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "fresh" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "count", + "observedAtMs", + "state", + "truncated" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 200 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "count", + "observedAtMs", + "staleSinceMs", + "state", + "truncated" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires last-known-good session staleness not to precede its observation." + } + }, + "required": [ + "gateway", + "sessions" + ], + "additionalProperties": false + }, + "queue": { + "oneOf": [ + { + "type": "object", + "properties": { + "claimingPaused": { + "type": "boolean" + }, + "oldestQueuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runs": { + "type": "object", + "properties": { + "queued": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "running": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "queued", + "running" + ], + "additionalProperties": false + }, + "status": { + "const": "observed" + }, + "workers": { + "type": "object", + "properties": { + "capacity": { + "type": "integer", + "minimum": 0, + "maximum": 512 + }, + "drainingCount": { + "type": "integer", + "minimum": 0, + "maximum": 32 + }, + "freshCount": { + "type": "integer", + "minimum": 0, + "maximum": 32 + }, + "onlineCount": { + "type": "integer", + "minimum": 0, + "maximum": 32 + } + }, + "required": [ + "capacity", + "drainingCount", + "freshCount", + "onlineCount" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires fresh worker count to equal its online and draining partitions, and aggregate capacity to remain a safe integer between one and sixteen slots per fresh worker." + } + }, + "required": [ + "claimingPaused", + "runs", + "status", + "workers" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "const": "unavailable" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ], + "$comment": "Live Valibot validation additionally requires a queued run if and only if an oldest queued timestamp is present." + }, + "status": { + "enum": [ + "not-ready", + "ready" + ], + "type": "string" + } + }, + "required": [ + "checkedAtMs", + "checks", + "dependencies", + "queue", + "status" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires aggregate readiness to match every gating check, binds database/worker health to an observed queue, forbids future queue/session observations, and permits fresh sessions only with a fresh Gateway.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/package.json b/greenfield/package.json index 902f56298..59d1b81e1 100644 --- a/greenfield/package.json +++ b/greenfield/package.json @@ -6,10 +6,10 @@ "scripts": { "dev": "bun scripts/developmentStack.ts", "dev:database:reset": "bun scripts/developmentStack.ts --reset-database", - "dev:doppler": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentStack.ts", + "dev:doppler": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MOLTBOOK_API_KEY,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentStack.ts", "dev:remote": "bun scripts/development/developmentTailscale.ts run", "dev:remote:disable": "bun scripts/development/developmentTailscale.ts disable", - "dev:remote:doppler": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/development/developmentTailscale.ts run", + "dev:remote:doppler": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MOLTBOOK_API_KEY,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/development/developmentTailscale.ts run", "dev:remote:enable": "bun scripts/development/developmentTailscale.ts enable", "dev:remote:status": "bun scripts/development/developmentTailscale.ts status", "dev:state:prepare": "bun scripts/developmentStack.ts --prepare-state", diff --git a/greenfield/scripts/delivery/systemdProductionServices.test.ts b/greenfield/scripts/delivery/systemdProductionServices.test.ts index f7074a3a9..3137c7881 100644 --- a/greenfield/scripts/delivery/systemdProductionServices.test.ts +++ b/greenfield/scripts/delivery/systemdProductionServices.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test import { mkdir, readFile, readlink, unlink } from "node:fs/promises"; import path from "node:path"; +import { configurationEnvironmentNamesForRole } from "../../src/shared/configuration/applicationConfigurationRegistry.ts"; import { createLocalReleaseFixture, createProductionTargetFixture, @@ -261,6 +262,12 @@ describe("production user-systemd service control", () => { readFile(path.join(systemdRoot, "mira-dashboard-web.service"), "utf8"), readFile(path.join(systemdRoot, "mira-dashboard-worker.service"), "utf8"), ]); + const webExecStart = web + .split("\n") + .find((line) => line.startsWith("ExecStart=")); + const workerExecStart = worker + .split("\n") + .find((line) => line.startsWith("ExecStart=")); for (const unit of [web, worker]) { expect(unit).not.toContain("StateDirectory="); expect(unit).not.toContain("LogsDirectory="); @@ -275,6 +282,7 @@ describe("production user-systemd service control", () => { expect(unit).toContain( "WorkingDirectory=%h/projects/mira-dashboard/production/releases/current" ); + expect(unit).toContain("--no-exit-on-missing-only-secrets"); expect(unit).toMatch( /StandardOutput=append:%h\/projects\/mira-dashboard\/production\/state\/logs\//u ); @@ -287,10 +295,23 @@ describe("production user-systemd service control", () => { expect(web).toContain( "--preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT" ); + expect(web).toContain( + `--only-secrets ${configurationEnvironmentNamesForRole("web").join(",")}` + ); + expect(webExecStart).not.toContain("MOLTBOOK_API_KEY"); + expect(web).toContain("UnsetEnvironment=MOLTBOOK_API_KEY MOLTBOOK_AGENT_NAME"); expect(worker).toContain("Environment=MIRA_DASHBOARD_OPENCLAW_ROOT=%h/.openclaw"); expect(worker).toContain( "--preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT" ); + expect(worker).toContain( + `--only-secrets ${configurationEnvironmentNamesForRole("worker").join(",")}` + ); + expect(workerExecStart).not.toContain("ELEVENLABS_API_KEY"); + expect(workerExecStart).not.toContain("MIRA_DASHBOARD_TOTP_KEYRING"); + expect(worker).toContain( + "UnsetEnvironment=ELEVENLABS_API_KEY MIRA_DASHBOARD_TOTP_KEYRING" + ); expect(web).toContain("MemoryMax=1G"); expect(web).toContain("TasksMax=96"); expect(web).toContain("ReadOnlyPaths=%h/.openclaw"); diff --git a/greenfield/scripts/development/developmentEnvironment.ts b/greenfield/scripts/development/developmentEnvironment.ts index 0c84f7bfe..5f381594a 100644 --- a/greenfield/scripts/development/developmentEnvironment.ts +++ b/greenfield/scripts/development/developmentEnvironment.ts @@ -58,6 +58,16 @@ async function gatewayToken( return token; } +function moltbookApiKey( + environment: Readonly> +): string { + const value = environment.MOLTBOOK_API_KEY; + if (value === undefined) { + throw new Error("Dashboard dev requires MOLTBOOK_API_KEY"); + } + return value; +} + function optionalConfiguration( target: Record, environment: Readonly>, @@ -83,6 +93,7 @@ export async function developmentProcessEnvironments( environment: Readonly> = process.env ): Promise; worker: Record }>> { const token = await gatewayToken(config, environment); + const moltbookCredential = moltbookApiKey(environment); const shared = { ...inheritedChildEnvironment(environment), MIRA_DASHBOARD_OPENCLAW_ROOT: config.openClawRoot, @@ -102,7 +113,14 @@ export async function developmentProcessEnvironments( MIRA_DASHBOARD_WEBAUTHN_RP_NAME: "Mira Dashboard Development", PORT: String(config.backendPort), }; - const worker: Record = { ...shared }; + const worker: Record = { + ...shared, + MOLTBOOK_API_KEY: moltbookCredential, + }; + const moltbookAgentName = environment.MOLTBOOK_AGENT_NAME; + if (moltbookAgentName !== undefined) { + worker.MOLTBOOK_AGENT_NAME = moltbookAgentName; + } for (const name of [ "MIRA_DASHBOARD_LOG_LEVEL", "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", diff --git a/greenfield/scripts/development/developmentPrivateFile.test.ts b/greenfield/scripts/development/developmentPrivateFile.test.ts index 00bcdb2f6..2cf123c87 100644 --- a/greenfield/scripts/development/developmentPrivateFile.test.ts +++ b/greenfield/scripts/development/developmentPrivateFile.test.ts @@ -76,9 +76,24 @@ test("reads the Gateway token from a private file without following a symlink", const environments = await developmentProcessEnvironments( config, "serialized-keyring", - {} + { MOLTBOOK_API_KEY: "private-moltbook-key" } ); expect(environments.web.OPENCLAW_GATEWAY_TOKEN).toBe("private-token"); + expect(environments.web.MOLTBOOK_API_KEY).toBeUndefined(); + expect(environments.worker.MOLTBOOK_API_KEY).toBe("private-moltbook-key"); + + const missingMoltbookKey = await developmentProcessEnvironments( + config, + "serialized-keyring", + {} + ).then( + () => null, + (error: unknown) => error + ); + expect(missingMoltbookKey).toBeInstanceOf(Error); + expect((missingMoltbookKey as Error).message).toBe( + "Dashboard dev requires MOLTBOOK_API_KEY" + ); await symlink(tokenPath, tokenSymlinkPath); const symlinkConfig = Object.freeze({ @@ -88,7 +103,7 @@ test("reads the Gateway token from a private file without following a symlink", const failure = await developmentProcessEnvironments( symlinkConfig, "serialized-keyring", - {} + { MOLTBOOK_API_KEY: "private-moltbook-key" } ).then( () => null, (error: unknown) => error diff --git a/greenfield/scripts/development/developmentRuntime.test.ts b/greenfield/scripts/development/developmentRuntime.test.ts index 2e57a692d..5a6671b84 100644 --- a/greenfield/scripts/development/developmentRuntime.test.ts +++ b/greenfield/scripts/development/developmentRuntime.test.ts @@ -13,6 +13,9 @@ import { prepareDevelopmentRuntimeState } from "./developmentState.ts"; const repositoryRoot = path.resolve(import.meta.dir, "../.."); const sourceCommit = "0".repeat(40); +const developmentTestEnvironment = Object.freeze({ + MOLTBOOK_API_KEY: "moltbook-development-test-key", +}); interface FakeChild { readonly child: DevelopmentChildProcess; @@ -123,6 +126,7 @@ describe("development runtime lifecycle", () => { try { const failure = await runDevelopmentStack(config, { + environment: developmentTestEnvironment, resolveSourceCommit: () => Promise.resolve(sourceCommit), spawn() { spawnCalls += 1; @@ -161,6 +165,7 @@ describe("development runtime lifecycle", () => { try { const running = runDevelopmentStack(config, { + environment: developmentTestEnvironment, resolveSourceCommit: () => Promise.resolve(sourceCommit), spawn(command) { commands.push(command); @@ -217,6 +222,7 @@ describe("development runtime lifecycle", () => { try { running = runDevelopmentStack(config, { + environment: developmentTestEnvironment, resolveSourceCommit: () => Promise.resolve(sourceCommit), spawn() { const next = children[spawnCalls]; diff --git a/greenfield/scripts/development/developmentRuntime.ts b/greenfield/scripts/development/developmentRuntime.ts index 15c8440c2..d5eb30343 100644 --- a/greenfield/scripts/development/developmentRuntime.ts +++ b/greenfield/scripts/development/developmentRuntime.ts @@ -21,6 +21,7 @@ export interface DevelopmentChildProcess { } export interface DevelopmentRuntimeDependencies { + readonly environment?: Readonly>; readonly resolveSourceCommit: (repositoryRoot: string) => Promise; readonly spawn: ( command: readonly string[], @@ -133,7 +134,11 @@ async function startDevelopmentChildren( | readonly [DevelopmentChildProcess, DevelopmentChildProcess, DevelopmentChildProcess] | undefined > { - const environments = await developmentProcessEnvironments(config, state.keyring); + const environments = await developmentProcessEnvironments( + config, + state.keyring, + dependencies.environment ?? process.env + ); if (stopController.stopRequested) return; const bun = process.execPath; const watch = config.hotReload ? ["--watch"] : []; diff --git a/greenfield/scripts/documentation/artifacts.test.ts b/greenfield/scripts/documentation/artifacts.test.ts index f40acc8c9..f4ce04fc4 100644 --- a/greenfield/scripts/documentation/artifacts.test.ts +++ b/greenfield/scripts/documentation/artifacts.test.ts @@ -53,6 +53,9 @@ describe("generated contract documentation", () => { expect(configurationDocumentation).toContain( "| `ELEVENLABS_API_KEY` | `elevenLabsApiKey` | `opaque-secret`; values withheld | When present, a trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Optional; no default | `web` | Yes | None |" ); + expect(configurationDocumentation).toContain( + "| `MOLTBOOK_API_KEY` | `moltbookApiKey` | `opaque-secret`; values withheld | Trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Required; value withheld | `worker` | Yes | None |" + ); const procedureDocumentation = first.get("procedures.md"); expect(procedureDocumentation).toContain("`auth.bootstrap`"); expect(procedureDocumentation).toContain("`auth.changePassword`"); @@ -101,6 +104,12 @@ describe("generated contract documentation", () => { expect(procedureDocumentation).toContain( "| `logs.tail` | query | logs | Authenticated browser session: logs:read |" ); + expect(procedureDocumentation).toContain( + "| `moltbook.feed` | query | moltbook | Authenticated browser session: cache:read |" + ); + expect(procedureDocumentation).toContain( + "| `moltbook.snapshot` | query | moltbook | Authenticated browser session: cache:read |" + ); expect(procedureDocumentation).toContain( "| `terminal.prepareSession` | mutation | terminal | Authenticated browser session: terminal:write; MFA enrollment required; recent MFA when enabled |" ); @@ -159,12 +168,17 @@ describe("generated contract documentation", () => { expect(routeDocumentation).toContain( "| `/logs` | Browser session | Logs | `logs` |" ); + expect(routeDocumentation).toContain( + "| `/moltbook` | Browser session | Moltbook | `moltbook` |" + ); expect(routeDocumentation).toContain( "| `/terminal` | Browser session | Terminal | `terminal` |" ); - expect(routeDocumentation?.match(/^\| `\//gmu)).toHaveLength(13); + expect(routeDocumentation?.match(/^\| `\//gmu)).toHaveLength(14); expect(first.has("schemas/files.upload.accepted.schema.json")).toBe(true); expect(first.has("schemas/logs.tail.output.schema.json")).toBe(true); + expect(first.has("schemas/moltbook.feed.result.v1.schema.json")).toBe(true); + expect(first.has("schemas/moltbook.snapshot.result.v1.schema.json")).toBe(true); expect(first.has("schemas/terminal.prepareSession.output.schema.json")).toBe( true ); diff --git a/greenfield/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts index fe4a8f3ea..b164907ed 100644 --- a/greenfield/scripts/documentation/jsonSchema.test.ts +++ b/greenfield/scripts/documentation/jsonSchema.test.ts @@ -25,6 +25,7 @@ import { createAutomationPrincipalResultSchema, listAutomationPrincipalsResultSchema, } from "../../src/contracts/automationSecurity.ts"; +import { cacheHeartbeatResultSchema } from "../../src/contracts/cache.ts"; import { chatRuntimeOutputSchema, chatSendInputSchema, @@ -172,6 +173,25 @@ describe("contract JSON Schema conversion", () => { ); }); + test("documents heartbeat v4 bounds and secure runtime-only invariants", () => { + const document = convertContractSchema( + cacheHeartbeatResultSchema, + "cache.getHeartbeat", + "output" + ); + const serialized = JSON.stringify(document); + + expect(document).toMatchObject({ + properties: { schemaVersion: { const: 4 } }, + }); + expect(serialized).toContain('"maxItems":100'); + expect(serialized).toContain('"maxItems":32'); + expect(serialized).toContain("strict canonical ID and relevance order"); + expect(serialized).toContain("disable-intent validity to match expiry"); + expect(serialized).toContain("cron health categories"); + expect(serialized).toContain("linked-cron actual, desired"); + }); + test("documents the runtime-only terminal replay window invariant", () => { const document = JSON.stringify( convertContractSchema( diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index d2f4e890f..956d61d6a 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -44,9 +44,14 @@ import { cacheEntryPayloadFitsBudget, cacheEntryStatusIsConsistent, cacheHeartbeatConnectionIsConsistent, + cacheHeartbeatCronHealthCountsAreConsistent, cacheHeartbeatCronLastKnownGoodIsConsistent, + cacheHeartbeatCronProjectionIsConsistent, + cacheHeartbeatDashboardJobsAreConsistent, cacheHeartbeatResultIsConsistent, cacheHeartbeatSessionsLastKnownGoodIsConsistent, + cacheHeartbeatTaskCronIsConsistent, + cacheHeartbeatTasksAreConsistent, cacheStatusEntriesAreCanonical, cacheStatusResultIsConsistent, systemHostCapacityIsConsistent, @@ -200,7 +205,14 @@ import { securityAuditEventsHaveStableOrder, securityAuditPageCursorIsConsistent, } from "../../src/contracts/securityAudit.ts"; -import { systemMetricCapacityIsConsistent } from "../../src/contracts/system.ts"; +import { + systemHealthDiagnosticsGatewayIsConsistent, + systemHealthDiagnosticsIsConsistent, + systemHealthDiagnosticsQueueIsConsistent, + systemHealthDiagnosticsSessionsAreConsistent, + systemHealthDiagnosticsWorkersAreConsistent, + systemMetricCapacityIsConsistent, +} from "../../src/contracts/system.ts"; import { canonicalizeTaskStrings, freezeTaskStrings, @@ -331,6 +343,26 @@ const runtimeCheckComments = new Map([ gatewayConnectionSnapshotIsConsistent, "Live Valibot validation additionally requires connected phase and fresh state to agree and past transport timestamps not to exceed the check time.", ], + [ + systemHealthDiagnosticsGatewayIsConsistent, + "Live Valibot validation additionally requires connected Gateway phase and fresh state to agree.", + ], + [ + systemHealthDiagnosticsSessionsAreConsistent, + "Live Valibot validation additionally requires last-known-good session staleness not to precede its observation.", + ], + [ + systemHealthDiagnosticsWorkersAreConsistent, + "Live Valibot validation additionally requires fresh worker count to equal its online and draining partitions, and aggregate capacity to remain a safe integer between one and sixteen slots per fresh worker.", + ], + [ + systemHealthDiagnosticsQueueIsConsistent, + "Live Valibot validation additionally requires a queued run if and only if an oldest queued timestamp is present.", + ], + [ + systemHealthDiagnosticsIsConsistent, + "Live Valibot validation additionally requires aggregate readiness to match every gating check, binds database/worker health to an observed queue, forbids future queue/session observations, and permits fresh sessions only with a fresh Gateway.", + ], [ chatHistoryMessagesHaveUniqueIds, "Live Valibot validation additionally requires every chat history message ID to be unique.", @@ -559,9 +591,29 @@ const runtimeCheckComments = new Map([ cacheHeartbeatCronLastKnownGoodIsConsistent, "Live Valibot validation additionally requires compact OpenClaw-cron staleness to begin at or after the last observation.", ], + [ + cacheHeartbeatCronHealthCountsAreConsistent, + "Live Valibot validation additionally requires OpenClaw-cron health categories to form consistent inspected, disabled, running, and synchronization subsets.", + ], + [ + cacheHeartbeatCronProjectionIsConsistent, + "Live Valibot validation additionally requires OpenClaw-cron coverage, truncation, pending synchronization, and freshness to agree.", + ], + [ + cacheHeartbeatTaskCronIsConsistent, + "Live Valibot validation additionally requires linked-cron actual, desired, and synchronization state to agree.", + ], + [ + cacheHeartbeatTasksAreConsistent, + "Live Valibot validation additionally requires bounded heartbeat tasks to use strict canonical ID and relevance order with exact totals and truncation.", + ], + [ + cacheHeartbeatDashboardJobsAreConsistent, + "Live Valibot validation additionally requires the bounded code-owned Dashboard-job inventory and compact run lifecycle to remain canonical.", + ], [ cacheHeartbeatResultIsConsistent, - "Live Valibot validation additionally requires nested heartbeat observations not to exceed the clamped response clock and cached projections not to remain fresh while Gateway is disconnected.", + "Live Valibot validation additionally requires nested heartbeat observations not to exceed the clamped response clock, disable-intent validity to match expiry, linked cron detail to follow global freshness and coverage, and cached projections not to remain fresh while Gateway is disconnected.", ], [ cacheRealtimeIdentityMatches, diff --git a/greenfield/scripts/frontendBuildArtifacts.ts b/greenfield/scripts/frontendBuildArtifacts.ts index 245cd8a46..886a82b6a 100644 --- a/greenfield/scripts/frontendBuildArtifacts.ts +++ b/greenfield/scripts/frontendBuildArtifacts.ts @@ -49,10 +49,10 @@ type FrontendBundleBudget = keyof Pick< >; export const FRONTEND_BUNDLE_BUDGETS: Readonly> = { - initialJavaScriptGzipBytes: 350 * 1024, + initialJavaScriptGzipBytes: 353 * 1024, initialStylesheetGzipBytes: 25 * 1024, largestJavaScriptGzipBytes: 200 * 1024, - totalJavaScriptGzipBytes: 850 * 1024, + totalJavaScriptGzipBytes: 865 * 1024, }; interface MeasuredOutput { diff --git a/greenfield/scripts/sourceBoundaries/policy.test.ts b/greenfield/scripts/sourceBoundaries/policy.test.ts index 050df7295..eb5a6a3c0 100644 --- a/greenfield/scripts/sourceBoundaries/policy.test.ts +++ b/greenfield/scripts/sourceBoundaries/policy.test.ts @@ -51,6 +51,12 @@ describe("source-boundary policy", () => { staticImport("../server/domains/jobs/workerRuntime.ts") ) ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/worker.ts", + staticImport("../server/domains/moltbook/provider.ts") + ) + ).toBeUndefined(); expect( validateSourceImport( "src/app/worker.ts", diff --git a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts index ff2bcd167..c17b5f9a1 100644 --- a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts +++ b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts @@ -53,6 +53,7 @@ const reviewedApplicationServerTargets: ReadonlyMap< "src/app/worker.ts", new Set([ "src/server/domains/jobs/workerRuntime.ts", + "src/server/domains/moltbook/provider.ts", "src/server/platform/configuration/workerConfiguration.ts", "src/server/platform/filesystem/projectLayout.ts", "src/server/platform/gateway/persistentGatewayTransport.ts", diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index 4b8279dfe..c752c5a49 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -342,6 +342,7 @@ describe("Dashboard OpenClaw cron composition", () => { const transport: PersistentOpenClawCronTransport = { request: (method, parameters, options) => { calls.push({ method, options, parameters }); + options?.onResponseBytes?.(1024); return Promise.resolve({ hasMore: false, jobs: [], @@ -379,7 +380,10 @@ describe("Dashboard OpenClaw cron composition", () => { expect(calls).toEqual([ { method: "cron.list", - options: { timeoutMs: 15_000 }, + options: { + onResponseBytes: expect.any(Function), + timeoutMs: 15_000, + }, parameters: { compact: false, enabled: "all", @@ -886,21 +890,27 @@ describe("Dashboard security composition", () => { scheduleBody.result?.data?.json ); expect(schedules.schedules.map(({ id }) => id)).toEqual([ + "cache.moltbook-dashboard", "cache.system-host", "maintenance.rotate-managed-logs", "system.worker-smoke", ]); expect(schedules.schedules[0]).toMatchObject({ + actionKey: "cache.refresh.moltbook-dashboard", + enabled: true, + id: "cache.moltbook-dashboard", + }); + expect(schedules.schedules[1]).toMatchObject({ actionKey: "cache.refresh.system-host", enabled: true, id: "cache.system-host", }); - expect(schedules.schedules[1]).toMatchObject({ + expect(schedules.schedules[2]).toMatchObject({ actionKey: "maintenance.rotate-logs", enabled: true, id: "maintenance.rotate-managed-logs", }); - expect(schedules.schedules[2]).toMatchObject({ + expect(schedules.schedules[3]).toMatchObject({ actionKey: "system.worker-smoke", enabled: false, id: "system.worker-smoke", @@ -930,17 +940,58 @@ describe("Dashboard security composition", () => { }; expect(heartbeatResponse.status).toBe(200); expect(heartbeatBody.error).toBeUndefined(); - expect( - v.parse(cacheHeartbeatResultSchema, heartbeatBody.result?.data?.json) - ).toMatchObject({ + const heartbeat = v.parse( + cacheHeartbeatResultSchema, + heartbeatBody.result?.data?.json + ); + expect(heartbeat).toMatchObject({ cache: { entries: [], totalCount: 0, truncated: false }, + dashboardJobs: { state: "available" }, gateway: { connection: { freshness: "unavailable", phase: "stopped" }, sessions: { state: "unavailable" }, }, openClawCron: { pendingSync: "unknown", state: "unavailable" }, - schemaVersion: 1, + schemaVersion: 4, + tasks: { + items: [], + state: "available", + totalCount: 0, + truncated: false, + }, }); + expect( + heartbeat.dashboardJobs.state === "available" + ? heartbeat.dashboardJobs.items.map( + ({ defaultEnabled, id, state }) => ({ + defaultEnabled, + id, + state, + }) + ) + : [] + ).toEqual([ + { + defaultEnabled: true, + id: "cache.moltbook-dashboard", + state: "present", + }, + { + defaultEnabled: true, + id: "cache.system-host", + state: "present", + }, + { + defaultEnabled: true, + id: "maintenance.rotate-managed-logs", + state: "present", + }, + { + defaultEnabled: false, + id: "system.worker-smoke", + state: "present", + }, + ]); const idempotencyKey = "cHJvZHVjdGlvbi1odHRwLWNvbXBvc2l0aW9uLWtleS0x"; const enqueue = () => diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 5c113dd4c..e9dbc2e5f 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -10,6 +10,10 @@ import { } from "../contracts/chatModel.ts"; import { createAgentRepository } from "../server/domains/agents/repository.ts"; import { createAgentService } from "../server/domains/agents/service.ts"; +import { + readCacheHeartbeatDashboardJobs, + readCacheHeartbeatTasksWithCronRefresh, +} from "../server/domains/cache/heartbeatProjection.ts"; import { createCacheRepository } from "../server/domains/cache/repository.ts"; import { createCacheService } from "../server/domains/cache/service.ts"; import { createChatRepository } from "../server/domains/chat/repository.ts"; @@ -57,7 +61,10 @@ import { OpenClawCronProviderError, type OpenClawCronProvider, } from "../server/domains/openClawCron/provider.ts"; -import { createOpenClawCronService } from "../server/domains/openClawCron/service.ts"; +import { + createOpenClawCronService, + type OpenClawCronHeartbeatReader, +} from "../server/domains/openClawCron/service.ts"; import { createSqliteOpenClawCronIntentStore } from "../server/domains/openClawCron/sqliteIntentStore.ts"; import { createOpenClawTasksRealtimePublisher } from "../server/domains/openClawTasks/realtime.ts"; import { @@ -90,6 +97,7 @@ import { createRequestAuthenticator } from "../server/domains/security/requestAu import { createRequestAuthenticationRepository } from "../server/domains/security/requestAuthenticationRepository.ts"; import { createSecurityAuditLifecycleService } from "../server/domains/security/securityAuditLifecycle.ts"; import { createSecurityAuditLifecycleRepository } from "../server/domains/security/securityAuditLifecycleRepository.ts"; +import { createSystemHealthDiagnosticsService } from "../server/domains/system/healthDiagnosticsService.ts"; import { createTaskRepository } from "../server/domains/tasks/repository.ts"; import { createTaskService } from "../server/domains/tasks/service.ts"; import { createElevenLabsSpeechProvider } from "../server/platform/chat/elevenLabsSpeechProvider.ts"; @@ -193,6 +201,7 @@ export interface DashboardServerOptions extends Omit< | "openClawCronService" | "openClawTasksService" | "securityAuditLifecycle" + | "systemHealthDiagnosticsService" | "taskService" | "terminalService" | "terminalSocketBoundary" @@ -218,6 +227,8 @@ export interface DashboardServerOptions extends Omit< readonly openClawFileRoot?: WorkspaceFileRootConfiguration; readonly recentAuthenticationWindowMs?: number; readonly sessionIdleDurationMs?: number; + /** Verified immutable release used to require a matching fresh worker. */ + readonly verifiedReleaseId?: string; /** Optional only for isolated composition tests; production supplies both paths. */ readonly terminalBrokerDirectory?: string; readonly terminalBrokerSocket?: string; @@ -427,6 +438,7 @@ export async function createDashboardServer( let chatMaintenance: DashboardChatRuntimeMaintenance | undefined; let chatTranscriptLifecycleSupervisor: ChatTranscriptLifecycleSupervisor | undefined; let openClawTasksService: OpenClawTasksService | undefined; + let openClawCronHeartbeatReader: OpenClawCronHeartbeatReader | undefined; let workspaceFilesService: WorkspaceFilesService | undefined; let openClawTasksSupervisor: | ReturnType @@ -455,6 +467,11 @@ export async function createDashboardServer( } catch (error) { failure ??= error; } + try { + await openClawCronHeartbeatReader?.disposeHeartbeat(); + } catch (error) { + failure ??= error; + } try { await chatService?.dispose(); } catch (error) { @@ -803,6 +820,17 @@ export async function createDashboardServer( ? {} : { transcriptLifecycle: chatTranscriptLifecycle }), }); + const systemHealthDiagnosticsService = createSystemHealthDiagnosticsService({ + ...(options.verifiedReleaseId === undefined + ? {} + : { expectedWorkerReleaseId: options.verifiedReleaseId }), + frontendReady: options.frontendAssets !== undefined, + gatewayConnectionService, + gatewaySessionsReader: gatewaySessionsService, + jobHealthReader: jobRepository, + ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), + readiness: options.readiness, + }); const agentService = createAgentService({ ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), gatewaySessionsService, @@ -983,12 +1011,24 @@ export async function createDashboardServer( options.applicationRuntime.persistentGatewayTransport ), }); + openClawCronHeartbeatReader = openClawCronService; const cacheService = createCacheService({ cacheRepository: createCacheRepository(database, databaseRuntime), jobRepository, ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), readGatewayConnection: gatewayConnectionService.get, readGatewaySessionsProjection: gatewaySessionsService.readHeartbeatProjection, + readHeartbeatDashboardJobs: (generatedAtMs) => + readCacheHeartbeatDashboardJobs(jobRepository, generatedAtMs), + readHeartbeatTasks: () => + readCacheHeartbeatTasksWithCronRefresh( + () => + taskRepository.withReadTransaction((reader) => + reader.readHeartbeatCandidates() + ), + openClawCronService.refreshHeartbeatProjection, + openClawCronService.readHeartbeatJobProjection + ), readOpenClawCronProjection: openClawCronService.readHeartbeatProjection, wakeEventPump, }); @@ -1053,6 +1093,7 @@ export async function createDashboardServer( port: options.port, readiness: options.readiness, securityAuditLifecycle, + systemHealthDiagnosticsService, taskService, ...(terminalComposition === undefined ? {} @@ -1266,6 +1307,7 @@ export async function runDashboardWebProcess( terminalBrokerSocket: layout.production.state.terminalBrokerSocket, totpSecretCipher, trustedProxyAddresses: configuration.trustedProxyAddresses, + verifiedReleaseId: release.manifest.source.commitSha, webAuthnRelyingParty: configuration.webAuthnRelyingParty, workspaceFileRoot, workspaceFileUploadSpoolRoot: layout.production.state.workspaceFileUploads, diff --git a/greenfield/src/app/dashboardServerProcess.test.ts b/greenfield/src/app/dashboardServerProcess.test.ts index 4502c3390..e80ded4d4 100644 --- a/greenfield/src/app/dashboardServerProcess.test.ts +++ b/greenfield/src/app/dashboardServerProcess.test.ts @@ -141,6 +141,7 @@ function processFixture(totpFailure?: Error) { ); expect(options.frontendAssets).toBeFunction(); expect(options.port).toBe(3100); + expect(options.verifiedReleaseId).toBe(releaseId); expect(options.openClawFileRoot).toEqual({ id: "openclaw-config", label: "OpenClaw Config", diff --git a/greenfield/src/app/developmentWorker.ts b/greenfield/src/app/developmentWorker.ts index 7c1ccd347..cfb78062e 100644 --- a/greenfield/src/app/developmentWorker.ts +++ b/greenfield/src/app/developmentWorker.ts @@ -44,7 +44,8 @@ export async function runDevelopmentWorkerProcess( gatewayTransport, workspaceRoot, openClawRoot, - logMaintenance + logMaintenance, + moltbook ) => { const writer = createDescriptorWorkspaceFileStructuralWriter({ roots: [workspaceRoot, openClawRoot], @@ -58,6 +59,7 @@ export async function runDevelopmentWorkerProcess( stateDirectory: layout.production.state.root, }, logMaintenance, + moltbook, persistentGatewayTransport: gatewayTransport, pid: process.pid, releaseId: source.manifest.source.commitSha, diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index ccb2113b1..fb3bef3ff 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -22,6 +22,7 @@ import type { AutomationSecurityLifecycleService } from "../server/domains/secur import type { MfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; import type { SecurityAuditLifecycleService } from "../server/domains/security/securityAuditLifecycle.ts"; +import type { SystemHealthDiagnosticsService } from "../server/domains/system/healthDiagnosticsService.ts"; import type { TaskService } from "../server/domains/tasks/service.ts"; import type { TerminalService } from "../server/domains/terminal/service.ts"; import type { ReadinessController } from "../server/platform/readiness/readinessState.ts"; @@ -217,6 +218,7 @@ export interface ServerOptions { readonly port: number; readonly readiness: ReadinessController; readonly securityAuditLifecycle: SecurityAuditLifecycleService; + readonly systemHealthDiagnosticsService: SystemHealthDiagnosticsService; readonly taskService: TaskService["Service"]; readonly terminalService?: TerminalService; /** Browser-session-only upgrade boundary for the worker-owned interactive PTY. */ @@ -272,6 +274,7 @@ export async function createServer(options: ServerOptions): Promise DashboardWorkerRuntime; readonly createTerminationController: () => ProcessTerminationController; readonly loadRelease: ( @@ -174,7 +179,8 @@ const defaultDependencies = Object.freeze({ gatewayTransport, workspaceRoot, openClawRoot, - logMaintenance + logMaintenance, + moltbook ) => { const writer = createDescriptorWorkspaceFileStructuralWriter({ roots: [workspaceRoot, openClawRoot], @@ -188,6 +194,7 @@ const defaultDependencies = Object.freeze({ stateDirectory: layout.production.state.root, }, logMaintenance, + moltbook, persistentGatewayTransport: gatewayTransport, pid: process.pid, releaseId: release.manifest.source.commitSha, @@ -293,6 +300,10 @@ export async function runDashboardWorkerProcess( url: configuration.gatewayUrl, }); const logMaintenance = dependencies.createLogMaintenanceExecutor(layout); + const moltbook = createMoltbookDashboardCollector({ + agentName: configuration.moltbookAgentName, + apiKey: configuration.moltbookApiKey, + }); runtime = dependencies.createRuntime( layout, release, @@ -300,7 +311,8 @@ export async function runDashboardWorkerProcess( gatewayTransport, workspaceRoot, openClawRoot, - logMaintenance + logMaintenance, + moltbook ); const runtimeCompletion = runtime.completion.then( () => ({ kind: "stopped" as const }), diff --git a/greenfield/src/browser/api/trpcClient.ts b/greenfield/src/browser/api/trpcClient.ts index 4885d5c9e..cb8f29ff7 100644 --- a/greenfield/src/browser/api/trpcClient.ts +++ b/greenfield/src/browser/api/trpcClient.ts @@ -104,6 +104,10 @@ async function procedureContractsFor( const module = await import("../../contracts/logs.ts"); return module.logProcedureContracts; } + case "moltbook": { + const module = await import("../../contracts/moltbook.ts"); + return module.moltbookProcedureContracts; + } case "notifications": { const module = await import("../../contracts/notifications.ts"); return module.notificationProcedureContracts; diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx index 6fd92cee0..f49abb370 100644 --- a/greenfield/src/browser/application.test.tsx +++ b/greenfield/src/browser/application.test.tsx @@ -1,15 +1,16 @@ -import { describe, expect, jest, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createMemoryHistory } from "@tanstack/react-router"; import type { AuthStatus } from "../contracts/auth.ts"; import { deriveGatewaySessionStats } from "../contracts/gatewaySessions.ts"; +import type { SystemHealthDiagnostics } from "../contracts/system.ts"; import { createDashboardQueryClient } from "./api/queryClient.ts"; import { createDashboardTrpcClient } from "./api/trpcClient.ts"; import { DashboardBrowserApplication } from "./application.tsx"; import { authStatusQueryKey } from "./auth/authQueries.ts"; import { createDashboardBrowserCollections } from "./data/dashboardCollections.ts"; -import { jobQueueSummaryQueryKey } from "./jobs/jobQueries.ts"; +import { dashboardHealthDiagnosticsQueryKey } from "./layout/dashboardSystemStatus.ts"; import { notificationLatestQueryKey } from "./notifications/notificationQueries.ts"; import { createDashboardRouter } from "./router.tsx"; import type { DashboardWebAuthnClient } from "./security/webauthn/webauthnClient.ts"; @@ -23,6 +24,51 @@ const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ register: () => Promise.reject(new TypeError("Unexpected registration")), }); +function healthDiagnostics( + timestampMs: number, + options: { + readonly claimingPaused?: boolean; + readonly workerReady?: boolean; + } = {} +): SystemHealthDiagnostics { + const workerReady = options.workerReady ?? true; + return { + checkedAtMs: timestampMs, + checks: { + application: { status: "ready" }, + database: { status: "ready" }, + frontend: { status: "ready" }, + release: { status: "verified" }, + worker: { status: workerReady ? "ready" : "not-ready" }, + }, + dependencies: { + gateway: { + freshness: "fresh", + phase: "connected", + status: "observed", + }, + sessions: { + count: 0, + observedAtMs: timestampMs, + state: "fresh", + truncated: false, + }, + }, + queue: { + claimingPaused: options.claimingPaused ?? false, + runs: { queued: 0, running: 0 }, + status: "observed", + workers: { + capacity: workerReady ? 1 : 0, + drainingCount: 0, + freshCount: workerReady ? 1 : 0, + onlineCount: workerReady ? 1 : 0, + }, + }, + status: workerReady ? "ready" : "not-ready", + }; +} + describe("Dashboard browser application", () => { test("renders the overview cache foundation and owns authenticated activity", async () => { const timestampMs = Date.now(); @@ -34,10 +80,9 @@ describe("Dashboard browser application", () => { let logoutCalls = 0; let notificationCalls = 0; let cacheStatusCalls = 0; + let healthUnavailable = false; + let healthStatusCalls = 0; let settleLogout: ((result: { readonly isOk: true }) => void) | undefined; - const readinessFetch = jest - .spyOn(globalThis, "fetch") - .mockResolvedValue(Response.json({ status: "ready" })); const trpcClient = createDashboardTrpcClient({ mutation(path, input) { expect(input).toEqual({}); @@ -54,17 +99,12 @@ describe("Dashboard browser application", () => { return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); }, query(path, input) { - if (path === "gateway.connection.get") { + if (path === "system.healthDiagnostics") { expect(input).toEqual({}); - return Promise.resolve({ - checkedAtMs: timestampMs, - connectedAtMs: timestampMs - 1000, - connectionGeneration: 1, - freshness: "fresh", - lastActivityAtMs: timestampMs, - phase: "connected", - reconnectAttempt: 0, - }); + healthStatusCalls += 1; + return healthUnavailable + ? Promise.reject(new Error("Health refresh unavailable")) + : Promise.resolve(healthDiagnostics(timestampMs)); } if (path === "jobs.listRuns") { expect(input).toEqual({ limit: 1 }); @@ -203,7 +243,7 @@ describe("Dashboard browser application", () => { screen.getByRole("button", { name: "Notifications, none unread" }) ).toBeTruthy(); const statusButton = await screen.findByRole("button", { - name: "System status: one or more systems need attention. Open details", + name: "System status: all systems online. Open details", }); expect(screen.getByRole("button", { name: "Log out" })).toBeTruthy(); await userEvent.click(statusButton); @@ -213,22 +253,26 @@ describe("Dashboard browser application", () => { expect(screen.getByText("Dashboard backend")).toBeTruthy(); expect(screen.getByText("Dashboard worker")).toBeTruthy(); expect(screen.getByText("OpenClaw Gateway")).toBeTruthy(); - const statusValues = [ - ...screen.getAllByText("Online ●"), - screen.getByText("Needs attention ○"), - ]; + const statusValues = screen.getAllByText("Online ●"); expect(statusValues).toHaveLength(3); for (const statusValue of statusValues) { expect(statusValue).toHaveClass("text-xs", "leading-5", "font-medium"); expect(statusValue).not.toHaveClass("text-sm"); } - expect(readinessFetch).toHaveBeenCalledWith( - "/api/health/ready", - expect.objectContaining({ - cache: "no-store", - credentials: "same-origin", - }) + expect(healthStatusCalls).toBe(1); + await act(async () => { + healthUnavailable = true; + await queryClient.refetchQueries({ + queryKey: dashboardHealthDiagnosticsQueryKey, + }); + }); + await waitFor(() => + expect(statusButton).toHaveAttribute( + "aria-label", + "System status: last known status is stale. Open details" + ) ); + expect(screen.getAllByText("Stale ○")).toHaveLength(3); await userEvent.click(screen.getByRole("button", { name: "Log out" })); expect(logoutCalls).toBe(1); expect(settleLogout).toBeDefined(); @@ -250,7 +294,6 @@ describe("Dashboard browser application", () => { view.unmount(); await collections.cleanup(); queryClient.clear(); - readinessFetch.mockRestore(); } }); @@ -276,16 +319,12 @@ describe("Dashboard browser application", () => { const secondAuthenticationCheck = Promise.withResolvers(); let authenticationCalls = 0; let deferAuthenticationChecks = false; - let gatewayStatusCalls = 0; + let healthStatusCalls = 0; let notificationCalls = 0; - let workerStatusCalls = 0; const queryClient = createDashboardQueryClient(); const router = createDashboardRouter( createMemoryHistory({ initialEntries: ["/agents"] }) ); - const readinessFetch = jest - .spyOn(globalThis, "fetch") - .mockResolvedValue(Response.json({ status: "ready" })); const trpcClient = createDashboardTrpcClient({ mutation(path) { return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); @@ -307,17 +346,11 @@ describe("Dashboard browser application", () => { case "agents.listTaskHistory": { return Promise.resolve({ runs: [] }); } - case "gateway.connection.get": { - gatewayStatusCalls += 1; - return Promise.resolve({ - checkedAtMs: timestampMs, - connectedAtMs: timestampMs, - connectionGeneration: 1, - freshness: "fresh" as const, - lastActivityAtMs: timestampMs, - phase: "connected" as const, - reconnectAttempt: 0, - }); + case "system.healthDiagnostics": { + healthStatusCalls += 1; + return Promise.resolve( + healthDiagnostics(timestampMs, { workerReady: false }) + ); } case "gatewaySessions.list": { return Promise.resolve({ @@ -333,29 +366,6 @@ describe("Dashboard browser application", () => { stats: deriveGatewaySessionStats([], timestampMs), }); } - case "jobs.listRuns": { - workerStatusCalls += 1; - return Promise.resolve({ - runs: [], - summary: { - activeResourceClasses: [], - control: { - claimingPaused: false, - updatedAtMs: timestampMs, - version: 1, - }, - stateCounts: { - cancelled: 0, - failed: 0, - queued: 0, - running: 0, - succeeded: 0, - "timed-out": 0, - }, - workers: [], - }, - }); - } case "notifications.list": { notificationCalls += 1; return Promise.resolve({ @@ -399,10 +409,8 @@ describe("Dashboard browser application", () => { name: "Notifications", }); const authenticationCallsBeforeNavigation = authenticationCalls; - const gatewayStatusCallsBeforeNavigation = gatewayStatusCalls; + const healthStatusCallsBeforeNavigation = healthStatusCalls; const notificationCallsBeforeNavigation = notificationCalls; - const readinessCallsBeforeNavigation = readinessFetch.mock.calls.length; - const workerStatusCallsBeforeNavigation = workerStatusCalls; deferAuthenticationChecks = true; await act(async () => { @@ -425,10 +433,8 @@ describe("Dashboard browser application", () => { expect(notificationButton).toBeVisible(); expect(notificationButton).toHaveAttribute("aria-expanded", "true"); expect(notificationHeading).toBeVisible(); - expect(gatewayStatusCalls).toBe(gatewayStatusCallsBeforeNavigation); + expect(healthStatusCalls).toBe(healthStatusCallsBeforeNavigation); expect(notificationCalls).toBe(notificationCallsBeforeNavigation); - expect(readinessFetch).toHaveBeenCalledTimes(readinessCallsBeforeNavigation); - expect(workerStatusCalls).toBe(workerStatusCallsBeforeNavigation); await act(async () => { secondAuthenticationCheck.resolve(authentication); @@ -446,42 +452,14 @@ describe("Dashboard browser application", () => { ).toBe(notificationButton); expect(notificationButton).toHaveAttribute("aria-expanded", "true"); expect(notificationHeading).toBeVisible(); - expect(gatewayStatusCalls).toBe(gatewayStatusCallsBeforeNavigation); + expect(healthStatusCalls).toBe(healthStatusCallsBeforeNavigation); expect(notificationCalls).toBe(notificationCallsBeforeNavigation); - expect(readinessFetch).toHaveBeenCalledTimes(readinessCallsBeforeNavigation); - expect(workerStatusCalls).toBe(workerStatusCallsBeforeNavigation); act(() => { - queryClient.setQueryData(jobQueueSummaryQueryKey, { - runs: [], - summary: { - activeResourceClasses: [], - control: { - claimingPaused: false, - updatedAtMs: timestampMs, - version: 2, - }, - stateCounts: { - cancelled: 0, - failed: 0, - queued: 0, - running: 0, - succeeded: 0, - "timed-out": 0, - }, - workers: [ - { - activeRunCount: 0, - capacity: 1, - heartbeatAtMs: timestampMs, - id: "019fe300-0000-7000-8000-000000000001", - releaseId: "a".repeat(40), - startedAtMs: timestampMs, - state: "online" as const, - }, - ], - }, - }); + queryClient.setQueryData( + dashboardHealthDiagnosticsQueryKey, + healthDiagnostics(timestampMs) + ); queryClient.setQueryData(notificationLatestQueryKey, { notifications: [], readCount: 4, @@ -515,7 +493,6 @@ describe("Dashboard browser application", () => { view.unmount(); await collections.cleanup(); queryClient.clear(); - readinessFetch.mockRestore(); } }); diff --git a/greenfield/src/browser/layout/DashboardHeaderControls.tsx b/greenfield/src/browser/layout/DashboardHeaderControls.tsx index a87c9aabe..914f0d8f5 100644 --- a/greenfield/src/browser/layout/DashboardHeaderControls.tsx +++ b/greenfield/src/browser/layout/DashboardHeaderControls.tsx @@ -7,7 +7,6 @@ import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; import { useObservedQueryData } from "../api/useObservedQueryState.ts"; import { authStatusQueryKey, publishAuthenticationStatus } from "../auth/authQueries.ts"; import { useExclusiveDashboardAction } from "../hooks/useExclusiveDashboardAction.ts"; -import { jobQueueSummaryQueryOptions } from "../jobs/jobQueries.ts"; import { cn } from "../lib/classNames.ts"; import { AuthenticatedNotificationCenter } from "../notifications/AuthenticatedNotificationCenter.tsx"; import { Button } from "../ui/Button.tsx"; @@ -16,8 +15,8 @@ import { Icon } from "../ui/Icon.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/Popover.tsx"; import { Text } from "../ui/Text.tsx"; import { - dashboardGatewayConnectionQueryOptions, - dashboardReadinessQueryOptions, + dashboardHealthDiagnosticsQueryOptions, + dashboardHealthSnapshotIsStale, projectDashboardSystemStatus, type DashboardSystemComponentState, } from "./dashboardSystemStatus.ts"; @@ -28,12 +27,14 @@ const componentLabels: Readonly> = Object.freeze({ offline: "Needs attention", online: "Online", + stale: "Stale", unavailable: "Checking", }); const overallLabels: Readonly> = Object.freeze({ offline: "one or more systems need attention", online: "all systems online", + stale: "last known status is stale", unavailable: "status unavailable", }); @@ -45,6 +46,9 @@ function statusClassName(state: DashboardSystemComponentState): string { case "offline": { return "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"; } + case "stale": { + return "border-amber-500/40 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"; + } case "unavailable": { return "border-amber-500/40 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"; } @@ -68,6 +72,7 @@ function StatusRow({ label, state }: StatusRowProps) { "font-medium", state === "online" && "text-green-300", state === "offline" && "text-red-300", + state === "stale" && "text-amber-200", state === "unavailable" && "text-amber-200" )} size="sm" @@ -92,15 +97,16 @@ function AuthenticatedDashboardHeaderControls() { const client = useDashboardTrpcClient(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const readiness = useQuery(dashboardReadinessQueryOptions()); - const gateway = useQuery(dashboardGatewayConnectionQueryOptions(client)); - const worker = useQuery(jobQueueSummaryQueryOptions(client)); - const status = projectDashboardSystemStatus({ - backendReady: - readiness.data === undefined ? undefined : readiness.data.status === "ready", - gateway: gateway.data, - workerSummary: worker.data, - }); + const health = useQuery(dashboardHealthDiagnosticsQueryOptions(client)); + const status = projectDashboardSystemStatus( + health.data, + dashboardHealthSnapshotIsStale({ + fetchStatus: health.fetchStatus, + hasData: health.data !== undefined, + isError: health.isError, + isStale: health.isStale, + }) + ); const overallLabel = overallLabels[status.overall]; async function logout(): Promise { diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx index c81376806..d384d27a1 100644 --- a/greenfield/src/browser/layout/DashboardShell.tsx +++ b/greenfield/src/browser/layout/DashboardShell.tsx @@ -1,6 +1,7 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react"; import { Outlet, useLocation } from "@tanstack/react-router"; import { + BookOpen, Bot, CalendarClock, FolderOpen, @@ -48,6 +49,7 @@ const navigationItems: readonly NavigationItem[] = Object.freeze([ { icon: ListTodo, label: "Tasks", to: "/tasks" }, { icon: CalendarClock, label: "Jobs", to: "/jobs" }, { icon: Logs, label: "Logs", to: "/logs" }, + { icon: BookOpen, label: "Moltbook", to: "/moltbook" }, { icon: SquareTerminal, label: "Terminal", to: "/terminal" }, { icon: Newspaper, label: "Reports", to: "/reports" }, { icon: ShieldCheck, label: "Account security", to: "/account-security" }, diff --git a/greenfield/src/browser/layout/dashboardSystemStatus.test.ts b/greenfield/src/browser/layout/dashboardSystemStatus.test.ts index 307e0242c..334e0255b 100644 --- a/greenfield/src/browser/layout/dashboardSystemStatus.test.ts +++ b/greenfield/src/browser/layout/dashboardSystemStatus.test.ts @@ -1,52 +1,102 @@ import { describe, expect, test } from "bun:test"; -import type { GatewayConnectionSnapshot } from "../../contracts/gatewayConnection.ts"; -import type { JobQueueSummary } from "../../contracts/jobs.ts"; -import { projectDashboardSystemStatus } from "./dashboardSystemStatus.ts"; +import type { SystemHealthDiagnostics } from "../../contracts/system.ts"; +import { + dashboardHealthSnapshotIsStale, + projectDashboardSystemStatus, +} from "./dashboardSystemStatus.ts"; const observedAtMs = 1_800_000_000_000; -const gateway = Object.freeze({ +const diagnostics = Object.freeze({ checkedAtMs: observedAtMs, - connectedAtMs: observedAtMs - 1000, - connectionGeneration: 2, - freshness: "fresh", - lastActivityAtMs: observedAtMs, - phase: "connected", - reconnectAttempt: 0, -} satisfies GatewayConnectionSnapshot); -const workerSummary = Object.freeze({ - activeResourceClasses: [], - control: { claimingPaused: false, updatedAtMs: observedAtMs, version: 1 }, - stateCounts: { - cancelled: 0, - failed: 0, - queued: 0, - running: 0, - succeeded: 0, - "timed-out": 0, + checks: { + application: { status: "ready" }, + database: { status: "ready" }, + frontend: { status: "ready" }, + release: { status: "verified" }, + worker: { status: "ready" }, }, - workers: [ - { - activeRunCount: 0, + dependencies: { + gateway: { + freshness: "fresh", + phase: "connected", + status: "observed", + }, + sessions: { + count: 1, + observedAtMs, + state: "fresh", + truncated: false, + }, + }, + queue: { + claimingPaused: false, + runs: { queued: 0, running: 0 }, + status: "observed", + workers: { capacity: 1, - heartbeatAtMs: observedAtMs, - id: "019fe300-0000-7000-8000-000000000001", - releaseId: "a".repeat(40), - startedAtMs: observedAtMs - 60_000, - state: "online", + drainingCount: 0, + freshCount: 1, + onlineCount: 1, }, - ], -} satisfies JobQueueSummary); + }, + status: "ready", +} as const satisfies SystemHealthDiagnostics); describe("Dashboard system status projection", () => { - test("reports online only when every bounded observation is online", () => { + test("marks only retained snapshots without a current observation stale", () => { expect( - projectDashboardSystemStatus({ - backendReady: true, - gateway, - workerSummary, + dashboardHealthSnapshotIsStale({ + fetchStatus: "idle", + hasData: false, + isError: true, + isStale: true, + }) + ).toBe(false); + expect( + dashboardHealthSnapshotIsStale({ + fetchStatus: "fetching", + hasData: true, + isError: false, + isStale: true, + }) + ).toBe(false); + expect( + dashboardHealthSnapshotIsStale({ + fetchStatus: "idle", + hasData: true, + isError: false, + isStale: true, }) - ).toEqual({ + ).toBe(true); + expect( + dashboardHealthSnapshotIsStale({ + fetchStatus: "paused", + hasData: true, + isError: false, + isStale: false, + }) + ).toBe(true); + expect( + dashboardHealthSnapshotIsStale({ + fetchStatus: "idle", + hasData: true, + isError: true, + isStale: false, + }) + ).toBe(true); + expect( + dashboardHealthSnapshotIsStale({ + fetchStatus: "idle", + hasData: true, + isError: false, + isStale: false, + }) + ).toBe(false); + }); + + test("reports online only when every bounded observation is online", () => { + expect(projectDashboardSystemStatus(diagnostics)).toEqual({ backend: "online", gateway: "online", overall: "online", @@ -54,8 +104,8 @@ describe("Dashboard system status projection", () => { }); }); - test("does not treat missing observations as healthy", () => { - expect(projectDashboardSystemStatus({})).toEqual({ + test("does not treat a missing diagnostic snapshot as healthy", () => { + expect(projectDashboardSystemStatus(undefined)).toEqual({ backend: "unavailable", gateway: "unavailable", overall: "unavailable", @@ -63,16 +113,60 @@ describe("Dashboard system status projection", () => { }); }); - test("surfaces a paused worker or degraded Gateway as attention", () => { + test("marks retained healthy data stale after a failed background refresh", () => { + expect(projectDashboardSystemStatus(diagnostics, true)).toEqual({ + backend: "stale", + gateway: "stale", + overall: "stale", + worker: "stale", + }); + }); + + test("does not treat intentionally paused claiming as a worker outage", () => { + expect( + projectDashboardSystemStatus({ + ...diagnostics, + queue: { ...diagnostics.queue, claimingPaused: true }, + }) + ).toMatchObject({ overall: "online", worker: "online" }); + }); + + test("surfaces a degraded Gateway as attention", () => { + expect( + projectDashboardSystemStatus({ + ...diagnostics, + dependencies: { + ...diagnostics.dependencies, + gateway: { + freshness: "stale", + phase: "degraded", + status: "observed", + }, + }, + }) + ).toMatchObject({ gateway: "offline", overall: "offline", worker: "online" }); + }); + + test("keeps unavailable backend checks distinct from an observed offline process", () => { + expect( + projectDashboardSystemStatus({ + ...diagnostics, + checks: { + ...diagnostics.checks, + database: { status: "unavailable" }, + }, + status: "not-ready", + }) + ).toMatchObject({ backend: "unavailable", overall: "unavailable" }); expect( projectDashboardSystemStatus({ - backendReady: true, - gateway: { ...gateway, freshness: "stale", phase: "degraded" }, - workerSummary: { - ...workerSummary, - control: { ...workerSummary.control, claimingPaused: true }, + ...diagnostics, + checks: { + ...diagnostics.checks, + application: { status: "not-ready" }, }, + status: "not-ready", }) - ).toMatchObject({ gateway: "offline", overall: "offline", worker: "offline" }); + ).toMatchObject({ backend: "offline", overall: "offline" }); }); }); diff --git a/greenfield/src/browser/layout/dashboardSystemStatus.ts b/greenfield/src/browser/layout/dashboardSystemStatus.ts index 518b1a78e..046652a61 100644 --- a/greenfield/src/browser/layout/dashboardSystemStatus.ts +++ b/greenfield/src/browser/layout/dashboardSystemStatus.ts @@ -1,20 +1,20 @@ import { queryOptions } from "@tanstack/react-query"; -import * as v from "valibot"; -import type { GatewayConnectionSnapshot } from "../../contracts/gatewayConnection.ts"; -import type { JobQueueSummary } from "../../contracts/jobs.ts"; -import { readinessStatusSchema } from "../../contracts/system.ts"; +import type { SystemHealthDiagnostics } from "../../contracts/system.ts"; import type { DashboardTrpcClient } from "../api/trpcClient.ts"; const systemStatusRefreshIntervalMs = 15_000; -export const dashboardReadinessQueryKey = ["system-status", "readiness"] as const; -export const dashboardGatewayConnectionQueryKey = [ +export const dashboardHealthDiagnosticsQueryKey = [ "system-status", - "gateway-connection", + "health-diagnostics", ] as const; -export type DashboardSystemComponentState = "offline" | "online" | "unavailable"; +export type DashboardSystemComponentState = + | "offline" + | "online" + | "stale" + | "unavailable"; export interface DashboardSystemStatus { readonly backend: DashboardSystemComponentState; @@ -24,43 +24,32 @@ export interface DashboardSystemStatus { } /** - * @param fetcher Injectable same-origin fetch implementation. - * @returns Bounded same-origin web-process readiness query options. + * @param query Retained-data and fetch state from the health query observer. + * @returns Whether a prior snapshot is no longer backed by a current observation. */ -export function dashboardReadinessQueryOptions( - fetcher: typeof globalThis.fetch = globalThis.fetch -) { - return queryOptions({ - queryFn: async ({ signal }) => { - const response = await fetcher("/api/health/ready", { - cache: "no-store", - credentials: "same-origin", - signal, - }); - const candidate: unknown = await response.json(); - const parsed = v.safeParse(readinessStatusSchema, candidate); - if (!parsed.success || (response.status !== 200 && response.status !== 503)) { - throw new TypeError("Dashboard readiness response is invalid"); - } - return parsed.output; - }, - queryKey: dashboardReadinessQueryKey, - refetchInterval: systemStatusRefreshIntervalMs, - refetchIntervalInBackground: false, - retry: false, - staleTime: systemStatusRefreshIntervalMs, - }); +export function dashboardHealthSnapshotIsStale(query: { + readonly fetchStatus: "fetching" | "idle" | "paused"; + readonly hasData: boolean; + readonly isError: boolean; + readonly isStale: boolean; +}): boolean { + return ( + query.hasData && + (query.isError || + query.fetchStatus === "paused" || + (query.isStale && query.fetchStatus === "idle")) + ); } /** * @param client Validated Dashboard transport client. - * @returns Sanitized native Gateway connection query options for the header. + * @returns Session-only bounded health diagnostics query options for the header. */ -export function dashboardGatewayConnectionQueryOptions(client: DashboardTrpcClient) { +export function dashboardHealthDiagnosticsQueryOptions(client: DashboardTrpcClient) { return queryOptions({ - queryFn: ({ signal }): Promise => - client.query("gateway.connection.get", {}, { signal }), - queryKey: dashboardGatewayConnectionQueryKey, + queryFn: ({ signal }): Promise => + client.query("system.healthDiagnostics", {}, { signal }), + queryKey: dashboardHealthDiagnosticsQueryKey, refetchInterval: systemStatusRefreshIntervalMs, refetchIntervalInBackground: false, retry: false, @@ -71,52 +60,77 @@ export function dashboardGatewayConnectionQueryOptions(client: DashboardTrpcClie function overallSystemState( states: readonly DashboardSystemComponentState[] ): DashboardSystemComponentState { - if (states.every((state) => state === "online")) return "online"; if (states.some((state) => state === "offline")) return "offline"; + if (states.every((state) => state === "online")) return "online"; + if ( + states.every((state) => state === "online" || state === "stale") && + states.some((state) => state === "stale") + ) { + return "stale"; + } return "unavailable"; } -function observedBooleanState( - observed: boolean | undefined +function staleState( + state: DashboardSystemComponentState, + stale: boolean ): DashboardSystemComponentState { - if (observed === undefined) return "unavailable"; - return observed ? "online" : "offline"; + return stale && state === "online" ? "stale" : state; } function gatewayState( - gateway: GatewayConnectionSnapshot | undefined + diagnostics: SystemHealthDiagnostics | undefined ): DashboardSystemComponentState { - if (gateway === undefined) return "unavailable"; + const gateway = diagnostics?.dependencies.gateway; + if (gateway === undefined || gateway.status === "unavailable") { + return "unavailable"; + } return gateway.phase === "connected" && gateway.freshness === "fresh" ? "online" : "offline"; } function workerState( - workerSummary: JobQueueSummary | undefined + diagnostics: SystemHealthDiagnostics | undefined +): DashboardSystemComponentState { + if (diagnostics === undefined) return "unavailable"; + if ( + diagnostics.checks.worker.status === "unavailable" || + diagnostics.queue.status === "unavailable" + ) { + return "unavailable"; + } + return diagnostics.checks.worker.status === "ready" ? "online" : "offline"; +} + +function backendState( + diagnostics: SystemHealthDiagnostics | undefined ): DashboardSystemComponentState { - if (workerSummary === undefined) return "unavailable"; - return workerSummary.control.claimingPaused || - !workerSummary.workers.some( - ({ state }) => state === "online" || state === "draining" - ) - ? "offline" - : "online"; + if (diagnostics === undefined) return "unavailable"; + const checks = diagnostics.checks; + if ( + checks.database.status === "unavailable" || + checks.frontend.status === "unavailable" || + checks.release.status === "unavailable" + ) { + return "unavailable"; + } + return checks.application.status === "ready" ? "online" : "offline"; } /** * Projects only directly observed backend, worker, and Gateway availability. - * @param input Current bounded observations. + * @param diagnostics Current bounded diagnostic snapshot, when observed. + * @param stale Whether the retained snapshot survived a failed background refresh. * @returns Honest aggregate without treating a missing observation as healthy. */ -export function projectDashboardSystemStatus(input: { - readonly backendReady?: boolean; - readonly gateway?: GatewayConnectionSnapshot; - readonly workerSummary?: JobQueueSummary; -}): DashboardSystemStatus { - const backend = observedBooleanState(input.backendReady); - const gateway = gatewayState(input.gateway); - const worker = workerState(input.workerSummary); +export function projectDashboardSystemStatus( + diagnostics: SystemHealthDiagnostics | undefined, + stale = false +): DashboardSystemStatus { + const backend = staleState(backendState(diagnostics), stale); + const gateway = staleState(gatewayState(diagnostics), stale); + const worker = staleState(workerState(diagnostics), stale); return { backend, gateway, diff --git a/greenfield/src/browser/moltbook/MoltbookCards.tsx b/greenfield/src/browser/moltbook/MoltbookCards.tsx new file mode 100644 index 000000000..0775521c7 --- /dev/null +++ b/greenfield/src/browser/moltbook/MoltbookCards.tsx @@ -0,0 +1,215 @@ +import { MessageSquare, Star, UserRound, UsersRound } from "lucide-react"; + +import type { + MoltbookFeedPost, + MoltbookHome, + MoltbookOwnComment, + MoltbookOwnPost, + MoltbookProfile, +} from "../../contracts/moltbook.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { ExternalLink } from "../ui/ExternalLink.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Text } from "../ui/Text.tsx"; +import { + formatMoltbookTime, + moltbookCommentUrl, + moltbookPostUrl, + moltbookProfileUrl, + moltbookSubmoltUrl, + truncateMoltbookText, +} from "./moltbookPresentation.ts"; + +function MoltbookVoteCounts({ + downvotes, + upvotes, +}: Readonly<{ downvotes: number; upvotes: number }>) { + return ( + <> + + + {upvotes} + {" "} + ·{" "} + + + {downvotes} + + + ); +} + +export function MoltbookProfileCard({ + home, + profile, +}: Readonly<{ home: MoltbookHome; profile: MoltbookProfile }>) { + return ( + + + + +
+
+ + {profile.displayName} + + {home.unreadMessageCount > 0 && ( + + {home.unreadMessageCount} unread messages + + )} + {home.unreadNotificationCount > 0 && ( + + {home.unreadNotificationCount} unread notifications + + )} +
+ + {profile.description || `@${profile.name}`} + +
+ + + {profile.karma} karma + + + + {profile.followerCount} followers + + + + {profile.followingCount} following + +
+
+
+ ); +} + +export function MoltbookFeedPostCard({ post }: Readonly<{ post: MoltbookFeedPost }>) { + const score = post.upvotes - post.downvotes; + return ( + + = 0 ? "info" : "danger"}> + {score} + +
+
+ + m/{post.submoltName} + + + · + + + {post.author.displayName ?? post.author.name} + + + · {formatMoltbookTime(post.createdAtMs)} + +
+ + + {post.title} + + {post.contentPreview !== "" && ( + + {post.contentPreview} + + )} + + + + {post.commentCount} comments + +
+
+ ); +} + +export function MoltbookOwnPostCard({ post }: Readonly<{ post: MoltbookOwnPost }>) { + return ( + +
+ + m/{post.submoltName} + + + · {formatMoltbookTime(post.createdAtMs)} + +
+ + + {post.title} + + {post.contentPreview !== "" && ( + + {post.contentPreview} + + )} + + + ·{" "} + {post.commentCount} comments + +
+ ); +} + +export function MoltbookOwnCommentCard({ + comment, +}: Readonly<{ comment: MoltbookOwnComment }>) { + return ( + + + Commented on{" "} + + {comment.post.title} + {" "} + · {formatMoltbookTime(comment.createdAtMs)} + + + + {truncateMoltbookText(comment.content)} + + + + + + + ); +} diff --git a/greenfield/src/browser/moltbook/MoltbookRoute.test.tsx b/greenfield/src/browser/moltbook/MoltbookRoute.test.tsx new file mode 100644 index 000000000..afd30aa28 --- /dev/null +++ b/greenfield/src/browser/moltbook/MoltbookRoute.test.tsx @@ -0,0 +1,204 @@ +import { expect, test } from "bun:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { MoltbookSnapshotResult } from "../../contracts/moltbook.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; +import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { Route as moltbookLazyRoute } from "../routes/moltbook.lazy.tsx"; +import { moltbookSnapshotQueryKey } from "./moltbookQueries.ts"; +import { MoltbookRoute } from "./MoltbookRoute.tsx"; + +const { act, render, screen, waitFor } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const status = Object.freeze({ + freshness: "stale" as const, + lastAttemptAtMs: 2000, + lastAttemptStatus: "failed" as const, + lastSuccessAtMs: 1000, + refreshFailureMessage: "Moltbook dashboard projection could not be collected.", +}); + +test("Moltbook route renders LKG state, encoded links, and independent content tabs", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const updatedAt = Date.now(); + const snapshot = { + content: { + comments: [ + { + content: "My comment", + createdAtMs: updatedAt - 120_000, + downvotes: 0, + id: "comment/one", + post: { + id: "post/one", + submoltName: "agent/news", + title: "A feed post", + }, + upvotes: 2, + }, + ], + posts: [ + { + commentCount: 4, + contentPreview: "My post preview", + createdAtMs: updatedAt - 180_000, + downvotes: 0, + id: "mine/one", + submoltName: "agent/news", + title: "My authored post", + upvotes: 5, + }, + ], + }, + feed: { + hasMore: false, + posts: [ + { + author: { name: "ada/lovelace" }, + commentCount: 4, + contentPreview: "A bounded preview", + createdAtMs: updatedAt - 60_000, + downvotes: 1, + id: "post/one", + submoltName: "agent/news", + title: "A feed post", + upvotes: 8, + }, + ], + sort: "hot", + }, + home: { + activityOnYourPostsCount: 0, + exploreCount: 0, + nextActions: [], + pendingRequestCount: 0, + postsFromAccountsYouFollowCount: 0, + unreadMessageCount: 2, + unreadNotificationCount: 3, + }, + profile: { + commentsCount: 1, + description: "Dashboard agent", + displayName: "Mira", + followerCount: 10, + followingCount: 2, + karma: 42, + name: "mira/2026", + postsCount: 1, + }, + status, + } as const satisfies MoltbookSnapshotResult; + queryClient.setQueryData(moltbookSnapshotQueryKey("hot"), snapshot, { + updatedAt, + }); + const failedFeed = Promise.withResolvers(); + const retriedFeed = Promise.withResolvers(); + let newFeedRequests = 0; + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query: (name: string, input: unknown) => { + if ( + name !== "moltbook.snapshot" || + typeof input !== "object" || + input === null || + !("sort" in input) || + input.sort !== "new" + ) { + return Promise.reject(new Error(`Unexpected query: ${name}`)); + } + + const response = + newFeedRequests === 0 ? failedFeed.promise : retriedFeed.promise; + newFeedRequests += 1; + return response; + }, + } as unknown as DashboardTrpcClient; + const view = render( + + + + + + ); + + try { + expect( + await screen.findByRole("heading", { level: 1, name: "Moltbook" }) + ).toBeVisible(); + expect(screen.getByText("Last-known-good snapshot")).toBeVisible(); + expect(screen.getByText("2 unread messages")).toBeVisible(); + expect(screen.getByText("3 unread notifications")).toBeVisible(); + expect(screen.getByRole("link", { name: /Open Mira/u })).toHaveAttribute( + "href", + "https://www.moltbook.com/u/mira%2F2026" + ); + expect(screen.getByRole("link", { name: /A feed post/u })).toHaveAttribute( + "href", + "https://www.moltbook.com/post/post%2Fone" + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole("tab", { name: /Posts/u })); + expect(screen.getByText("My authored post")).toBeVisible(); + expect(screen.getByLabelText("5 upvotes")).toBeVisible(); + await user.click(screen.getByRole("tab", { name: /Comments/u })); + expect(screen.getByText("My comment")).toBeVisible(); + expect(screen.getByLabelText("2 upvotes")).toBeVisible(); + + await user.click(screen.getByRole("tab", { name: /Feed/u })); + const newTab = screen.getByRole("tab", { name: "New" }); + await user.click(newTab); + expect(screen.getByText("A feed post")).toBeVisible(); + expect(screen.getByRole("tab", { name: /Comments/u })).toBeVisible(); + expect(newTab).toHaveFocus(); + + act(() => { + failedFeed.reject(new Error("Moltbook New feed fixture failed")); + }); + expect( + await screen.findByText( + "The new feed could not be loaded; showing hot feed data." + ) + ).toBeVisible(); + expect(screen.getByText("A feed post")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(newFeedRequests).toBe(2)); + act(() => { + retriedFeed.resolve({ + ...snapshot, + feed: { + hasMore: false, + posts: [ + { + author: { name: "grace" }, + commentCount: 0, + contentPreview: "Newest bounded preview", + createdAtMs: updatedAt, + downvotes: 0, + id: "post-new", + submoltName: "agents", + title: "Newest post", + upvotes: 1, + }, + ], + sort: "new", + }, + }); + }); + expect(await screen.findByText("Newest post")).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + +test("Moltbook route is registered through its authenticated lazy boundary", () => { + expect(moltbookLazyRoute.options.id).toBe("/moltbook"); + expect(moltbookLazyRoute.options.component).toBeFunction(); +}); diff --git a/greenfield/src/browser/moltbook/MoltbookRoute.tsx b/greenfield/src/browser/moltbook/MoltbookRoute.tsx new file mode 100644 index 000000000..6b56fa831 --- /dev/null +++ b/greenfield/src/browser/moltbook/MoltbookRoute.tsx @@ -0,0 +1,253 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Flame, MessageCircle, MessageSquare, Newspaper, RotateCw } from "lucide-react"; +import { useState } from "react"; + +import type { + MoltbookFeedPost, + MoltbookOwnComment, + MoltbookOwnPost, + MoltbookSnapshotResult, + MoltbookSnapshotStatus, +} from "../../contracts/moltbook.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Tabs } from "../ui/Tabs.tsx"; +import { + MoltbookFeedPostCard, + MoltbookOwnCommentCard, + MoltbookOwnPostCard, + MoltbookProfileCard, +} from "./MoltbookCards.tsx"; +import { + moltbookSnapshotQueryKey, + moltbookSnapshotQueryOptions, + refreshMoltbookQueries, +} from "./moltbookQueries.ts"; + +function MoltbookSnapshotNotice({ status }: { readonly status: MoltbookSnapshotStatus }) { + if (status.freshness === "fresh" && status.lastAttemptStatus === "succeeded") { + return null; + } + const message = + status.lastAttemptStatus === "failed" + ? (status.refreshFailureMessage ?? + "The latest Moltbook refresh failed; showing last-known-good data.") + : "Moltbook data is stale; showing the last-known-good snapshot."; + return ; +} + +function MoltbookSnapshotBadge({ status }: { readonly status: MoltbookSnapshotStatus }) { + const isCurrent = + status.freshness === "fresh" && status.lastAttemptStatus === "succeeded"; + return ( + + {isCurrent ? "Fresh snapshot" : "Last-known-good snapshot"} + + ); +} + +function MoltbookFeedList({ posts }: { readonly posts: readonly MoltbookFeedPost[] }) { + return posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( + + ))} +
+ ); +} + +function MoltbookOwnPostList({ posts }: { readonly posts: readonly MoltbookOwnPost[] }) { + return posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( + + ))} +
+ ); +} + +function MoltbookOwnCommentList({ + comments, +}: { + readonly comments: readonly MoltbookOwnComment[]; +}) { + return comments.length === 0 ? ( + + ) : ( +
+ {comments.map((comment) => ( + + ))} +
+ ); +} + +/** @returns Read-only Moltbook profile, feed, posts, and comments from durable LKG state. */ +export function MoltbookRoute() { + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const [content, setContent] = useState<"comments" | "feed" | "posts">("feed"); + const [sort, setSort] = useState<"hot" | "new">("hot"); + const snapshotQuery = useQuery(moltbookSnapshotQueryOptions(client, sort)); + const firstError = snapshotQuery.error; + const retainedSort = sort === "hot" ? "new" : "hot"; + const retainedSnapshot = queryClient.getQueryData( + moltbookSnapshotQueryKey(retainedSort) + ); + const ready = snapshotQuery.data ?? retainedSnapshot; + const complete = ready !== undefined; + const loading = snapshotQuery.isPending; + const fetching = snapshotQuery.isFetching; + const refresh = () => void refreshMoltbookQueries(queryClient); + + return ( +
+ + + Retry + + } + description="Read the configured agent's bounded Moltbook profile, feeds, posts, and comments from a durable worker-owned snapshot." + eyebrow="Community" + title="Moltbook" + /> +
+ {loading && !complete ? ( + + ) : null} + {!loading && firstError !== null && !complete ? ( + + ) : null} + {complete ? ( + +
+ {firstError === null ? null : ( + + )} + + + {ready.profile === undefined ? null : ( + + )} + + Feed + + ), + panel: ( + + {" "} + Hot + + ), + panel: ( + + ), + value: "hot", + }, + { + label: "New", + panel: ( + + ), + value: "new", + }, + ]} + value={sort} + /> + ), + value: "feed", + }, + { + label: ( + + {" "} + Posts + + ), + panel: ( + + ), + value: "posts", + }, + { + label: ( + + {" "} + Comments + + ), + panel: ( + + ), + value: "comments", + }, + ]} + value={content} + /> +
+
+ ) : null} +
+
+ ); +} diff --git a/greenfield/src/browser/moltbook/moltbookPresentation.test.ts b/greenfield/src/browser/moltbook/moltbookPresentation.test.ts new file mode 100644 index 000000000..512dfc4c4 --- /dev/null +++ b/greenfield/src/browser/moltbook/moltbookPresentation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import { formatMoltbookTime, truncateMoltbookText } from "./moltbookPresentation.ts"; + +describe("Moltbook presentation", () => { + test("truncates by Unicode code point without splitting surrogate pairs", () => { + expect(truncateMoltbookText("A😀B", 2)).toBe("A😀…"); + expect(truncateMoltbookText("A😀B", 3)).toBe("A😀B"); + }); + + test("contains invalid timestamps without breaking the card tree", () => { + expect(formatMoltbookTime(Number.NaN)).toBe("Unknown time"); + expect(formatMoltbookTime(Number.POSITIVE_INFINITY)).toBe("Unknown time"); + }); +}); diff --git a/greenfield/src/browser/moltbook/moltbookPresentation.ts b/greenfield/src/browser/moltbook/moltbookPresentation.ts new file mode 100644 index 000000000..1e990f9d8 --- /dev/null +++ b/greenfield/src/browser/moltbook/moltbookPresentation.ts @@ -0,0 +1,44 @@ +import { formatDistanceToNow } from "date-fns"; + +const moltbookGraphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme", +}); + +const moltbookOrigin = "https://www.moltbook.com"; + +function pathSegment(value: string): string { + return encodeURIComponent(value); +} + +export function moltbookProfileUrl(name: string): string { + return `${moltbookOrigin}/u/${pathSegment(name)}`; +} + +export function moltbookSubmoltUrl(name: string): string { + return `${moltbookOrigin}/m/${pathSegment(name)}`; +} + +export function moltbookPostUrl(id: string): string { + return `${moltbookOrigin}/post/${pathSegment(id)}`; +} + +export function moltbookCommentUrl(postId: string, commentId: string): string { + return `${moltbookPostUrl(postId)}#comment-${pathSegment(commentId)}`; +} + +export function formatMoltbookTime(timestampMs: number): string { + const date = new Date(timestampMs); + return Number.isFinite(timestampMs) && !Number.isNaN(date.getTime()) + ? formatDistanceToNow(date, { addSuffix: true }) + : "Unknown time"; +} + +export function truncateMoltbookText(text: string, maximumCharacters = 300): string { + const characters = Array.from( + moltbookGraphemeSegmenter.segment(text), + ({ segment }) => segment + ); + return characters.length <= maximumCharacters + ? text + : `${characters.slice(0, maximumCharacters).join("")}…`; +} diff --git a/greenfield/src/browser/moltbook/moltbookQueries.ts b/greenfield/src/browser/moltbook/moltbookQueries.ts new file mode 100644 index 000000000..5ecb57499 --- /dev/null +++ b/greenfield/src/browser/moltbook/moltbookQueries.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query"; + +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +export const moltbookQueryKey = ["moltbook"] as const; +const moltbookPollingIntervalMs = 30 * 60_000; + +export function moltbookSnapshotQueryKey(sort: "hot" | "new") { + return [...moltbookQueryKey, "snapshot", sort] as const; +} + +/** @returns The complete Moltbook page projection from one bounded cache read. */ +export function moltbookSnapshotQueryOptions( + client: DashboardTrpcClient, + sort: "hot" | "new" +) { + return queryOptions({ + placeholderData: keepPreviousData, + queryFn: ({ signal }) => client.query("moltbook.snapshot", { sort }, { signal }), + queryKey: moltbookSnapshotQueryKey(sort), + refetchInterval: moltbookPollingIntervalMs, + retry: false, + staleTime: moltbookPollingIntervalMs, + }); +} + +/** Refetches every browser projection without directly dispatching an upstream job. */ +export async function refreshMoltbookQueries(queryClient: QueryClient): Promise { + await queryClient.invalidateQueries({ queryKey: moltbookQueryKey }); +} diff --git a/greenfield/src/browser/router.tsx b/greenfield/src/browser/router.tsx index e2129b99b..afd4c52d9 100644 --- a/greenfield/src/browser/router.tsx +++ b/greenfield/src/browser/router.tsx @@ -49,6 +49,10 @@ const logsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/logs", }).lazy(() => import("./routes/logs.lazy.tsx").then((module) => module.Route)); +const moltbookRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/moltbook", +}).lazy(() => import("./routes/moltbook.lazy.tsx").then((module) => module.Route)); const terminalRoute = createRoute({ getParentRoute: () => rootRoute, path: "/terminal", @@ -82,6 +86,7 @@ const routeTree = rootRoute.addChildren([ incidentsRoute, jobsRoute, logsRoute, + moltbookRoute, reportsRoute, sessionsRoute, tasksRoute, diff --git a/greenfield/src/browser/routes/moltbook.lazy.tsx b/greenfield/src/browser/routes/moltbook.lazy.tsx new file mode 100644 index 000000000..eccdec3b8 --- /dev/null +++ b/greenfield/src/browser/routes/moltbook.lazy.tsx @@ -0,0 +1,14 @@ +import { createLazyRoute } from "@tanstack/react-router"; + +import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx"; +import { MoltbookRoute } from "../moltbook/MoltbookRoute.tsx"; + +export const Route = createLazyRoute("/moltbook")({ + component: function MoltbookRouteBoundary() { + return ( + + + + ); + }, +}); diff --git a/greenfield/src/contracts/cache.test.ts b/greenfield/src/contracts/cache.test.ts index 41c90abf1..15f1db8c2 100644 --- a/greenfield/src/contracts/cache.test.ts +++ b/greenfield/src/contracts/cache.test.ts @@ -4,6 +4,7 @@ import * as v from "valibot"; import { cacheEntrySchema, + cacheHeartbeatCronProjectionIsConsistent, cacheHeartbeatResultSchema, cacheStatusMaximumEntries, cacheStatusResultSchema, @@ -140,6 +141,33 @@ describe("cache contracts", () => { totalCount: 0, truncated: false, }, + dashboardJobs: { + items: [ + { + defaultEnabled: true, + disableIntent: { expiresAtMs: 1800, valid: false }, + enabled: false, + id: "cache.system-host", + latestRun: { + finishedAtMs: 1800, + firstStartedAtMs: 1600, + queuedAtMs: 1500, + state: "failed", + terminalCode: "provider-unavailable", + triggerType: "schedule", + updatedAtMs: 1800, + }, + nextRunAtMs: null, + state: "present", + }, + { + defaultEnabled: false, + id: "system.worker-smoke", + state: "missing", + }, + ], + state: "available", + }, gateway: { connection: { checkedAtMs: 2000, @@ -157,15 +185,123 @@ describe("cache contracts", () => { generatedAtMs: 2000, openClawCron: { count: 5, + health: { + disabledCount: 0, + enabledCount: 3, + inspectedCount: 3, + intendedDisabledCount: 0, + lastRunErrorCount: 0, + runningCount: 0, + staleRunningCount: 0, + synchronizationConflictCount: 0, + synchronizationPendingCount: 0, + truncated: true, + unexpectedDisabledCount: 0, + }, observedAtMs: 900, pendingSync: "unknown", staleSinceMs: 1500, state: "last-known-good", }, - schemaVersion: 1, + schemaVersion: 4, + tasks: { + items: [ + { + automation: { + cron: { state: "unavailable" }, + recurring: true, + }, + id: "019fc968-1a9b-7765-8f1b-d5b863b0e7b4", + priority: "high", + relevance: ["automation-linked", "agent-priority"], + status: "blocked", + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }, } as const; expect(v.parse(cacheHeartbeatResultSchema, heartbeat)).toEqual(heartbeat); + const futureLinkedRun = { + ...heartbeat, + gateway: { + ...heartbeat.gateway, + connection: { + checkedAtMs: 2000, + freshness: "fresh" as const, + phase: "connected" as const, + }, + }, + openClawCron: { + count: 1, + health: { + disabledCount: 0, + enabledCount: 1, + inspectedCount: 1, + intendedDisabledCount: 0, + lastRunErrorCount: 0, + runningCount: 0, + staleRunningCount: 0, + synchronizationConflictCount: 0, + synchronizationPendingCount: 0, + truncated: false, + unexpectedDisabledCount: 0, + }, + observedAtMs: 1900, + pendingSync: "none" as const, + state: "fresh" as const, + }, + tasks: { + ...heartbeat.tasks, + items: [ + { + ...heartbeat.tasks.items[0], + automation: { + cron: { + enabled: true, + lastRunAtMs: 1800, + nextRunAtMs: 5000, + state: "present" as const, + synchronization: "confirmed" as const, + }, + recurring: true, + }, + }, + ], + }, + }; + expect( + v.safeParse(cacheHeartbeatResultSchema, futureLinkedRun).success + ).toBeTrue(); + expect( + v.safeParse(cacheHeartbeatResultSchema, { + ...futureLinkedRun, + openClawCron: { + ...futureLinkedRun.openClawCron, + count: 2, + health: { + ...futureLinkedRun.openClawCron.health, + truncated: true, + }, + pendingSync: "unknown", + }, + tasks: { + ...futureLinkedRun.tasks, + items: [ + { + ...futureLinkedRun.tasks.items[0], + automation: { + cron: { state: "missing" }, + recurring: true, + }, + }, + ], + }, + }).success + ).toBeFalse(); + for (const invalid of [ { ...heartbeat, @@ -191,11 +327,282 @@ describe("cache contracts", () => { ...heartbeat, openClawCron: { pendingSync: "none", state: "unavailable" }, }, + { + ...heartbeat, + dashboardJobs: { + ...heartbeat.dashboardJobs, + items: [ + { + ...heartbeat.dashboardJobs.items[0], + disableIntent: { expiresAtMs: 1800, valid: true }, + }, + heartbeat.dashboardJobs.items[1], + ], + }, + }, + { + ...heartbeat, + tasks: { ...heartbeat.tasks, totalCount: 2, truncated: true }, + }, + { + ...heartbeat, + tasks: { + ...heartbeat.tasks, + items: [ + { + ...heartbeat.tasks.items[0], + relevance: ["agent-priority", "automation-linked"], + }, + ], + }, + }, + { + ...heartbeat, + tasks: { + ...heartbeat.tasks, + items: [ + { + ...heartbeat.tasks.items[0], + priority: "low", + relevance: ["automation-linked", "agent-priority"], + }, + ], + }, + }, + { + ...heartbeat, + tasks: { + ...heartbeat.tasks, + items: [ + { + ...heartbeat.tasks.items[0], + relevance: ["automation-linked", "owner-blocked"], + status: "todo", + }, + ], + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + defaultEnabled: true, + enabled: true, + id: "cache.system-host", + latestRun: heartbeat.dashboardJobs.items[0].latestRun, + nextRunAtMs: null, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + activeRun: { + queuedAtMs: 1500, + state: "queued", + updatedAtMs: 1700, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: heartbeat.dashboardJobs.items[0].latestRun, + nextRunAtMs: null, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + queuedAtMs: 1500, + state: "queued", + triggerType: "schedule", + updatedAtMs: 1700, + }, + nextRunAtMs: null, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: heartbeat.dashboardJobs.items[0].latestRun, + nextRunAtMs: 2500, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + defaultEnabled: true, + disableIntent: { expiresAtMs: 1800, valid: false }, + enabled: true, + id: "cache.system-host", + latestRun: heartbeat.dashboardJobs.items[0].latestRun, + nextRunAtMs: 2500, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + activeRun: { + queuedAtMs: 1500, + state: "running", + updatedAtMs: 1800, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + queuedAtMs: 1500, + state: "running", + triggerType: "schedule", + updatedAtMs: 1800, + }, + nextRunAtMs: null, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, + { + ...heartbeat, + dashboardJobs: { + items: [ + { + activeRun: { + queuedAtMs: 1500, + state: "queued", + updatedAtMs: 1700, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + firstStartedAtMs: 1600, + queuedAtMs: 1500, + state: "running", + triggerType: "schedule", + updatedAtMs: 1800, + }, + nextRunAtMs: null, + state: "present", + }, + heartbeat.dashboardJobs.items[1], + ], + state: "available", + }, + }, ]) { expect(v.safeParse(cacheHeartbeatResultSchema, invalid).success).toBeFalse(); } }); + test("allows last-known-good synchronization warnings to strengthen stale counts", () => { + const health = { + disabledCount: 0, + enabledCount: 1, + inspectedCount: 1, + intendedDisabledCount: 0, + lastRunErrorCount: 0, + runningCount: 0, + staleRunningCount: 0, + synchronizationConflictCount: 0, + synchronizationPendingCount: 0, + truncated: false, + unexpectedDisabledCount: 0, + } as const; + expect( + cacheHeartbeatCronProjectionIsConsistent({ + count: 1, + health, + observedAtMs: 1000, + pendingSync: "present", + staleSinceMs: 1100, + state: "last-known-good", + }) + ).toBeTrue(); + expect( + cacheHeartbeatCronProjectionIsConsistent({ + count: 1, + health: { ...health, synchronizationPendingCount: 1 }, + observedAtMs: 1000, + pendingSync: "unknown", + staleSinceMs: 1100, + state: "last-known-good", + }) + ).toBeFalse(); + expect( + cacheHeartbeatCronProjectionIsConsistent({ + count: 2, + health: { ...health, truncated: true }, + observedAtMs: 1000, + pendingSync: "none", + staleSinceMs: 1100, + state: "last-known-good", + }) + ).toBeFalse(); + expect( + cacheHeartbeatCronProjectionIsConsistent({ + count: 1, + health, + observedAtMs: 1000, + pendingSync: "unknown", + staleSinceMs: 1100, + state: "last-known-good", + }) + ).toBeTrue(); + expect( + cacheHeartbeatCronProjectionIsConsistent({ + count: 1, + health, + observedAtMs: 1000, + pendingSync: "present", + state: "fresh", + }) + ).toBeFalse(); + }); + test("accepts only canonical lost-response-safe refresh requests", () => { expect( v.parse(refreshCacheEntryInputSchema, { diff --git a/greenfield/src/contracts/cache.ts b/greenfield/src/contracts/cache.ts index b2f2938a5..a1dfa6bfa 100644 --- a/greenfield/src/contracts/cache.ts +++ b/greenfield/src/contracts/cache.ts @@ -17,10 +17,16 @@ import { gatewaySessionProjectionMaximum } from "./gatewaySessions.ts"; import { jobIdempotencyKeySchema, jobRunIdSchema, + jobRunStateSchema, jobRunSummarySchema, + jobRunTerminalCodeSchema, + jobTriggerTypeSchema, + scheduleIdSchema, } from "./jobModel.ts"; +import { openClawCronRunStatusSchema } from "./openClawCron.ts"; import type { ProcedureContract } from "./registry.ts"; import { emptyInputSchema } from "./system.ts"; +import { taskIdSchema, taskPrioritySchema, taskStatusSchema } from "./taskModel.ts"; /** Hard cache-status row budget for one complete status response. */ export const cacheStatusMaximumEntries = 128; @@ -354,8 +360,12 @@ export const cacheStatusResultSchema = v.pipe( v.check(cacheStatusResultIsConsistent, "Cache status result is inconsistent") ); -/** First compact greenfield heartbeat schema; legacy schema-v3 rows are not mirrored. */ -export const cacheHeartbeatSchemaVersion = 1 as const; +/** Purpose-built heartbeat schema retaining health signals without content identities. */ +export const cacheHeartbeatSchemaVersion = 4 as const; +/** Hard row budget for the purpose-built heartbeat task projection. */ +export const cacheHeartbeatTaskMaximum = 100; +/** Hard release-registry budget for Dashboard schedules exposed in one heartbeat. */ +export const cacheHeartbeatDashboardJobMaximum = 32; interface CacheHeartbeatConnectionState { readonly checkedAtMs: number; @@ -453,8 +463,77 @@ export const cacheHeartbeatPendingSyncSchema = v.picklist( "Heartbeat OpenClaw cron pending-sync state is invalid" ); +interface CacheHeartbeatCronHealthCounts { + readonly disabledCount: number; + readonly enabledCount: number; + readonly inspectedCount: number; + readonly intendedDisabledCount: number; + readonly lastRunErrorCount: number; + readonly runningCount: number; + readonly staleRunningCount: number; + readonly synchronizationConflictCount: number; + readonly synchronizationPendingCount: number; + readonly truncated: boolean; + readonly unexpectedDisabledCount: number; +} + +/** @returns Whether all identity-free cron health categories form valid subsets. */ +export function cacheHeartbeatCronHealthCountsAreConsistent( + health: CacheHeartbeatCronHealthCounts +): boolean { + return ( + health.enabledCount + health.disabledCount === health.inspectedCount && + health.intendedDisabledCount + health.unexpectedDisabledCount === + health.disabledCount && + health.lastRunErrorCount <= health.inspectedCount && + health.runningCount <= health.inspectedCount && + health.staleRunningCount <= health.runningCount && + health.synchronizationConflictCount + health.synchronizationPendingCount <= + health.inspectedCount + ); +} + const cacheHeartbeatCronProjectionEntries = { count: nonnegativeSafeIntegerSchema("Heartbeat OpenClaw cron count is invalid"), + health: v.pipe( + v.strictObject({ + disabledCount: nonnegativeSafeIntegerSchema( + "Heartbeat disabled OpenClaw cron count is invalid" + ), + enabledCount: nonnegativeSafeIntegerSchema( + "Heartbeat enabled OpenClaw cron count is invalid" + ), + inspectedCount: nonnegativeSafeIntegerSchema( + "Heartbeat inspected OpenClaw cron count is invalid" + ), + intendedDisabledCount: nonnegativeSafeIntegerSchema( + "Heartbeat intended-disabled OpenClaw cron count is invalid" + ), + lastRunErrorCount: nonnegativeSafeIntegerSchema( + "Heartbeat failing OpenClaw cron count is invalid" + ), + runningCount: nonnegativeSafeIntegerSchema( + "Heartbeat running OpenClaw cron count is invalid" + ), + staleRunningCount: nonnegativeSafeIntegerSchema( + "Heartbeat stale-running OpenClaw cron count is invalid" + ), + synchronizationConflictCount: nonnegativeSafeIntegerSchema( + "Heartbeat conflicting OpenClaw cron count is invalid" + ), + synchronizationPendingCount: nonnegativeSafeIntegerSchema( + "Heartbeat pending OpenClaw cron count is invalid" + ), + truncated: v.boolean("Heartbeat OpenClaw cron truncation is invalid"), + unexpectedDisabledCount: nonnegativeSafeIntegerSchema( + "Heartbeat unexpected-disabled OpenClaw cron count is invalid" + ), + }), + v.check( + cacheHeartbeatCronHealthCountsAreConsistent, + "Heartbeat OpenClaw cron health counts are inconsistent" + ) + ), observedAtMs: cacheTimestampSchema, pendingSync: cacheHeartbeatPendingSyncSchema, }; @@ -465,12 +544,31 @@ const cacheHeartbeatCronUnavailableSchema = v.strictObject({ ), state: v.literal("unavailable"), }); +interface CacheHeartbeatCronHealthProjection { + readonly count: number; + readonly health: { + readonly inspectedCount: number; + readonly truncated: boolean; + }; +} + +function cacheHeartbeatCronHealthIsConsistent( + projection: CacheHeartbeatCronHealthProjection +): boolean { + const expectedTruncated = projection.health.inspectedCount < projection.count; + return ( + projection.health.inspectedCount <= projection.count && + projection.health.truncated === expectedTruncated + ); +} + const cacheHeartbeatCronFreshSchema = v.strictObject({ ...cacheHeartbeatCronProjectionEntries, state: v.literal("fresh"), }); interface CacheHeartbeatCronLastKnownGood { readonly count: number; + readonly health: CacheHeartbeatCronHealthProjection["health"]; readonly observedAtMs: number; readonly pendingSync: "none" | "present" | "unknown"; readonly staleSinceMs: number; @@ -484,30 +582,369 @@ interface CacheHeartbeatCronLastKnownGood { export function cacheHeartbeatCronLastKnownGoodIsConsistent( projection: CacheHeartbeatCronLastKnownGood & Record ): boolean { - return cacheHeartbeatLastKnownGoodTimesAreConsistent(projection); + return ( + cacheHeartbeatLastKnownGoodTimesAreConsistent(projection) && + cacheHeartbeatCronHealthIsConsistent(projection) + ); } -const cacheHeartbeatCronLastKnownGoodSchema = v.pipe( - v.strictObject({ - ...cacheHeartbeatCronProjectionEntries, - staleSinceMs: cacheTimestampSchema, - state: v.literal("last-known-good"), - }), - v.check( - cacheHeartbeatCronLastKnownGoodIsConsistent, - "Heartbeat OpenClaw cron freshness is inconsistent" - ) -); +const cacheHeartbeatCronLastKnownGoodSchema = v.strictObject({ + ...cacheHeartbeatCronProjectionEntries, + staleSinceMs: cacheTimestampSchema, + state: v.literal("last-known-good"), +}); -/** Identity- and payload-free state of the latest global OpenClaw cron projection. */ -export const cacheHeartbeatOpenClawCronSchema = v.variant("state", [ +const cacheHeartbeatOpenClawCronVariantSchema = v.variant("state", [ cacheHeartbeatCronUnavailableSchema, cacheHeartbeatCronFreshSchema, cacheHeartbeatCronLastKnownGoodSchema, ]); +type CacheHeartbeatOpenClawCronProjection = v.InferOutput< + typeof cacheHeartbeatOpenClawCronVariantSchema +>; + +/** @returns Whether global cron freshness, coverage, and synchronization agree. */ +export function cacheHeartbeatCronProjectionIsConsistent( + projection: CacheHeartbeatOpenClawCronProjection +): boolean { + if (projection.state === "unavailable") return true; + let expectedPendingSync: CacheHeartbeatOpenClawCronProjection["pendingSync"] = "none"; + if ( + projection.health.synchronizationConflictCount > 0 || + projection.health.synchronizationPendingCount > 0 + ) { + expectedPendingSync = "present"; + } else if (projection.health.truncated) { + expectedPendingSync = "unknown"; + } + let pendingSyncIsConsistent = projection.pendingSync === expectedPendingSync; + if (projection.state === "last-known-good") { + if (expectedPendingSync === "present") { + pendingSyncIsConsistent = projection.pendingSync === "present"; + } else if (expectedPendingSync === "unknown") { + pendingSyncIsConsistent = projection.pendingSync !== "none"; + } else { + pendingSyncIsConsistent = true; + } + } + return ( + cacheHeartbeatCronHealthIsConsistent(projection) && + pendingSyncIsConsistent && + (projection.state === "fresh" || + cacheHeartbeatCronLastKnownGoodIsConsistent(projection)) + ); +} + +/** Identity- and payload-free state of the latest global OpenClaw cron projection. */ +export const cacheHeartbeatOpenClawCronSchema = v.pipe( + cacheHeartbeatOpenClawCronVariantSchema, + v.check( + cacheHeartbeatCronProjectionIsConsistent, + "Heartbeat OpenClaw cron projection is inconsistent" + ) +); + +export const cacheHeartbeatTaskRelevanceValues = [ + "automation-linked", + "agent-priority", + "owner-blocked", +] as const; +const cacheHeartbeatTaskCronUnavailableSchema = v.strictObject({ + state: v.literal("unavailable"), +}); +const cacheHeartbeatTaskCronMissingSchema = v.strictObject({ + state: v.literal("missing"), +}); +const cacheHeartbeatTaskCronPresentSchema = v.strictObject({ + desiredEnabled: v.optional( + v.boolean("Heartbeat linked-cron desired state is invalid") + ), + enabled: v.boolean("Heartbeat linked-cron enabled state is invalid"), + lastDurationMs: v.optional(cacheTimestampSchema), + lastRunAtMs: v.optional(cacheTimestampSchema), + lastRunStatus: v.optional(openClawCronRunStatusSchema), + nextRunAtMs: v.optional(cacheTimestampSchema), + runningAtMs: v.optional(cacheTimestampSchema), + state: v.literal("present"), + synchronization: v.picklist( + ["confirmed", "conflict", "pending"], + "Heartbeat linked-cron synchronization state is invalid" + ), +}); +const cacheHeartbeatTaskCronVariantSchema = v.variant("state", [ + cacheHeartbeatTaskCronUnavailableSchema, + cacheHeartbeatTaskCronMissingSchema, + cacheHeartbeatTaskCronPresentSchema, +]); +type CacheHeartbeatTaskCronProjection = v.InferOutput< + typeof cacheHeartbeatTaskCronVariantSchema +>; + +/** @returns Whether one linked cron's actual and desired enabled state agree. */ +export function cacheHeartbeatTaskCronIsConsistent( + cron: CacheHeartbeatTaskCronProjection +): boolean { + if (cron.state !== "present") return true; + return cron.synchronization === "confirmed" + ? cron.desiredEnabled === undefined || cron.desiredEnabled === cron.enabled + : cron.desiredEnabled !== undefined && cron.desiredEnabled !== cron.enabled; +} + +/** Identity-free health of the OpenClaw cron linked to one task. */ +export const cacheHeartbeatTaskCronSchema = v.pipe( + cacheHeartbeatTaskCronVariantSchema, + v.check( + cacheHeartbeatTaskCronIsConsistent, + "Heartbeat linked-cron synchronization state is inconsistent" + ) +); +const cacheHeartbeatTaskRelevanceSchema = v.pipe( + v.array( + v.picklist( + cacheHeartbeatTaskRelevanceValues, + "Heartbeat task relevance is invalid" + ), + "Heartbeat task relevance is invalid" + ), + v.minLength(1, "Heartbeat task relevance is invalid"), + v.maxLength( + cacheHeartbeatTaskRelevanceValues.length, + "Heartbeat task relevance is invalid" + ) +); +const cacheHeartbeatTaskSchema = v.strictObject({ + automation: v.optional( + v.strictObject({ + cron: cacheHeartbeatTaskCronSchema, + recurring: v.boolean("Heartbeat task recurring state is invalid"), + }) + ), + id: taskIdSchema, + priority: taskPrioritySchema, + relevance: cacheHeartbeatTaskRelevanceSchema, + status: taskStatusSchema, +}); +const cacheHeartbeatTasksUnavailableSchema = v.strictObject({ + state: v.literal("unavailable"), +}); +const cacheHeartbeatTasksAvailableSchema = v.strictObject({ + items: v.pipe( + v.array(cacheHeartbeatTaskSchema, "Heartbeat task rows are invalid"), + v.maxLength(cacheHeartbeatTaskMaximum, "Heartbeat task rows exceed their budget") + ), + state: v.literal("available"), + totalCount: nonnegativeSafeIntegerSchema("Heartbeat task total count is invalid"), + truncated: v.boolean("Heartbeat task truncation state is invalid"), +}); +const cacheHeartbeatTasksVariantSchema = v.variant("state", [ + cacheHeartbeatTasksUnavailableSchema, + cacheHeartbeatTasksAvailableSchema, +]); +export type CacheHeartbeatTasks = v.InferOutput; + +/** @returns Whether task rows, relevance, exact total, and truncation are canonical. */ +export function cacheHeartbeatTasksAreConsistent( + projection: CacheHeartbeatTasks +): boolean { + if (projection.state === "unavailable") return true; + const items = projection.items; + const expectedTruncated = projection.totalCount > cacheHeartbeatTaskMaximum; + return ( + items.length === Math.min(projection.totalCount, cacheHeartbeatTaskMaximum) && + projection.truncated === expectedTruncated && + items.every((item, index) => + index === 0 ? true : compareStrings(items[index - 1]!.id, item.id) < 0 + ) && + items.every((item) => { + const canonicalRelevance = cacheHeartbeatTaskRelevanceValues.filter((value) => + item.relevance.includes(value) + ); + return ( + item.status !== "done" && + canonicalRelevance.length === item.relevance.length && + canonicalRelevance.every( + (value, index) => value === item.relevance[index] + ) && + item.relevance.includes("automation-linked") === + (item.automation !== undefined) && + (!item.relevance.includes("agent-priority") || + item.priority === "medium" || + item.priority === "high") && + (!item.relevance.includes("owner-blocked") || item.status === "blocked") + ); + }) + ); +} + +/** Bounded content-free task state used only by cache-read automation. */ +export const cacheHeartbeatTasksSchema = v.pipe( + cacheHeartbeatTasksVariantSchema, + v.check(cacheHeartbeatTasksAreConsistent, "Heartbeat task projection is inconsistent") +); + +const cacheHeartbeatJobDisableIntentSchema = v.strictObject({ + expiresAtMs: v.optional(cacheTimestampSchema), + valid: v.boolean("Heartbeat Dashboard-job disable validity is invalid"), +}); +const cacheHeartbeatActiveRunSchema = v.strictObject({ + firstStartedAtMs: v.optional(cacheTimestampSchema), + queuedAtMs: cacheTimestampSchema, + state: v.picklist( + ["queued", "running"], + "Heartbeat Dashboard-job active-run state is invalid" + ), + updatedAtMs: cacheTimestampSchema, +}); +const cacheHeartbeatLatestRunSchema = v.strictObject({ + finishedAtMs: v.optional(cacheTimestampSchema), + firstStartedAtMs: v.optional(cacheTimestampSchema), + queuedAtMs: cacheTimestampSchema, + state: jobRunStateSchema, + terminalCode: v.optional(jobRunTerminalCodeSchema), + triggerType: jobTriggerTypeSchema, + updatedAtMs: cacheTimestampSchema, +}); +const cacheHeartbeatDashboardJobMissingSchema = v.strictObject({ + defaultEnabled: v.boolean("Heartbeat Dashboard-job default state is invalid"), + id: scheduleIdSchema, + state: v.literal("missing"), +}); +const cacheHeartbeatDashboardJobPresentSchema = v.strictObject({ + activeRun: v.optional(cacheHeartbeatActiveRunSchema), + defaultEnabled: v.boolean("Heartbeat Dashboard-job default state is invalid"), + disableIntent: v.optional(cacheHeartbeatJobDisableIntentSchema), + enabled: v.boolean("Heartbeat Dashboard-job enabled state is invalid"), + id: scheduleIdSchema, + latestRun: v.optional(cacheHeartbeatLatestRunSchema), + nextRunAtMs: v.nullable(cacheTimestampSchema), + state: v.literal("present"), +}); +/** One code-owned schedule without action metadata, payloads, identities, or messages. */ +export const cacheHeartbeatDashboardJobSchema = v.variant("state", [ + cacheHeartbeatDashboardJobMissingSchema, + cacheHeartbeatDashboardJobPresentSchema, +]); +const cacheHeartbeatDashboardJobsUnavailableSchema = v.strictObject({ + state: v.literal("unavailable"), +}); +const cacheHeartbeatDashboardJobsAvailableSchema = v.strictObject({ + items: v.pipe( + v.array( + cacheHeartbeatDashboardJobSchema, + "Heartbeat Dashboard-job rows are invalid" + ), + v.maxLength( + cacheHeartbeatDashboardJobMaximum, + "Heartbeat Dashboard-job rows exceed their budget" + ) + ), + state: v.literal("available"), +}); +const cacheHeartbeatDashboardJobsVariantSchema = v.variant("state", [ + cacheHeartbeatDashboardJobsUnavailableSchema, + cacheHeartbeatDashboardJobsAvailableSchema, +]); +export type CacheHeartbeatDashboardJobs = v.InferOutput< + typeof cacheHeartbeatDashboardJobsVariantSchema +>; + +/** @returns Whether schedule rows, run lifecycle, and optional expiry state are canonical. */ +export function cacheHeartbeatDashboardJobsAreConsistent( + projection: CacheHeartbeatDashboardJobs, + generatedAtMs?: number +): boolean { + if (projection.state === "unavailable") return true; + return ( + projection.items.every((item, index) => + index === 0 + ? true + : compareStrings(projection.items[index - 1]!.id, item.id) < 0 + ) && + projection.items.every((job) => { + if (job.state === "missing") return true; + const latestRunIsActive = + job.latestRun !== undefined && + ["queued", "running"].includes(job.latestRun.state); + if ( + job.enabled !== (job.nextRunAtMs !== null) || + (job.enabled && job.disableIntent !== undefined) || + (job.activeRun?.state === "running" && + job.activeRun.firstStartedAtMs === undefined) || + (job.activeRun !== undefined) !== latestRunIsActive + ) { + return false; + } + if ( + job.activeRun !== undefined && + job.latestRun !== undefined && + (job.activeRun.state !== job.latestRun.state || + job.activeRun.queuedAtMs !== job.latestRun.queuedAtMs || + job.activeRun.firstStartedAtMs !== job.latestRun.firstStartedAtMs || + job.activeRun.updatedAtMs !== job.latestRun.updatedAtMs) + ) { + return false; + } + const historicalTimestamps = [ + ...(job.activeRun === undefined + ? [] + : [ + job.activeRun.queuedAtMs, + job.activeRun.updatedAtMs, + ...(job.activeRun.firstStartedAtMs === undefined + ? [] + : [job.activeRun.firstStartedAtMs]), + ]), + ...(job.latestRun === undefined + ? [] + : [ + job.latestRun.queuedAtMs, + job.latestRun.updatedAtMs, + ...(job.latestRun.firstStartedAtMs === undefined + ? [] + : [job.latestRun.firstStartedAtMs]), + ...(job.latestRun.finishedAtMs === undefined + ? [] + : [job.latestRun.finishedAtMs]), + ]), + ]; + if ( + generatedAtMs !== undefined && + (!historicalTimestamps.every((timestamp) => timestamp <= generatedAtMs) || + (job.disableIntent !== undefined && + job.disableIntent.valid !== + (job.disableIntent.expiresAtMs === undefined || + job.disableIntent.expiresAtMs > generatedAtMs))) + ) { + return false; + } + return !( + (job.activeRun !== undefined && + !heartbeatRunTimesAreConsistent(job.activeRun)) || + (job.latestRun !== undefined && + (!heartbeatRunTimesAreConsistent(job.latestRun) || + ["queued", "running"].includes(job.latestRun.state) !== + (job.latestRun.finishedAtMs === undefined) || + ["cancelled", "failed", "timed-out"].includes( + job.latestRun.state + ) !== + (job.latestRun.terminalCode !== undefined))) + ); + }) + ); +} + +/** Complete code-owned Dashboard schedule inventory or an explicit safe read failure. */ +export const cacheHeartbeatDashboardJobsSchema = v.pipe( + cacheHeartbeatDashboardJobsVariantSchema, + v.check( + cacheHeartbeatDashboardJobsAreConsistent, + "Heartbeat Dashboard-job projection is inconsistent" + ) +); const cacheHeartbeatResultObjectSchema = v.strictObject({ cache: cacheStatusResultSchema, + dashboardJobs: cacheHeartbeatDashboardJobsSchema, gateway: v.strictObject({ connection: cacheHeartbeatConnectionSchema, sessions: cacheHeartbeatSessionsSchema, @@ -515,12 +952,41 @@ const cacheHeartbeatResultObjectSchema = v.strictObject({ generatedAtMs: cacheTimestampSchema, openClawCron: cacheHeartbeatOpenClawCronSchema, schemaVersion: v.literal(cacheHeartbeatSchemaVersion), + tasks: cacheHeartbeatTasksSchema, }); export type CacheHeartbeatResult = v.InferOutput; -/** @returns Whether all nested observations precede the clamped response clock. */ +function heartbeatRunTimesAreConsistent(run: { + readonly finishedAtMs?: number; + readonly firstStartedAtMs?: number; + readonly queuedAtMs: number; + readonly updatedAtMs: number; +}): boolean { + return ( + run.queuedAtMs <= run.updatedAtMs && + (run.firstStartedAtMs === undefined || + (run.firstStartedAtMs >= run.queuedAtMs && + run.firstStartedAtMs <= run.updatedAtMs)) && + (run.finishedAtMs === undefined || + (run.finishedAtMs >= (run.firstStartedAtMs ?? run.queuedAtMs) && + run.finishedAtMs <= run.updatedAtMs)) + ); +} + +/** @returns Whether all bounded rows, totals, and timestamps agree with the response clock. */ export function cacheHeartbeatResultIsConsistent(result: CacheHeartbeatResult): boolean { + if ( + !cacheHeartbeatTasksAreConsistent(result.tasks) || + !cacheHeartbeatDashboardJobsAreConsistent( + result.dashboardJobs, + result.generatedAtMs + ) + ) { + return false; + } + const dashboardJobs = + result.dashboardJobs.state === "available" ? result.dashboardJobs.items : []; const timestamps = [ result.cache.generatedAtMs, result.gateway.connection.checkedAtMs, @@ -540,16 +1006,67 @@ export function cacheHeartbeatResultIsConsistent(result: CacheHeartbeatResult): ? [result.openClawCron.staleSinceMs] : []), ]), + ...dashboardJobs.flatMap((job) => + job.state === "missing" + ? [] + : [ + ...(job.activeRun === undefined + ? [] + : [ + job.activeRun.queuedAtMs, + job.activeRun.updatedAtMs, + ...(job.activeRun.firstStartedAtMs === undefined + ? [] + : [job.activeRun.firstStartedAtMs]), + ]), + ...(job.latestRun === undefined + ? [] + : [ + job.latestRun.queuedAtMs, + job.latestRun.updatedAtMs, + ...(job.latestRun.firstStartedAtMs === undefined + ? [] + : [job.latestRun.firstStartedAtMs]), + ...(job.latestRun.finishedAtMs === undefined + ? [] + : [job.latestRun.finishedAtMs]), + ]), + ] + ), + ...(result.tasks.state === "unavailable" + ? [] + : result.tasks.items.flatMap((task) => { + const cron = task.automation?.cron; + return cron?.state === "present" + ? [ + ...(cron.lastRunAtMs === undefined ? [] : [cron.lastRunAtMs]), + ...(cron.runningAtMs === undefined ? [] : [cron.runningAtMs]), + ] + : []; + })), ]; + const linkedCronStatesAreTruthful = + result.tasks.state === "unavailable" || + result.tasks.items.every((task) => { + const cron = task.automation?.cron; + if (cron === undefined) return true; + if (result.openClawCron.state !== "fresh") { + return cron.state === "unavailable"; + } + return result.openClawCron.health.truncated + ? cron.state !== "missing" + : cron.state !== "unavailable"; + }); return ( timestamps.every((timestamp) => timestamp <= result.generatedAtMs) && + linkedCronStatesAreTruthful && (result.gateway.connection.freshness === "fresh" || (result.gateway.sessions.state !== "fresh" && result.openClawCron.state !== "fresh")) ); } -/** Compact cache, Gateway, current-session, and OpenClaw-cron heartbeat projection. */ +/** Compact cache, Gateway, task, schedule, and OpenClaw-cron heartbeat projection. */ export const cacheHeartbeatResultSchema = v.pipe( cacheHeartbeatResultObjectSchema, v.check(cacheHeartbeatResultIsConsistent, "Cache heartbeat result is inconsistent") @@ -601,8 +1118,7 @@ export const cacheProcedureContracts = [ name: "cache.getHeartbeat", output: cacheHeartbeatResultSchema, outputSchemaId: "cache.getHeartbeat.output", - summary: - "Returns compact cache status plus sanitized process-owned Gateway projections.", + summary: "Returns compact cache status plus sanitized operational projections.", transport: cacheQueryTransport, }, { diff --git a/greenfield/src/contracts/contractRegistry.ts b/greenfield/src/contracts/contractRegistry.ts index 948dbf77b..c507609e0 100644 --- a/greenfield/src/contracts/contractRegistry.ts +++ b/greenfield/src/contracts/contractRegistry.ts @@ -24,6 +24,7 @@ import { incidentProcedureContracts } from "./incidents.ts"; import { jobRealtimeEventContracts } from "./jobRealtime.ts"; import { jobProcedureContracts } from "./jobs.ts"; import { logProcedureContracts } from "./logs.ts"; +import { moltbookProcedureContracts } from "./moltbook.ts"; import { monitoringProcedureContracts } from "./monitoringIngestion.ts"; import { monitoringRealtimeEventContracts } from "./monitoringRealtime.ts"; import { notificationProcedureContracts } from "./notifications.ts"; @@ -60,6 +61,7 @@ const registeredProcedureContracts = [ ...jobProcedureContracts, ...logProcedureContracts, ...monitoringProcedureContracts, + ...moltbookProcedureContracts, ...notificationProcedureContracts, ...openClawTaskProcedureContracts, ...openClawCronProcedureContracts, diff --git a/greenfield/src/contracts/jobLimits.ts b/greenfield/src/contracts/jobLimits.ts new file mode 100644 index 000000000..20f312765 --- /dev/null +++ b/greenfield/src/contracts/jobLimits.ts @@ -0,0 +1,4 @@ +/** Maximum concurrent run slots declared by one durable worker. */ +export const jobWorkerCapacityMaximum = 16; +/** Maximum durable worker rows retained in one browser-facing summary. */ +export const jobWorkerSummaryMaximum = 32; diff --git a/greenfield/src/contracts/jobModel.ts b/greenfield/src/contracts/jobModel.ts index cde5477e7..7e947a10d 100644 --- a/greenfield/src/contracts/jobModel.ts +++ b/greenfield/src/contracts/jobModel.ts @@ -14,9 +14,12 @@ import { nonnegativeSafeIntegerSchema, positiveSafeIntegerSchema, } from "../shared/validation.ts"; +import { jobWorkerCapacityMaximum } from "./jobLimits.ts"; import { canonicalScheduleTimeZones } from "./scheduleTimeZones.ts"; import { isCanonicalWebAuthnBase64Url } from "./webauthn.ts"; +export { jobWorkerCapacityMaximum, jobWorkerSummaryMaximum } from "./jobLimits.ts"; + /** Canonical durable job-run states. */ export const jobRunStates = [ "cancelled", @@ -86,9 +89,7 @@ export const jobRunEventMessageMaximumBytes = 4096; export const jobRunPayloadEventMaximumBytes = jobRunOutputMaximumBytes - jobRunAttemptMaximum * jobRunEventMessageMaximumBytes; export const jobRunEventProgressMaximumBytes = 16 * 1024; -export const jobWorkerCapacityMaximum = 16; export const jobWorkerFreshnessMs = 30_000; -export const jobWorkerSummaryMaximum = 32; export const jobIdempotencyKeyMinimumLength = 32; export const jobIdempotencyKeyMaximumLength = 128; export const scheduleIdMaximumLength = 80; diff --git a/greenfield/src/contracts/moltbook.test.ts b/greenfield/src/contracts/moltbook.test.ts new file mode 100644 index 000000000..0466ee9c0 --- /dev/null +++ b/greenfield/src/contracts/moltbook.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { + moltbookFeedMaximumPosts, + moltbookFeedSchema, + moltbookProcedureContracts, + moltbookSnapshotResultSchema, + moltbookSnapshotStatusSchema, +} from "./moltbook.ts"; + +const post = Object.freeze({ + author: { name: "mira" }, + commentCount: 0, + contentPreview: "preview", + createdAtMs: 1000, + downvotes: 0, + id: "post-1", + submoltName: "agents", + title: "Title", + upvotes: 1, +}); + +describe("Moltbook contracts", () => { + test("keeps provider content strict and within row budgets", () => { + expect( + v.safeParse(moltbookFeedSchema, { + hasMore: false, + posts: [post], + sort: "hot", + }).success + ).toBe(true); + expect( + v.safeParse(moltbookFeedSchema, { + hasMore: false, + posts: Array.from({ length: moltbookFeedMaximumPosts + 1 }, () => post), + sort: "hot", + }).success + ).toBe(false); + expect( + v.safeParse(moltbookFeedSchema, { + extra: "provider field", + hasMore: false, + posts: [], + sort: "hot", + }).success + ).toBe(false); + }); + + test("requires refresh failure details to match the latest attempt", () => { + expect( + v.safeParse(moltbookSnapshotStatusSchema, { + freshness: "stale", + lastAttemptAtMs: 2000, + lastAttemptStatus: "failed", + lastSuccessAtMs: 1000, + refreshFailureMessage: "Moltbook refresh failed.", + }).success + ).toBe(true); + expect( + v.safeParse(moltbookSnapshotStatusSchema, { + freshness: "fresh", + lastAttemptAtMs: 2000, + lastAttemptStatus: "failed", + lastSuccessAtMs: 1000, + }).success + ).toBe(false); + expect( + v.safeParse(moltbookSnapshotStatusSchema, { + freshness: "missing", + lastAttemptAtMs: 2000, + lastAttemptStatus: "succeeded", + lastSuccessAtMs: 2000, + }).success + ).toBe(false); + }); + + test("exposes one strict combined browser snapshot without removing legacy reads", () => { + const snapshot = { + content: { comments: [], posts: [] }, + feed: { hasMore: false, posts: [post], sort: "hot" }, + home: { + activityOnYourPostsCount: 0, + exploreCount: 0, + nextActions: [], + pendingRequestCount: 0, + postsFromAccountsYouFollowCount: 0, + unreadMessageCount: 0, + unreadNotificationCount: 0, + }, + status: { + freshness: "fresh", + lastAttemptAtMs: 2000, + lastAttemptStatus: "succeeded", + lastSuccessAtMs: 2000, + }, + } as const; + expect(v.safeParse(moltbookSnapshotResultSchema, snapshot).success).toBe(true); + expect( + v.safeParse(moltbookSnapshotResultSchema, { + ...snapshot, + providerPath: "/private/moltbook", + }).success + ).toBe(false); + expect(moltbookProcedureContracts.map(({ name }) => name)).toEqual([ + "moltbook.feed", + "moltbook.home", + "moltbook.listMyPosts", + "moltbook.profile", + "moltbook.snapshot", + ]); + }); +}); diff --git a/greenfield/src/contracts/moltbook.ts b/greenfield/src/contracts/moltbook.ts new file mode 100644 index 000000000..c9a9a23c1 --- /dev/null +++ b/greenfield/src/contracts/moltbook.ts @@ -0,0 +1,327 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { + boundedControlSafeTextSchema, + hasNoUnicodeControlOrFormat, + noNulStringAction, + nonnegativeSafeIntegerSchema, +} from "../shared/validation.ts"; +import type { ProcedureContract } from "./registry.ts"; +import { emptyInputSchema } from "./system.ts"; + +export const moltbookFeedMaximumPosts = 25; +export const moltbookOwnPostsMaximum = 25; +export const moltbookOwnCommentsMaximum = 50; +export const moltbookNextActionsMaximum = 8; + +function boundedTextSchema(maximumLength: number, message: string) { + return v.pipe( + v.string(message), + v.maxLength(maximumLength, message), + noNulStringAction(message) + ); +} + +function boundedDisplayTextSchema(maximumLength: number, message: string) { + return v.pipe( + boundedTextSchema(maximumLength, message), + v.check(hasNoUnicodeControlOrFormat, message) + ); +} + +const moltbookIdentitySchema = boundedControlSafeTextSchema( + 128, + "Moltbook identity is invalid" +); +const moltbookTitleSchema = boundedDisplayTextSchema(500, "Moltbook title is invalid"); +const moltbookContentSchema = boundedTextSchema(8000, "Moltbook content is invalid"); +const moltbookCountSchema = nonnegativeSafeIntegerSchema("Moltbook count is invalid"); +const moltbookKarmaSchema = v.pipe( + v.number("Moltbook karma is invalid"), + v.safeInteger("Moltbook karma is invalid") +); +const moltbookTimestampSchema = timestampMillisecondsSchema( + "Moltbook timestamp is invalid" +); + +export const moltbookAuthorSchema = v.strictObject({ + displayName: v.optional( + boundedDisplayTextSchema(200, "Moltbook author display name is invalid") + ), + name: moltbookIdentitySchema, +}); + +export const moltbookFeedPostSchema = v.strictObject({ + author: moltbookAuthorSchema, + commentCount: moltbookCountSchema, + contentPreview: moltbookContentSchema, + createdAtMs: moltbookTimestampSchema, + downvotes: moltbookCountSchema, + id: moltbookIdentitySchema, + submoltName: moltbookIdentitySchema, + title: moltbookTitleSchema, + upvotes: moltbookCountSchema, + youFollowAuthor: v.optional(v.boolean("Moltbook follow state is invalid")), +}); + +export const moltbookFeedSchema = v.strictObject({ + filter: v.optional(boundedDisplayTextSchema(80, "Moltbook feed filter is invalid")), + hasMore: v.boolean("Moltbook feed continuation state is invalid"), + posts: v.pipe( + v.array(moltbookFeedPostSchema, "Moltbook feed posts are invalid"), + v.maxLength(moltbookFeedMaximumPosts, "Moltbook feed is outside its row budget") + ), + sort: v.picklist(["hot", "new"], "Moltbook feed sort is invalid"), + tip: v.optional(boundedTextSchema(1000, "Moltbook feed tip is invalid")), +}); + +export const moltbookProfileSchema = v.strictObject({ + commentsCount: moltbookCountSchema, + description: boundedTextSchema(4000, "Moltbook profile description is invalid"), + displayName: boundedDisplayTextSchema( + 200, + "Moltbook profile display name is invalid" + ), + followerCount: moltbookCountSchema, + followingCount: moltbookCountSchema, + karma: moltbookKarmaSchema, + name: moltbookIdentitySchema, + postsCount: moltbookCountSchema, +}); + +export const moltbookOwnPostSchema = v.strictObject({ + commentCount: moltbookCountSchema, + contentPreview: moltbookContentSchema, + createdAtMs: moltbookTimestampSchema, + downvotes: moltbookCountSchema, + id: moltbookIdentitySchema, + submoltName: moltbookIdentitySchema, + title: moltbookTitleSchema, + upvotes: moltbookCountSchema, +}); + +export const moltbookOwnCommentSchema = v.strictObject({ + content: moltbookContentSchema, + createdAtMs: moltbookTimestampSchema, + downvotes: moltbookCountSchema, + id: moltbookIdentitySchema, + post: v.strictObject({ + id: moltbookIdentitySchema, + submoltName: moltbookIdentitySchema, + title: moltbookTitleSchema, + }), + upvotes: moltbookCountSchema, +}); + +export const moltbookOwnContentSchema = v.strictObject({ + comments: v.pipe( + v.array(moltbookOwnCommentSchema, "Moltbook comments are invalid"), + v.maxLength( + moltbookOwnCommentsMaximum, + "Moltbook comments are outside their row budget" + ) + ), + posts: v.pipe( + v.array(moltbookOwnPostSchema, "Moltbook posts are invalid"), + v.maxLength( + moltbookOwnPostsMaximum, + "Moltbook posts are outside their row budget" + ) + ), +}); + +export const moltbookHomeSchema = v.strictObject({ + activityOnYourPostsCount: moltbookCountSchema, + exploreCount: moltbookCountSchema, + latestAnnouncement: v.optional( + v.strictObject({ + authorName: v.optional( + boundedDisplayTextSchema(200, "Moltbook announcement author is invalid") + ), + createdAtMs: v.optional(moltbookTimestampSchema), + postId: v.optional(moltbookIdentitySchema), + previewText: v.optional( + boundedTextSchema(2000, "Moltbook announcement preview is invalid") + ), + title: v.optional(moltbookTitleSchema), + }) + ), + nextActions: v.pipe( + v.array( + boundedDisplayTextSchema(300, "Moltbook next action is invalid"), + "Moltbook next actions are invalid" + ), + v.maxLength( + moltbookNextActionsMaximum, + "Moltbook next actions are outside their row budget" + ) + ), + pendingRequestCount: moltbookCountSchema, + postsFromAccountsYouFollowCount: moltbookCountSchema, + unreadMessageCount: moltbookCountSchema, + unreadNotificationCount: moltbookCountSchema, +}); + +/** One all-or-nothing last-known-good projection from the four fixed Moltbook reads. */ +export const moltbookDashboardCachePayloadSchema = v.strictObject({ + feeds: v.strictObject({ + hot: moltbookFeedSchema, + new: moltbookFeedSchema, + }), + fetchedAtMs: moltbookTimestampSchema, + home: moltbookHomeSchema, + myContent: moltbookOwnContentSchema, + profile: v.optional(moltbookProfileSchema), +}); + +export type MoltbookDashboardCachePayload = v.InferOutput< + typeof moltbookDashboardCachePayloadSchema +>; +export type MoltbookFeed = v.InferOutput; +export type MoltbookFeedPost = v.InferOutput; +export type MoltbookHome = v.InferOutput; +export type MoltbookOwnComment = v.InferOutput; +export type MoltbookOwnContent = v.InferOutput; +export type MoltbookOwnPost = v.InferOutput; +export type MoltbookProfile = v.InferOutput; + +const moltbookSnapshotStatusEntries = { + freshness: v.picklist(["fresh", "stale"], "Moltbook freshness is invalid"), + lastAttemptAtMs: moltbookTimestampSchema, + lastSuccessAtMs: moltbookTimestampSchema, +}; + +export const moltbookSnapshotStatusSchema = v.variant("lastAttemptStatus", [ + v.strictObject({ + ...moltbookSnapshotStatusEntries, + lastAttemptStatus: v.literal("failed"), + refreshFailureMessage: boundedDisplayTextSchema( + 2000, + "Moltbook refresh failure is invalid" + ), + }), + v.strictObject({ + ...moltbookSnapshotStatusEntries, + lastAttemptStatus: v.literal("succeeded"), + }), +]); + +export const moltbookHomeResultSchema = v.strictObject({ + home: moltbookHomeSchema, + status: moltbookSnapshotStatusSchema, +}); +export const moltbookFeedInputSchema = v.strictObject({ + sort: v.optional(v.picklist(["hot", "new"], "Moltbook feed sort is invalid"), "hot"), +}); +export const moltbookFeedResultSchema = v.strictObject({ + feed: moltbookFeedSchema, + status: moltbookSnapshotStatusSchema, +}); +export const moltbookProfileResultSchema = v.strictObject({ + profile: v.optional(moltbookProfileSchema), + status: moltbookSnapshotStatusSchema, +}); +export const moltbookOwnContentResultSchema = v.strictObject({ + content: moltbookOwnContentSchema, + status: moltbookSnapshotStatusSchema, +}); +export const moltbookSnapshotResultSchema = v.strictObject({ + content: moltbookOwnContentSchema, + feed: moltbookFeedSchema, + home: moltbookHomeSchema, + profile: v.optional(moltbookProfileSchema), + status: moltbookSnapshotStatusSchema, +}); + +export type MoltbookFeedInput = v.InferOutput; +export type MoltbookFeedResult = v.InferOutput; +export type MoltbookHomeResult = v.InferOutput; +export type MoltbookOwnContentResult = v.InferOutput< + typeof moltbookOwnContentResultSchema +>; +export type MoltbookProfileResult = v.InferOutput; +export type MoltbookSnapshotResult = v.InferOutput; +export type MoltbookSnapshotStatus = v.InferOutput; + +const moltbookReadAccess = Object.freeze({ + capabilities: ["cache:read"] as const, + capabilityPolicy: "all" as const, + kind: "authenticated" as const, + principalKinds: ["session"] as const, +}); +const moltbookReadErrors = ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"] as const; +const moltbookReadTransport = Object.freeze({ + batching: "adapter-default" as const, + handler: "default" as const, + requestBody: "default" as const, +}); + +/** Capability-scoped read-only Moltbook procedure metadata. */ +export const moltbookProcedureContracts = [ + { + access: moltbookReadAccess, + domain: "moltbook", + errors: moltbookReadErrors, + input: moltbookFeedInputSchema, + inputSchemaId: "moltbook.feed.input.v1", + kind: "query", + name: "moltbook.feed", + output: moltbookFeedResultSchema, + outputSchemaId: "moltbook.feed.result.v1", + summary: "Reads one sorted feed from the bounded Moltbook snapshot.", + transport: moltbookReadTransport, + }, + { + access: moltbookReadAccess, + domain: "moltbook", + errors: moltbookReadErrors, + input: emptyInputSchema, + inputSchemaId: "system.empty.v1", + kind: "query", + name: "moltbook.home", + output: moltbookHomeResultSchema, + outputSchemaId: "moltbook.home.result.v1", + summary: "Reads bounded Moltbook activity counts and notification status.", + transport: moltbookReadTransport, + }, + { + access: moltbookReadAccess, + domain: "moltbook", + errors: moltbookReadErrors, + input: emptyInputSchema, + inputSchemaId: "system.empty.v1", + kind: "query", + name: "moltbook.listMyPosts", + output: moltbookOwnContentResultSchema, + outputSchemaId: "moltbook.own-content.result.v1", + summary: "Reads bounded posts and comments authored by the configured agent.", + transport: moltbookReadTransport, + }, + { + access: moltbookReadAccess, + domain: "moltbook", + errors: moltbookReadErrors, + input: emptyInputSchema, + inputSchemaId: "system.empty.v1", + kind: "query", + name: "moltbook.profile", + output: moltbookProfileResultSchema, + outputSchemaId: "moltbook.profile.result.v1", + summary: "Reads the configured agent's bounded Moltbook profile.", + transport: moltbookReadTransport, + }, + { + access: moltbookReadAccess, + domain: "moltbook", + errors: moltbookReadErrors, + input: moltbookFeedInputSchema, + inputSchemaId: "moltbook.feed.input.v1", + kind: "query", + name: "moltbook.snapshot", + output: moltbookSnapshotResultSchema, + outputSchemaId: "moltbook.snapshot.result.v1", + summary: "Reads the complete bounded Moltbook page projection in one request.", + transport: moltbookReadTransport, + }, +] as const satisfies readonly ProcedureContract[]; diff --git a/greenfield/src/contracts/system.test.ts b/greenfield/src/contracts/system.test.ts index 80ae89e3a..d6818f657 100644 --- a/greenfield/src/contracts/system.test.ts +++ b/greenfield/src/contracts/system.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from "bun:test"; import * as v from "valibot"; import { + type SystemHealthDiagnostics, type SystemMetrics, + systemHealthDiagnosticsContract, + systemHealthDiagnosticsSchema, systemMetricsContract, systemMetricsSchema, } from "./system.ts"; @@ -36,6 +39,270 @@ const metrics = Object.freeze({ uptimeSeconds: 12, } as const satisfies SystemMetrics); +const healthDiagnostics = Object.freeze({ + checkedAtMs: 1_800_000_000_000, + checks: { + application: { status: "ready" }, + database: { status: "ready" }, + frontend: { status: "ready" }, + release: { status: "verified" }, + worker: { status: "ready" }, + }, + dependencies: { + gateway: { + freshness: "fresh", + phase: "connected", + status: "observed", + }, + sessions: { + count: 2, + observedAtMs: 1_800_000_000_000, + state: "fresh", + truncated: false, + }, + }, + queue: { + claimingPaused: false, + oldestQueuedAtMs: 1_799_999_999_000, + runs: { queued: 1, running: 1 }, + status: "observed", + workers: { + capacity: 2, + drainingCount: 0, + freshCount: 1, + onlineCount: 1, + }, + }, + status: "ready", +} as const satisfies SystemHealthDiagnostics); + +describe("system health diagnostics contract", () => { + test("accepts one bounded identity-free readiness and queue projection", () => { + expect(v.parse(systemHealthDiagnosticsSchema, healthDiagnostics)).toEqual( + healthDiagnostics + ); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + hostname: "private-host", + }) + ).toThrow(); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + ...healthDiagnostics.queue.workers, + workerId: "private-worker", + }, + }, + }) + ).toThrow(); + }); + + test("rejects inconsistent aggregate and worker states", () => { + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + status: "not-ready", + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { status: "unavailable" }, + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + capacity: 0, + drainingCount: 0, + freshCount: 0, + onlineCount: 0, + }, + }, + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + ...healthDiagnostics.queue.workers, + freshCount: 2, + }, + }, + }) + ).toThrow("worker projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + capacity: 33, + drainingCount: 0, + freshCount: 33, + onlineCount: 33, + }, + }, + }) + ).toThrow("worker count is outside its budget"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + ...healthDiagnostics.queue.workers, + capacity: 0, + }, + }, + }) + ).toThrow("worker projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + workers: { + ...healthDiagnostics.queue.workers, + capacity: 17, + }, + }, + }) + ).toThrow("worker projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + checks: { + ...healthDiagnostics.checks, + release: { status: "unavailable" }, + }, + status: "not-ready", + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + checks: { + ...healthDiagnostics.checks, + worker: { status: "unavailable" }, + }, + status: "not-ready", + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + dependencies: { + ...healthDiagnostics.dependencies, + gateway: { + freshness: "stale", + phase: "connected", + status: "observed", + }, + }, + }) + ).toThrow("Gateway projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + dependencies: { + ...healthDiagnostics.dependencies, + gateway: { + freshness: "stale", + phase: "degraded", + status: "observed", + }, + }, + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + dependencies: { + ...healthDiagnostics.dependencies, + sessions: { + ...healthDiagnostics.dependencies.sessions, + observedAtMs: healthDiagnostics.checkedAtMs + 1, + }, + }, + }) + ).toThrow("aggregate is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + runs: { ...healthDiagnostics.queue.runs, queued: 0 }, + }, + }) + ).toThrow("queue projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + queue: { + ...healthDiagnostics.queue, + oldestQueuedAtMs: healthDiagnostics.checkedAtMs + 1, + }, + }) + ).toThrow("aggregate is inconsistent"); + }); + + test("rejects inconsistent last-known-good session timestamps", () => { + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + dependencies: { + ...healthDiagnostics.dependencies, + sessions: { + count: 2, + observedAtMs: healthDiagnostics.checkedAtMs, + staleSinceMs: healthDiagnostics.checkedAtMs - 1, + state: "last-known-good", + truncated: false, + }, + }, + }) + ).toThrow("session projection is inconsistent"); + expect(() => + v.parse(systemHealthDiagnosticsSchema, { + ...healthDiagnostics, + dependencies: { + ...healthDiagnostics.dependencies, + sessions: { + count: 2, + observedAtMs: healthDiagnostics.checkedAtMs, + staleSinceMs: healthDiagnostics.checkedAtMs + 1, + state: "last-known-good", + truncated: false, + }, + }, + }) + ).toThrow("aggregate is inconsistent"); + }); + + test("is browser-session-only without automation or control authority", () => { + expect(systemHealthDiagnosticsContract.access).toEqual({ + capabilities: [], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], + }); + expect(systemHealthDiagnosticsContract.errors).toEqual([ + "FORBIDDEN", + "UNAUTHORIZED", + ]); + }); +}); + describe("system metrics contract", () => { test("accepts only the bounded identity-free operational projection", () => { expect(v.parse(systemMetricsSchema, metrics)).toEqual(metrics); diff --git a/greenfield/src/contracts/system.ts b/greenfield/src/contracts/system.ts index c097d9456..1049379af 100644 --- a/greenfield/src/contracts/system.ts +++ b/greenfield/src/contracts/system.ts @@ -1,5 +1,13 @@ import * as v from "valibot"; +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { nonnegativeSafeIntegerSchema } from "../shared/validation.ts"; +import { + gatewayConnectionFreshnessSchema, + gatewayConnectionPhaseSchema, +} from "./gatewayConnection.ts"; +import { gatewaySessionProjectionMaximum } from "./gatewaySessions.ts"; +import { jobWorkerCapacityMaximum, jobWorkerSummaryMaximum } from "./jobLimits.ts"; import type { ProcedureContract, RawHttpContract } from "./registry.ts"; /** Stable raw HTTP liveness endpoint shared by contracts and runtime dispatch. */ @@ -80,6 +88,256 @@ export const systemMetricsSchema = v.strictObject({ export type SystemMetrics = v.InferOutput; +const systemHealthDiagnosticsTimestampSchema = timestampMillisecondsSchema( + "System health diagnostics timestamp is invalid" +); +const systemHealthDiagnosticsCountSchema = nonnegativeSafeIntegerSchema( + "System health diagnostics count is invalid" +); + +/** Sanitized process-owned Gateway state, with provider failures made explicit. */ +const systemHealthDiagnosticsGatewayVariantSchema = v.variant("status", [ + v.strictObject({ + freshness: gatewayConnectionFreshnessSchema, + phase: gatewayConnectionPhaseSchema, + status: v.literal("observed"), + }), + v.strictObject({ status: v.literal("unavailable") }), +]); + +/** @returns Whether connected phase and fresh state agree. */ +export function systemHealthDiagnosticsGatewayIsConsistent( + gateway: v.InferOutput +): boolean { + return ( + gateway.status === "unavailable" || + (gateway.freshness === "fresh") === (gateway.phase === "connected") + ); +} + +export const systemHealthDiagnosticsGatewaySchema = v.pipe( + systemHealthDiagnosticsGatewayVariantSchema, + v.check( + systemHealthDiagnosticsGatewayIsConsistent, + "System health Gateway projection is inconsistent" + ) +); + +/** Identity-free cached Gateway-session count with explicit source freshness. */ +const systemHealthDiagnosticsSessionsVariantSchema = v.variant("state", [ + v.strictObject({ state: v.literal("unavailable") }), + v.strictObject({ + count: v.pipe( + systemHealthDiagnosticsCountSchema, + v.maxValue( + gatewaySessionProjectionMaximum, + "System health session count is outside its budget" + ) + ), + observedAtMs: systemHealthDiagnosticsTimestampSchema, + state: v.literal("fresh"), + truncated: v.boolean(), + }), + v.strictObject({ + count: v.pipe( + systemHealthDiagnosticsCountSchema, + v.maxValue( + gatewaySessionProjectionMaximum, + "System health session count is outside its budget" + ) + ), + observedAtMs: systemHealthDiagnosticsTimestampSchema, + staleSinceMs: systemHealthDiagnosticsTimestampSchema, + state: v.literal("last-known-good"), + truncated: v.boolean(), + }), +]); + +/** @returns Whether last-known-good session timestamps remain ordered. */ +export function systemHealthDiagnosticsSessionsAreConsistent( + sessions: v.InferOutput +): boolean { + return ( + sessions.state !== "last-known-good" || + sessions.staleSinceMs >= sessions.observedAtMs + ); +} + +export const systemHealthDiagnosticsSessionsSchema = v.pipe( + systemHealthDiagnosticsSessionsVariantSchema, + v.check( + systemHealthDiagnosticsSessionsAreConsistent, + "System health session projection is inconsistent" + ) +); + +const systemHealthDiagnosticsWorkerCountSchema = v.pipe( + systemHealthDiagnosticsCountSchema, + v.maxValue( + jobWorkerSummaryMaximum, + "System health worker count is outside its budget" + ) +); +const systemHealthDiagnosticsWorkerCapacitySchema = v.pipe( + systemHealthDiagnosticsCountSchema, + v.maxValue( + jobWorkerSummaryMaximum * jobWorkerCapacityMaximum, + "System health worker capacity is outside its budget" + ) +); + +const systemHealthDiagnosticsWorkersObjectSchema = v.strictObject({ + capacity: systemHealthDiagnosticsWorkerCapacitySchema, + drainingCount: systemHealthDiagnosticsWorkerCountSchema, + freshCount: systemHealthDiagnosticsWorkerCountSchema, + onlineCount: systemHealthDiagnosticsWorkerCountSchema, +}); + +/** @returns Whether the fresh worker count matches its lifecycle partitions. */ +export function systemHealthDiagnosticsWorkersAreConsistent( + workers: v.InferOutput +): boolean { + const maximumCapacity = workers.freshCount * jobWorkerCapacityMaximum; + return ( + workers.freshCount === workers.drainingCount + workers.onlineCount && + workers.capacity >= workers.freshCount && + workers.capacity <= maximumCapacity + ); +} + +const systemHealthDiagnosticsWorkersSchema = v.pipe( + systemHealthDiagnosticsWorkersObjectSchema, + v.check( + systemHealthDiagnosticsWorkersAreConsistent, + "System health worker projection is inconsistent" + ) +); + +/** Bounded content-free queue state, or an explicit unavailable component. */ +const systemHealthDiagnosticsQueueVariantSchema = v.variant("status", [ + v.strictObject({ + claimingPaused: v.boolean(), + oldestQueuedAtMs: v.optional(systemHealthDiagnosticsTimestampSchema), + runs: v.strictObject({ + queued: systemHealthDiagnosticsCountSchema, + running: systemHealthDiagnosticsCountSchema, + }), + status: v.literal("observed"), + workers: systemHealthDiagnosticsWorkersSchema, + }), + v.strictObject({ status: v.literal("unavailable") }), +]); + +/** @returns Whether queue count and oldest-row presence agree. */ +export function systemHealthDiagnosticsQueueIsConsistent( + queue: v.InferOutput +): boolean { + return ( + queue.status === "unavailable" || + queue.runs.queued > 0 === (queue.oldestQueuedAtMs !== undefined) + ); +} + +export const systemHealthDiagnosticsQueueSchema = v.pipe( + systemHealthDiagnosticsQueueVariantSchema, + v.check( + systemHealthDiagnosticsQueueIsConsistent, + "System health queue projection is inconsistent" + ) +); + +const systemHealthDiagnosticsChecksSchema = v.strictObject({ + application: v.strictObject({ + status: v.picklist(["not-ready", "ready"]), + }), + database: v.strictObject({ + status: v.picklist(["ready", "unavailable"]), + }), + frontend: v.strictObject({ + status: v.picklist(["ready", "unavailable"]), + }), + release: v.strictObject({ + status: v.picklist(["unavailable", "verified"]), + }), + worker: v.strictObject({ + status: v.picklist(["not-ready", "ready", "unavailable"]), + }), +}); + +type SystemHealthDiagnosticsValue = { + readonly checkedAtMs: number; + readonly checks: v.InferOutput; + readonly dependencies: { + readonly gateway: v.InferOutput; + readonly sessions: v.InferOutput; + }; + readonly queue: v.InferOutput; + readonly status: "not-ready" | "ready"; +}; + +/** @returns Whether the aggregate state exactly reflects every gating check. */ +export function systemHealthDiagnosticsIsConsistent( + diagnostics: SystemHealthDiagnosticsValue +): boolean { + const checks = diagnostics.checks; + const ready = + checks.application.status === "ready" && + checks.database.status === "ready" && + checks.frontend.status === "ready" && + checks.release.status === "verified" && + checks.worker.status === "ready"; + const queueTimeIsConsistent = + diagnostics.queue.status === "unavailable" || + diagnostics.queue.oldestQueuedAtMs === undefined || + diagnostics.queue.oldestQueuedAtMs <= diagnostics.checkedAtMs; + const sessions = diagnostics.dependencies.sessions; + const gateway = diagnostics.dependencies.gateway; + const sessionTimeIsConsistent = + sessions.state === "unavailable" || + (sessions.observedAtMs <= diagnostics.checkedAtMs && + (sessions.state !== "last-known-good" || + sessions.staleSinceMs <= diagnostics.checkedAtMs)); + const dependencyFreshnessIsConsistent = + sessions.state !== "fresh" || + (gateway.status === "observed" && gateway.freshness === "fresh"); + const queueChecksAreConsistent = + diagnostics.queue.status === "observed" + ? checks.database.status === "ready" && + (checks.release.status === "verified") === + (checks.worker.status !== "unavailable") && + (checks.worker.status !== "ready" || + diagnostics.queue.workers.onlineCount > 0) + : checks.database.status === "unavailable" && + checks.worker.status === "unavailable"; + return ( + queueTimeIsConsistent && + sessionTimeIsConsistent && + dependencyFreshnessIsConsistent && + queueChecksAreConsistent && + (diagnostics.status === "ready") === ready + ); +} + +/** Session-only readiness, dependency, and queue diagnostics without identities. */ +export const systemHealthDiagnosticsSchema = v.pipe( + v.strictObject({ + checkedAtMs: systemHealthDiagnosticsTimestampSchema, + checks: systemHealthDiagnosticsChecksSchema, + dependencies: v.strictObject({ + gateway: systemHealthDiagnosticsGatewaySchema, + sessions: systemHealthDiagnosticsSessionsSchema, + }), + queue: systemHealthDiagnosticsQueueSchema, + status: v.picklist(["not-ready", "ready"]), + }), + v.check( + systemHealthDiagnosticsIsConsistent, + "System health diagnostics aggregate is inconsistent" + ) +); + +export type SystemHealthDiagnostics = v.InferOutput; + /** Public runtime identity returned by the system procedure. */ export const runtimeIdentitySchema = v.strictObject({ revision: v.pipe(v.string(), v.description("Full Bun Git revision.")), @@ -144,8 +402,34 @@ export const systemMetricsContract = { }, } as const satisfies ProcedureContract; +/** Session-only detailed health contract without automation or control authority. */ +export const systemHealthDiagnosticsContract = { + access: { + capabilities: [], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], + }, + domain: "system", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: emptyInputSchema, + inputSchemaId: "system.healthDiagnostics.input", + kind: "query", + name: "system.healthDiagnostics", + output: systemHealthDiagnosticsSchema, + outputSchemaId: "system.healthDiagnostics.output", + summary: + "Returns bounded readiness, dependency, and queue diagnostics without operational identities.", + transport: { + batching: "adapter-default", + handler: "default", + requestBody: "default", + }, +} as const satisfies ProcedureContract; + /** Implemented system tRPC contracts. */ export const systemProcedureContracts = [ + systemHealthDiagnosticsContract, systemMetricsContract, runtimeIdentityContract, ] as const; diff --git a/greenfield/src/server/domains/cache/heartbeatProjection.test.ts b/greenfield/src/server/domains/cache/heartbeatProjection.test.ts new file mode 100644 index 000000000..7ee8a65a4 --- /dev/null +++ b/greenfield/src/server/domains/cache/heartbeatProjection.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, test } from "bun:test"; + +import { jobActionDefinitions } from "../jobs/actionRegistry.ts"; +import type { + JobRepositoryReader, + ScheduleRecordWithRelations, +} from "../jobs/repository.ts"; +import type { TaskRepositoryReader } from "../tasks/repositoryTypes.ts"; +import { + readCacheHeartbeatDashboardJobs, + readCacheHeartbeatTasks, + readCacheHeartbeatTasksWithCronRefresh, +} from "./heartbeatProjection.ts"; + +function uuid(index: number): string { + return `019fdf40-0000-7000-8000-${String(index).padStart(12, "0")}`; +} + +function definition(scheduleId: string) { + const found = jobActionDefinitions.find( + (candidate) => candidate.scheduleId === scheduleId + ); + if (found === undefined) throw new Error(`Missing test definition: ${scheduleId}`); + return found; +} + +function relation( + scheduleId: string, + input: { + readonly activeDisableIntent?: Record; + readonly activeRun?: Record; + readonly enabled: boolean; + readonly latestRun?: Record; + readonly nextRunAt?: Date; + } +): ScheduleRecordWithRelations { + return { + ...(input.activeDisableIntent === undefined + ? {} + : { activeDisableIntent: input.activeDisableIntent }), + ...(input.activeRun === undefined ? {} : { activeRun: input.activeRun }), + ...(input.latestRun === undefined ? {} : { latestRun: input.latestRun }), + schedule: { + actionKey: definition(scheduleId).actionKey, + actionPayloadJson: '{"private":"payload"}', + description: "Private description", + enabled: input.enabled, + id: scheduleId, + name: "Private schedule name", + nextRunAt: input.nextRunAt ?? null, + resourceKeysJson: '["private.resource"]', + }, + } as unknown as ScheduleRecordWithRelations; +} + +describe("cache heartbeat projection", () => { + test("declassifies only canonical task identifiers and operational relevance", () => { + const repository = { + readHeartbeatCandidates: () => ({ + rows: [ + { + assignee: "rajohan" as const, + id: uuid(3), + priority: "low" as const, + status: "blocked" as const, + }, + { + assignee: "mira-2026" as const, + automation: { + cronJobId: "private-cron-1", + recurring: false, + }, + id: uuid(1), + priority: "high" as const, + status: "in-progress" as const, + }, + { + automation: { + cronJobId: "private-cron-2", + recurring: true, + }, + id: uuid(2), + priority: "low" as const, + status: "todo" as const, + }, + ], + totalCount: 3, + }), + } satisfies Pick; + + const projection = readCacheHeartbeatTasks(repository, (cronJobId) => + cronJobId === "private-cron-1" + ? { + desiredEnabled: false, + enabled: false, + state: "present", + synchronization: "confirmed", + } + : { state: "missing" } + ); + expect(projection).toEqual({ + items: [ + { + automation: { + cron: { + desiredEnabled: false, + enabled: false, + state: "present", + synchronization: "confirmed", + }, + recurring: false, + }, + id: uuid(1), + priority: "high", + relevance: ["automation-linked", "agent-priority"], + status: "in-progress", + }, + { + automation: { + cron: { state: "missing" }, + recurring: true, + }, + id: uuid(2), + priority: "low", + relevance: ["automation-linked"], + status: "todo", + }, + { + id: uuid(3), + priority: "low", + relevance: ["owner-blocked"], + status: "blocked", + }, + ], + state: "available", + totalCount: 3, + truncated: false, + }); + expect(JSON.stringify(projection)).not.toContain("assignee"); + expect(JSON.stringify(projection)).not.toContain("cronJobId"); + }); + + test("refreshes cron outside the task read and independently of task failures", async () => { + const events: string[] = []; + const snapshot = { + rows: [], + totalCount: 0, + }; + const available = await readCacheHeartbeatTasksWithCronRefresh( + () => { + events.push("task-read"); + return snapshot; + }, + () => { + events.push("cron-refresh"); + return Promise.resolve(); + }, + () => ({ state: "unavailable" }) + ); + expect(events).toEqual(["task-read", "cron-refresh"]); + expect(available).toEqual({ + items: [], + state: "available", + totalCount: 0, + truncated: false, + }); + + events.length = 0; + const unavailable = await readCacheHeartbeatTasksWithCronRefresh( + () => { + events.push("task-read-failed"); + throw new Error("private database detail"); + }, + () => { + events.push("cron-refresh-after-failure"); + return Promise.resolve(); + }, + () => ({ state: "missing" }) + ); + expect(events).toEqual(["task-read-failed", "cron-refresh-after-failure"]); + expect(unavailable).toEqual({ state: "unavailable" }); + }); + + test("preserves readable tasks when cron refresh or lookup is unavailable", async () => { + const snapshot = { + rows: [ + { + automation: { + cronJobId: "private-cron", + recurring: true, + }, + id: uuid(4), + priority: "low" as const, + status: "todo" as const, + }, + { + assignee: "rajohan" as const, + id: uuid(5), + priority: "low" as const, + status: "blocked" as const, + }, + ], + totalCount: 2, + }; + let cronLookups = 0; + const refreshDegraded = await readCacheHeartbeatTasksWithCronRefresh( + () => snapshot, + () => Promise.reject(new Error("private cron refresh failure")), + () => { + cronLookups += 1; + return { state: "missing" }; + } + ); + const expected = { + items: [ + { + automation: { + cron: { state: "unavailable" as const }, + recurring: true, + }, + id: uuid(4), + priority: "low" as const, + relevance: ["automation-linked" as const], + status: "todo" as const, + }, + { + id: uuid(5), + priority: "low" as const, + relevance: ["owner-blocked" as const], + status: "blocked" as const, + }, + ], + state: "available" as const, + totalCount: 2, + truncated: false, + }; + + expect(refreshDegraded).toEqual(expected); + expect(cronLookups).toBe(0); + + const lookupDegraded = await readCacheHeartbeatTasksWithCronRefresh( + () => snapshot, + () => Promise.resolve(), + () => { + throw new Error("private cron lookup failure"); + } + ); + expect(lookupDegraded).toEqual(expected); + }); + + test("projects every code-owned schedule with compact run and expiry state", () => { + const queuedRun = { + firstStartedAt: null, + finishedAt: null, + payloadJson: '{"private":"queued-payload"}', + queuedAt: new Date(4000), + state: "queued", + terminalCode: null, + terminalMessage: "Private queued message", + triggerType: "schedule", + updatedAt: new Date(4500), + }; + const runningRun = { + firstStartedAt: new Date(4500), + finishedAt: null, + leaseOwnerId: "private-worker", + queuedAt: new Date(4000), + state: "running", + terminalCode: null, + terminalMessage: null, + triggerType: "schedule", + updatedAt: new Date(6000), + }; + const failedRun = { + firstStartedAt: new Date(3000), + finishedAt: new Date(3500), + queuedAt: new Date(2500), + resultJson: '{"private":"result"}', + state: "failed", + terminalCode: "provider-unavailable", + terminalMessage: "Private terminal message", + triggerType: "schedule", + updatedAt: new Date(3500), + }; + const byId = new Map([ + [ + "cache.moltbook-dashboard", + relation("cache.moltbook-dashboard", { + activeRun: runningRun, + enabled: true, + latestRun: runningRun, + nextRunAt: new Date(10_000), + }), + ], + [ + "cache.system-host", + relation("cache.system-host", { + activeDisableIntent: { + createdAt: new Date(1000), + expiresAt: new Date(5500), + id: uuid(90), + reason: "Private reason", + }, + activeRun: queuedRun, + enabled: false, + latestRun: queuedRun, + nextRunAt: new Date(9000), + }), + ], + [ + "maintenance.rotate-managed-logs", + relation("maintenance.rotate-managed-logs", { + activeDisableIntent: { + createdAt: new Date(1000), + expiresAt: null, + id: uuid(91), + reason: "Private indefinite reason", + }, + enabled: false, + latestRun: failedRun, + }), + ], + ]); + const repository = { + findSchedule: (id: string) => byId.get(id), + } satisfies Pick; + + const result = readCacheHeartbeatDashboardJobs(repository, 5000); + expect(result.generatedAtMs).toBe(6000); + expect(result.dashboardJobs).toMatchObject({ + items: [ + { + activeRun: { state: "running" }, + defaultEnabled: true, + enabled: true, + id: "cache.moltbook-dashboard", + latestRun: { state: "running", triggerType: "schedule" }, + nextRunAtMs: 10_000, + state: "present", + }, + { + activeRun: { state: "queued" }, + defaultEnabled: true, + disableIntent: { expiresAtMs: 5500, valid: false }, + enabled: false, + id: "cache.system-host", + latestRun: { state: "queued", triggerType: "schedule" }, + nextRunAtMs: null, + state: "present", + }, + { + defaultEnabled: true, + disableIntent: { valid: true }, + enabled: false, + id: "maintenance.rotate-managed-logs", + latestRun: { + state: "failed", + terminalCode: "provider-unavailable", + triggerType: "schedule", + }, + nextRunAtMs: null, + state: "present", + }, + { + defaultEnabled: false, + id: "system.worker-smoke", + state: "missing", + }, + ], + state: "available", + }); + const serialized = JSON.stringify(result); + for (const forbidden of [ + "Private", + "payloadJson", + "resultJson", + "leaseOwnerId", + "resourceKeys", + uuid(90), + uuid(91), + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + + test("degrades inconsistent or failed Dashboard-job reads without failing heartbeat", () => { + const selectedDefinition = definition("cache.moltbook-dashboard"); + const terminalActiveRun = relation(selectedDefinition.scheduleId, { + activeRun: { state: "succeeded" }, + enabled: true, + }); + + expect( + readCacheHeartbeatDashboardJobs( + { findSchedule: () => terminalActiveRun }, + 5000, + [selectedDefinition] + ) + ).toEqual({ + dashboardJobs: { state: "unavailable" }, + generatedAtMs: 5000, + }); + expect( + readCacheHeartbeatDashboardJobs( + { + findSchedule() { + throw new Error("private repository failure"); + }, + }, + 6000, + [selectedDefinition] + ) + ).toEqual({ + dashboardJobs: { state: "unavailable" }, + generatedAtMs: 6000, + }); + }); +}); diff --git a/greenfield/src/server/domains/cache/heartbeatProjection.ts b/greenfield/src/server/domains/cache/heartbeatProjection.ts new file mode 100644 index 000000000..b5720eb4d --- /dev/null +++ b/greenfield/src/server/domains/cache/heartbeatProjection.ts @@ -0,0 +1,287 @@ +import { getTime } from "date-fns"; + +import { + type CacheHeartbeatResult, + cacheHeartbeatTaskRelevanceValues, +} from "../../../contracts/cache.ts"; +import { compareStrings } from "../../../shared/validation.ts"; +import { + jobActionDefinitions, + type JobActionDefinition, +} from "../jobs/actionRegistry.ts"; +import type { JobRunRecord } from "../jobs/records.ts"; +import type { + JobRepositoryReader, + ScheduleRecordWithRelations, +} from "../jobs/repository.ts"; +import { + taskHeartbeatAgentAssignee, + taskHeartbeatAgentPriorities, + taskHeartbeatOwnerAssignee, + taskHeartbeatOwnerStatus, +} from "../tasks/heartbeatPolicy.ts"; +import type { + TaskHeartbeatCandidateSnapshot, + TaskRepositoryReader, +} from "../tasks/repositoryTypes.ts"; + +type HeartbeatTasks = CacheHeartbeatResult["tasks"]; +type HeartbeatDashboardJobs = CacheHeartbeatResult["dashboardJobs"]; +type HeartbeatTaskCron = NonNullable< + Extract< + HeartbeatTasks, + { readonly state: "available" } + >["items"][number]["automation"] +>["cron"]; +type PresentDashboardJob = Extract< + HeartbeatDashboardJobs, + { readonly state: "available" } +>["items"][number] & { readonly state: "present" }; + +function unavailableHeartbeatTaskCron(): HeartbeatTaskCron { + return { state: "unavailable" }; +} + +function readHeartbeatTaskCron( + readCron: (cronJobId: string) => HeartbeatTaskCron, + cronJobId: string +): HeartbeatTaskCron { + try { + return readCron(cronJobId); + } catch { + return unavailableHeartbeatTaskCron(); + } +} + +/** @returns The bounded content-free task projection for cache-read automation. */ +export function projectCacheHeartbeatTasks( + snapshot: TaskHeartbeatCandidateSnapshot, + readCron: (cronJobId: string) => HeartbeatTaskCron = unavailableHeartbeatTaskCron +): HeartbeatTasks { + const items = snapshot.rows + .map((row) => { + const relevance = cacheHeartbeatTaskRelevanceValues.filter((value) => { + switch (value) { + case "automation-linked": { + return row.automation !== undefined; + } + case "agent-priority": { + return ( + row.assignee === taskHeartbeatAgentAssignee && + taskHeartbeatAgentPriorities.some( + (priority) => priority === row.priority + ) + ); + } + case "owner-blocked": { + return ( + row.assignee === taskHeartbeatOwnerAssignee && + row.status === taskHeartbeatOwnerStatus + ); + } + } + }); + return { + ...(row.automation === undefined + ? {} + : { + automation: { + cron: readHeartbeatTaskCron( + readCron, + row.automation.cronJobId + ), + recurring: row.automation.recurring, + }, + }), + id: row.id, + priority: row.priority, + relevance, + status: row.status, + }; + }) + .toSorted((left, right) => compareStrings(left.id, right.id)); + return { + items, + state: "available", + totalCount: snapshot.totalCount, + truncated: snapshot.totalCount > items.length, + }; +} + +/** + * Reads one short task snapshot, then refreshes cron outside its transaction boundary. + * A task-read failure cannot suppress the independent cron refresh. + * A cron refresh failure cannot suppress an otherwise readable task snapshot. + * @param readSnapshot Synchronous task snapshot boundary that owns any short transaction. + * @param refreshCron Process-owned cron refresh performed after the task read closes. + * @param readCron Identity-private lookup against the resulting cron snapshot. + * @returns The projected tasks, or an unavailable task projection after a read failure. + */ +export async function readCacheHeartbeatTasksWithCronRefresh( + readSnapshot: () => TaskHeartbeatCandidateSnapshot, + refreshCron: () => Promise, + readCron: (cronJobId: string) => HeartbeatTaskCron +): Promise { + const snapshotRead = (() => { + try { + return { snapshot: readSnapshot(), state: "available" as const }; + } catch { + return { state: "unavailable" as const }; + } + })(); + let cronAvailable = true; + try { + await refreshCron(); + } catch { + cronAvailable = false; + } + return snapshotRead.state === "unavailable" + ? { state: "unavailable" } + : projectCacheHeartbeatTasks( + snapshotRead.snapshot, + cronAvailable ? readCron : unavailableHeartbeatTaskCron + ); +} + +/** @returns A bounded task projection using an unavailable cron reader by default. */ +export function readCacheHeartbeatTasks( + repository: Pick, + readCron?: (cronJobId: string) => HeartbeatTaskCron +): HeartbeatTasks { + return projectCacheHeartbeatTasks(repository.readHeartbeatCandidates(), readCron); +} + +function projectActiveRun(run: JobRunRecord): PresentDashboardJob["activeRun"] { + if (run.state !== "queued" && run.state !== "running") { + throw new Error("Heartbeat active Dashboard run is terminal"); + } + return { + ...(run.firstStartedAt === null + ? {} + : { firstStartedAtMs: getTime(run.firstStartedAt) }), + queuedAtMs: getTime(run.queuedAt), + state: run.state, + updatedAtMs: getTime(run.updatedAt), + }; +} + +function projectLatestRun(run: JobRunRecord): PresentDashboardJob["latestRun"] { + return { + ...(run.finishedAt === null ? {} : { finishedAtMs: getTime(run.finishedAt) }), + ...(run.firstStartedAt === null + ? {} + : { firstStartedAtMs: getTime(run.firstStartedAt) }), + queuedAtMs: getTime(run.queuedAt), + state: run.state, + ...(run.terminalCode === null ? {} : { terminalCode: run.terminalCode }), + triggerType: run.triggerType, + updatedAtMs: getTime(run.updatedAt), + }; +} + +function historicalRunTimestamps(relation: ScheduleRecordWithRelations): number[] { + return [relation.activeRun, relation.latestRun].flatMap((run) => + run === undefined + ? [] + : [ + getTime(run.queuedAt), + getTime(run.updatedAt), + ...(run.firstStartedAt === null ? [] : [getTime(run.firstStartedAt)]), + ...(run.finishedAt === null ? [] : [getTime(run.finishedAt)]), + ] + ); +} + +export interface CacheHeartbeatDashboardJobsRead { + readonly dashboardJobs: HeartbeatDashboardJobs; + readonly generatedAtMs: number; +} + +/** + * Projects the complete release-owned schedule registry without action payloads or identities. + * @param repository Synchronous Dashboard-job read boundary. + * @param candidateGeneratedAtMs Response clock already clamped to other observations. + * @param definitions Reviewed release-owned definitions, injectable only for focused tests. + * @returns Canonical missing/present rows and a clock covering all exposed lifecycle times. + */ +export function readCacheHeartbeatDashboardJobs( + repository: Pick, + candidateGeneratedAtMs: number, + definitions: readonly JobActionDefinition[] = jobActionDefinitions +): CacheHeartbeatDashboardJobsRead { + try { + const rows = definitions + .map((definition) => ({ + definition, + relation: repository.findSchedule(definition.scheduleId), + })) + .toSorted((left, right) => + compareStrings(left.definition.scheduleId, right.definition.scheduleId) + ); + const generatedAtMs = Math.max( + candidateGeneratedAtMs, + ...rows.flatMap(({ relation }) => + relation === undefined ? [] : historicalRunTimestamps(relation) + ) + ); + return { + dashboardJobs: { + items: rows.map(({ definition, relation }) => { + if ( + relation === undefined || + relation.schedule.actionKey !== definition.actionKey + ) { + return { + defaultEnabled: definition.defaultEnabled, + id: definition.scheduleId, + state: "missing" as const, + }; + } + return { + ...(relation.activeRun === undefined + ? {} + : { activeRun: projectActiveRun(relation.activeRun) }), + defaultEnabled: definition.defaultEnabled, + ...(relation.activeDisableIntent === undefined + ? {} + : { + disableIntent: { + ...(relation.activeDisableIntent.expiresAt === null + ? {} + : { + expiresAtMs: getTime( + relation.activeDisableIntent.expiresAt + ), + }), + valid: + relation.activeDisableIntent.expiresAt === + null || + getTime( + relation.activeDisableIntent.expiresAt + ) > generatedAtMs, + }, + }), + enabled: relation.schedule.enabled, + id: definition.scheduleId, + ...(relation.latestRun === undefined + ? {} + : { latestRun: projectLatestRun(relation.latestRun) }), + nextRunAtMs: + relation.schedule.enabled && + relation.schedule.nextRunAt !== null + ? getTime(relation.schedule.nextRunAt) + : null, + state: "present" as const, + }; + }), + state: "available", + }, + generatedAtMs, + }; + } catch { + return { + dashboardJobs: { state: "unavailable" }, + generatedAtMs: candidateGeneratedAtMs, + }; + } +} diff --git a/greenfield/src/server/domains/cache/procedures.test.ts b/greenfield/src/server/domains/cache/procedures.test.ts index 5e1b192b0..db9349f27 100644 --- a/greenfield/src/server/domains/cache/procedures.test.ts +++ b/greenfield/src/server/domains/cache/procedures.test.ts @@ -77,6 +77,7 @@ describe("cache procedures", () => { () => writeOnly.getEntry({ key: systemHostKey }), "FORBIDDEN" ); + await expectTrpcCode(() => writeOnly.getHeartbeat({}), "FORBIDDEN"); }); test("serves bounded status and durable refresh results", async () => { @@ -89,6 +90,7 @@ describe("cache procedures", () => { totalCount: 0, truncated: false, }, + dashboardJobs: { items: [], state: "available" }, gateway: { connection: { checkedAtMs: 1000, @@ -102,7 +104,13 @@ describe("cache procedures", () => { pendingSync: "unknown", state: "unavailable", }, - schemaVersion: 1, + schemaVersion: 4, + tasks: { + items: [], + state: "available", + totalCount: 0, + truncated: false, + }, }), getStatus: () => Effect.succeed({ @@ -120,6 +128,13 @@ describe("cache procedures", () => { { cacheService } ) ).cache; + const cacheOnlyAutomation = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication(["cache:read"]), + createTestApplicationRuntime(), + { cacheService } + ) + ).cache; expect(await caller.getStatus({})).toEqual({ entries: [], @@ -127,13 +142,14 @@ describe("cache procedures", () => { totalCount: 0, truncated: false, }); - expect(await caller.getHeartbeat({})).toEqual({ + const heartbeat = { cache: { entries: [], generatedAtMs: 1000, totalCount: 0, truncated: false, }, + dashboardJobs: { items: [], state: "available" }, gateway: { connection: { checkedAtMs: 1000, @@ -144,8 +160,16 @@ describe("cache procedures", () => { }, generatedAtMs: 1000, openClawCron: { pendingSync: "unknown", state: "unavailable" }, - schemaVersion: 1, - }); + schemaVersion: 4, + tasks: { + items: [], + state: "available", + totalCount: 0, + truncated: false, + }, + } as const; + expect(await cacheOnlyAutomation.getHeartbeat({})).toEqual(heartbeat); + expect(await caller.getHeartbeat({})).toEqual(heartbeat); expect( await caller.refreshEntry({ idempotencyKey: "A".repeat(32), diff --git a/greenfield/src/server/domains/cache/providerRegistry.ts b/greenfield/src/server/domains/cache/providerRegistry.ts index e58260726..54b11076c 100644 --- a/greenfield/src/server/domains/cache/providerRegistry.ts +++ b/greenfield/src/server/domains/cache/providerRegistry.ts @@ -2,17 +2,23 @@ import * as v from "valibot"; import { cacheEntryKeySchema, + cacheEntryPayloadSchema, cacheEntrySchemaIdSchema, cacheEntrySourceSchema, systemHostCachePayloadSchema, } from "../../../contracts/cache.ts"; +import { moltbookDashboardCachePayloadSchema } from "../../../contracts/moltbook.ts"; import type { JsonObject } from "../../../shared/json.ts"; -import { findJobActionDefinition } from "../jobs/actionRegistry.ts"; +import { + findJobActionDefinition, + moltbookDashboardCacheJobActionKey, + moltbookDashboardCacheJobScheduleId, +} from "../jobs/actionRegistry.ts"; export interface CacheProviderDefinition { readonly actionKey: string; readonly key: string; - readonly payloadSchema: typeof systemHostCachePayloadSchema; + readonly payloadSchema: v.GenericSchema; readonly scheduleId: string; readonly schemaId: string; readonly source: string; @@ -50,8 +56,21 @@ const systemHostProvider = validateCacheProviderDefinition({ ttlMs: 86_400_000, }); +const moltbookDashboardProvider = validateCacheProviderDefinition({ + actionKey: moltbookDashboardCacheJobActionKey, + key: "moltbook.dashboard", + payloadSchema: moltbookDashboardCachePayloadSchema, + scheduleId: moltbookDashboardCacheJobScheduleId, + schemaId: "moltbook.dashboard.v1", + source: "moltbook.api", + ttlMs: 30 * 60_000, +}); + /** Complete local-only provider directory for the implemented cache slice. */ -export const cacheProviderDefinitions = Object.freeze([systemHostProvider]); +export const cacheProviderDefinitions = Object.freeze([ + systemHostProvider, + moltbookDashboardProvider, +]); const providerByKey = new Map( cacheProviderDefinitions.map((definition) => [definition.key, definition]) @@ -81,7 +100,7 @@ export function parseCacheProviderPayload( definition: CacheProviderDefinition, payload: JsonObject ): JsonObject { - return v.parse(definition.payloadSchema, payload); + return v.parse(cacheEntryPayloadSchema, v.parse(definition.payloadSchema, payload)); } /** diff --git a/greenfield/src/server/domains/cache/service.test.ts b/greenfield/src/server/domains/cache/service.test.ts index c5bd2a3e6..69d40c04b 100644 --- a/greenfield/src/server/domains/cache/service.test.ts +++ b/greenfield/src/server/domains/cache/service.test.ts @@ -146,10 +146,60 @@ describe("cache service", () => { truncated: true, }; }, + readHeartbeatDashboardJobs: (generatedAtMs) => { + calls.push("dashboard-jobs"); + return { + dashboardJobs: { items: [], state: "available" }, + generatedAtMs, + }; + }, + readHeartbeatTasks: () => { + calls.push("tasks"); + return { + items: [ + { + automation: { + cron: { + enabled: true, + lastRunAtMs: 5100, + nextRunAtMs: 9000, + runningAtMs: 5200, + state: "present" as const, + synchronization: "confirmed" as const, + }, + recurring: true, + }, + id: uuid(40), + priority: "high" as const, + relevance: [ + "automation-linked" as const, + "agent-priority" as const, + ], + status: "in-progress" as const, + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }; + }, readOpenClawCronProjection: () => { calls.push("cron"); return { count: 7, + health: { + disabledCount: 0, + enabledCount: 7, + inspectedCount: 7, + intendedDisabledCount: 0, + lastRunErrorCount: 0, + runningCount: 0, + staleRunningCount: 0, + synchronizationConflictCount: 1, + synchronizationPendingCount: 0, + truncated: false, + unexpectedDisabledCount: 0, + }, observedAtMs: 4600, pendingSync: "present", state: "fresh", @@ -158,7 +208,13 @@ describe("cache service", () => { }); const heartbeat = await Effect.runPromise(service.getHeartbeat()); - expect(calls).toEqual(["connection", "sessions", "cron"]); + expect(calls).toEqual([ + "connection", + "sessions", + "tasks", + "cron", + "dashboard-jobs", + ]); expect(heartbeat).toMatchObject({ cache: { entries: [{ freshness: "stale", key: "system.host" }], @@ -166,6 +222,7 @@ describe("cache service", () => { totalCount: 129, truncated: true, }, + dashboardJobs: { items: [], state: "available" }, gateway: { connection: { checkedAtMs: 5000, @@ -179,14 +236,30 @@ describe("cache service", () => { truncated: true, }, }, - generatedAtMs: 5000, + generatedAtMs: 5200, openClawCron: { count: 7, observedAtMs: 4600, pendingSync: "present", state: "fresh", }, - schemaVersion: 1, + schemaVersion: 4, + tasks: { + items: [ + { + automation: { + cron: { + lastRunAtMs: 5100, + nextRunAtMs: 9000, + runningAtMs: 5200, + }, + }, + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }, }); expect(JSON.stringify(heartbeat)).not.toContain("session-key"); }); @@ -207,8 +280,42 @@ describe("cache service", () => { state: "fresh", truncated: false, }), + readHeartbeatTasks: () => ({ + items: [ + { + automation: { + cron: { + enabled: true, + state: "present", + synchronization: "confirmed", + }, + recurring: true, + }, + id: "019fc968-1a9b-7765-8f1b-d5b863b0e7b4", + priority: "high", + relevance: ["automation-linked", "agent-priority"], + status: "in-progress", + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }), readOpenClawCronProjection: () => ({ count: 4, + health: { + disabledCount: 0, + enabledCount: 4, + inspectedCount: 4, + intendedDisabledCount: 0, + lastRunErrorCount: 0, + runningCount: 0, + staleRunningCount: 0, + synchronizationConflictCount: 0, + synchronizationPendingCount: 0, + truncated: false, + unexpectedDisabledCount: 0, + }, observedAtMs: 4500, pendingSync: "none", state: "fresh", @@ -225,6 +332,15 @@ describe("cache service", () => { staleSinceMs: 6000, state: "last-known-good", }, + tasks: { + items: [ + { + automation: { + cron: { state: "unavailable" }, + }, + }, + ], + }, }); const unavailable = createCacheService({ @@ -240,16 +356,271 @@ describe("cache service", () => { readOpenClawCronProjection: () => { throw new Error("private cron payload"); }, + readHeartbeatDashboardJobs: () => ({ + dashboardJobs: { + items: [ + { + defaultEnabled: true, + id: "cache.system-host", + state: "missing", + }, + { + defaultEnabled: true, + id: "cache.system-host", + state: "missing", + }, + ], + state: "available", + }, + generatedAtMs: 7000, + }), + readHeartbeatTasks: () => + ({ + items: [], + state: "available", + totalCount: 0, + truncated: true, + }) as never, }); expect(await Effect.runPromise(unavailable.getHeartbeat())).toMatchObject({ gateway: { connection: { freshness: "unavailable", phase: "stopped" }, sessions: { state: "unavailable" }, }, + dashboardJobs: { state: "unavailable" }, openClawCron: { pendingSync: "unknown", state: "unavailable" }, + tasks: { state: "unavailable" }, }); }); + test("contains malformed local heartbeat projections independently", async () => { + const validTasks = { + items: [], + state: "available" as const, + totalCount: 0, + truncated: false, + }; + const malformedTaskProjections = [ + { + items: [], + state: "available", + totalCount: 0, + truncated: true, + }, + { + items: [ + { + id: uuid(30), + priority: "low", + relevance: ["agent-priority"], + status: "todo", + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }, + { + items: [ + { + id: uuid(31), + priority: "low", + relevance: ["owner-blocked"], + status: "todo", + }, + ], + state: "available", + totalCount: 1, + truncated: false, + }, + ] as const; + for (const [index, tasks] of malformedTaskProjections.entries()) { + const service = createCacheService({ + cacheRepository: readOnlyCacheRepository(record), + jobRepository: Object.freeze({}) as never, + nowMs: () => 7000, + readHeartbeatDashboardJobs: (generatedAtMs) => ({ + dashboardJobs: { items: [], state: "available" }, + generatedAtMs, + }), + readHeartbeatTasks: () => tasks as never, + }); + expect( + await Effect.runPromise(service.getHeartbeat()), + `malformed task projection ${index}` + ).toMatchObject({ + dashboardJobs: { items: [], state: "available" }, + tasks: { state: "unavailable" }, + }); + } + + const malformedDashboardJobProjections = [ + { + items: [ + { + defaultEnabled: true, + id: "cache.system-host", + state: "missing", + }, + { + defaultEnabled: true, + id: "cache.system-host", + state: "missing", + }, + ], + state: "available", + }, + { + items: [ + { + defaultEnabled: true, + enabled: true, + id: "cache.system-host", + nextRunAtMs: null, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + nextRunAtMs: 8000, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + defaultEnabled: true, + disableIntent: { expiresAtMs: 8000, valid: true }, + enabled: true, + id: "cache.system-host", + nextRunAtMs: 8000, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + activeRun: { + queuedAtMs: 6000, + state: "queued", + updatedAtMs: 6200, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + finishedAtMs: 6500, + firstStartedAtMs: 6100, + queuedAtMs: 6000, + state: "failed", + terminalCode: "provider-unavailable", + triggerType: "schedule", + updatedAtMs: 6500, + }, + nextRunAtMs: null, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + queuedAtMs: 6000, + state: "queued", + triggerType: "schedule", + updatedAtMs: 6200, + }, + nextRunAtMs: null, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + activeRun: { + queuedAtMs: 6000, + state: "running", + updatedAtMs: 6500, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + queuedAtMs: 6000, + state: "running", + triggerType: "schedule", + updatedAtMs: 6500, + }, + nextRunAtMs: null, + state: "present", + }, + ], + state: "available", + }, + { + items: [ + { + activeRun: { + queuedAtMs: 6000, + state: "queued", + updatedAtMs: 6200, + }, + defaultEnabled: true, + enabled: false, + id: "cache.system-host", + latestRun: { + firstStartedAtMs: 6100, + queuedAtMs: 6000, + state: "running", + triggerType: "schedule", + updatedAtMs: 6500, + }, + nextRunAtMs: null, + state: "present", + }, + ], + state: "available", + }, + ] as const; + for (const [index, dashboardJobs] of malformedDashboardJobProjections.entries()) { + const service = createCacheService({ + cacheRepository: readOnlyCacheRepository(record), + jobRepository: Object.freeze({}) as never, + nowMs: () => 7000, + readHeartbeatDashboardJobs: () => + ({ + dashboardJobs, + generatedAtMs: 7000, + }) as never, + readHeartbeatTasks: () => validTasks, + }); + expect( + await Effect.runPromise(service.getHeartbeat()), + `malformed dashboard-job projection ${index}` + ).toMatchObject({ + dashboardJobs: { state: "unavailable" }, + tasks: { items: [], state: "available" }, + }); + } + }); + test("replays before mutable provider and schedule lookups with caller isolation", async () => { const database = await openFreshMigratedDatabase(); const jobRepository = createJobRepository( diff --git a/greenfield/src/server/domains/cache/service.ts b/greenfield/src/server/domains/cache/service.ts index 0fb96697e..4893addc1 100644 --- a/greenfield/src/server/domains/cache/service.ts +++ b/greenfield/src/server/domains/cache/service.ts @@ -8,8 +8,11 @@ import { type CacheStatusResult, type GetCacheEntryInput, type RefreshCacheEntryInput, + cacheHeartbeatDashboardJobsAreConsistent, + cacheHeartbeatDashboardJobsSchema, cacheHeartbeatResultSchema, cacheHeartbeatSchemaVersion, + cacheHeartbeatTasksSchema, cacheStatusResultSchema, } from "../../../contracts/cache.ts"; import { type JobRunSummary, jobTimestampSchema } from "../../../contracts/jobModel.ts"; @@ -33,6 +36,7 @@ import { CacheNotFoundError, type CacheOperationError, } from "./errors.ts"; +import type { CacheHeartbeatDashboardJobsRead } from "./heartbeatProjection.ts"; import { findCacheProviderDefinition } from "./providerRegistry.ts"; import { toCacheEntry, toCacheEntryStatus } from "./records.ts"; import type { CacheRepository } from "./repository.ts"; @@ -80,6 +84,17 @@ function readEffect( ); } +function asyncReadEffect(operation: () => Promise): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => new CacheUnexpectedOperationError({ cause }), + try: operation, + }).pipe( + Effect.catchTag("CacheUnexpectedOperationError", (error) => + Effect.die(error.cause) + ) + ); +} + function mutationEffect( operation: () => Promise ): Effect.Effect { @@ -123,6 +138,7 @@ function demoteCronWhenDisconnected( } return { count: projection.count, + health: projection.health, observedAtMs: projection.observedAtMs, pendingSync: projection.pendingSync, staleSinceMs: Math.max(connection.checkedAtMs, projection.observedAtMs), @@ -130,6 +146,29 @@ function demoteCronWhenDisconnected( }; } +function demoteTaskCronWhenGlobalCronIsNotFresh( + tasks: CacheHeartbeatResult["tasks"], + openClawCron: CacheHeartbeatResult["openClawCron"] +): CacheHeartbeatResult["tasks"] { + if (tasks.state === "unavailable" || openClawCron.state === "fresh") { + return tasks; + } + return { + ...tasks, + items: tasks.items.map((task) => + task.automation === undefined + ? task + : { + ...task, + automation: { + ...task.automation, + cron: { state: "unavailable" }, + }, + } + ), + }; +} + export interface CacheServiceShape { readonly getEntry: ( input: GetCacheEntryInput @@ -153,6 +192,12 @@ export interface CacheServiceDependencies { readonly nowMs?: () => number; readonly readGatewayConnection?: () => CacheHeartbeatResult["gateway"]["connection"]; readonly readGatewaySessionsProjection?: () => CacheHeartbeatResult["gateway"]["sessions"]; + readonly readHeartbeatDashboardJobs?: ( + generatedAtMs: number + ) => CacheHeartbeatDashboardJobsRead; + readonly readHeartbeatTasks?: () => + | CacheHeartbeatResult["tasks"] + | Promise; readonly readOpenClawCronProjection?: () => CacheHeartbeatResult["openClawCron"]; readonly wakeEventPump?: () => Promise | void; } @@ -230,6 +275,52 @@ export function createCacheService( } } + async function readTasksProjection(): Promise { + try { + return v.parse( + cacheHeartbeatTasksSchema, + (await dependencies.readHeartbeatTasks?.()) ?? { + state: "unavailable", + } + ); + } catch { + return { state: "unavailable" }; + } + } + + function readDashboardJobsProjection( + generatedAtMs: number + ): CacheHeartbeatDashboardJobsRead { + try { + const read = dependencies.readHeartbeatDashboardJobs?.(generatedAtMs) ?? { + dashboardJobs: { state: "unavailable" }, + generatedAtMs, + }; + const clampedGeneratedAtMs = v.parse( + jobTimestampSchema, + Math.max(generatedAtMs, read.generatedAtMs) + ); + const dashboardJobs = v.parse( + cacheHeartbeatDashboardJobsSchema, + read.dashboardJobs + ); + if ( + !cacheHeartbeatDashboardJobsAreConsistent( + dashboardJobs, + clampedGeneratedAtMs + ) + ) { + throw new Error("Heartbeat Dashboard-job reader is inconsistent"); + } + return { dashboardJobs, generatedAtMs: clampedGeneratedAtMs }; + } catch { + return { + dashboardJobs: { state: "unavailable" }, + generatedAtMs, + }; + } + } + async function wake(): Promise { if (dependencies.wakeEventPump === undefined) return; try { @@ -260,49 +351,77 @@ export function createCacheService( error instanceof CacheNotFoundError ), getHeartbeat: () => - readEffect( - () => { - const requestedAtMs = v.parse(jobTimestampSchema, nowMs()); - const cache = readCacheStatus(requestedAtMs); - const connection = readConnection(requestedAtMs); - const sessions = demoteSessionsWhenDisconnected( - readSessionsProjection(), - connection - ); - const openClawCron = demoteCronWhenDisconnected( - readCronProjection(), - connection - ); - const projectionTimestamps = [ - cache.generatedAtMs, - connection.checkedAtMs, - ...(sessions.state === "unavailable" - ? [] - : [ - sessions.observedAtMs, - ...(sessions.state === "last-known-good" - ? [sessions.staleSinceMs] - : []), - ]), - ...(openClawCron.state === "unavailable" - ? [] - : [ - openClawCron.observedAtMs, - ...(openClawCron.state === "last-known-good" - ? [openClawCron.staleSinceMs] - : []), - ]), - ]; - return v.parse(cacheHeartbeatResultSchema, { - cache, - gateway: { connection, sessions }, - generatedAtMs: Math.max(requestedAtMs, ...projectionTimestamps), - openClawCron, - schemaVersion: cacheHeartbeatSchemaVersion, - }); - }, - (_error): _error is never => false - ), + asyncReadEffect(async () => { + const requestedAtMs = v.parse(jobTimestampSchema, nowMs()); + const cache = readCacheStatus(requestedAtMs); + const connection = readConnection(requestedAtMs); + const sessions = demoteSessionsWhenDisconnected( + readSessionsProjection(), + connection + ); + const taskProjection = await readTasksProjection(); + const openClawCron = demoteCronWhenDisconnected( + readCronProjection(), + connection + ); + const heartbeatTasks = demoteTaskCronWhenGlobalCronIsNotFresh( + taskProjection, + openClawCron + ); + const projectionTimestamps = [ + cache.generatedAtMs, + connection.checkedAtMs, + ...(sessions.state === "unavailable" + ? [] + : [ + sessions.observedAtMs, + ...(sessions.state === "last-known-good" + ? [sessions.staleSinceMs] + : []), + ]), + ...(openClawCron.state === "unavailable" + ? [] + : [ + openClawCron.observedAtMs, + ...(openClawCron.state === "last-known-good" + ? [openClawCron.staleSinceMs] + : []), + ]), + ...(heartbeatTasks.state === "unavailable" + ? [] + : heartbeatTasks.items.flatMap((task) => { + const cron = task.automation?.cron; + return cron?.state === "present" + ? [ + ...(cron.lastRunAtMs === undefined + ? [] + : [cron.lastRunAtMs]), + ...(cron.runningAtMs === undefined + ? [] + : [cron.runningAtMs]), + ] + : []; + })), + ]; + const initialGeneratedAtMs = Math.max( + requestedAtMs, + ...projectionTimestamps + ); + const dashboardJobRead = + readDashboardJobsProjection(initialGeneratedAtMs); + return v.parse(cacheHeartbeatResultSchema, { + cache, + dashboardJobs: dashboardJobRead.dashboardJobs, + gateway: { connection, sessions }, + generatedAtMs: Math.max( + initialGeneratedAtMs, + dashboardJobRead.generatedAtMs + ), + openClawCron, + schemaVersion: cacheHeartbeatSchemaVersion, + tasks: heartbeatTasks, + }); + }), getStatus: () => readEffect( () => readCacheStatus(), diff --git a/greenfield/src/server/domains/jobs/actionExecutors.test.ts b/greenfield/src/server/domains/jobs/actionExecutors.test.ts index fe7eec6e8..6dfe7f5fe 100644 --- a/greenfield/src/server/domains/jobs/actionExecutors.test.ts +++ b/greenfield/src/server/domains/jobs/actionExecutors.test.ts @@ -2,9 +2,14 @@ import { describe, expect, test } from "bun:test"; import { Effect } from "effect"; +import { + testMoltbookCollector, + testMoltbookDashboardSnapshot, +} from "../../test/support/moltbook.ts"; import { createJobWorkerActionResolver, createLogMaintenanceJobExecutor, + createMoltbookDashboardExecutor, createSystemHostExecutor, createWorkspaceFileWriteJobExecutor, createJobWorkerActionRegistry, @@ -36,7 +41,8 @@ const successfulExecutor = () => Effect.succeed({}); describe("worker-only job executor registry", () => { test("matches every pure definition with one exact executor", () => { const findAction = createJobWorkerActionResolver({ - run: () => Promise.resolve(undefined), + logMaintenance: { run: () => Promise.resolve(undefined) }, + moltbook: testMoltbookCollector, }); expect(findAction("system.worker-smoke")).toBeDefined(); expect(findAction("cache.refresh.system-host")).toBeDefined(); @@ -223,10 +229,11 @@ describe("worker-only job executor registry", () => { }, ]); - const findAction = createJobWorkerActionResolver( - { run: () => Promise.resolve(undefined) }, - writer - ); + const findAction = createJobWorkerActionResolver({ + logMaintenance: { run: () => Promise.resolve(undefined) }, + moltbook: testMoltbookCollector, + workspaceFiles: writer, + }); expect(findAction("workspace-files.apply-write")).toBeDefined(); expect(findAction("workspace-files.apply-write")).not.toHaveProperty( "scheduleId" @@ -327,4 +334,61 @@ describe("worker-only job executor registry", () => { expect(Effect.runPromise(invalidPayloadExecution)).rejects.toBeInstanceOf(Error); expect(invalidPayloadCollections).toBe(0); }); + + test("commits one aggregate Moltbook attempt and redacts collector failures", async () => { + const attempts: JobCacheAttemptCommit[] = []; + const times = [10, 19]; + const executor = createMoltbookDashboardExecutor({ + collector: testMoltbookCollector, + monotonicNowMs: () => times.shift() ?? 19, + }); + expect( + await Effect.runPromise( + executor(executionContext(attempts), { + key: "moltbook.dashboard", + }) + ) + ).toEqual({ cacheKeys: ["moltbook.dashboard"], completedAtMs: 5000 }); + expect(attempts).toEqual([ + { + durationMs: 9, + entries: [ + { + key: "moltbook.dashboard", + metadata: { kind: "dashboard" }, + payload: testMoltbookDashboardSnapshot, + schemaId: "moltbook.dashboard.v1", + source: "moltbook.api", + ttlMs: 1_800_000, + }, + ], + kind: "succeeded", + }, + ]); + + const failedAttempts: JobCacheAttemptCommit[] = []; + const secret = "private-provider-detail"; + const failure = await Effect.runPromise( + createMoltbookDashboardExecutor({ + collector: { + collect: () => Promise.reject(new Error(secret)), + }, + monotonicNowMs: (() => { + const values = [20, 24]; + return () => values.shift() ?? 24; + })(), + })(executionContext(failedAttempts), { key: "moltbook.dashboard" }) + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(JobActionRetryableError); + expect(failedAttempts).toEqual([ + { + durationMs: 4, + failureCode: "provider/moltbook-unavailable", + failureMessage: "Moltbook dashboard projection could not be collected.", + key: "moltbook.dashboard", + kind: "failed", + }, + ]); + expect(JSON.stringify(failedAttempts)).not.toContain(secret); + }); }); diff --git a/greenfield/src/server/domains/jobs/actionExecutors.ts b/greenfield/src/server/domains/jobs/actionExecutors.ts index 9ba696f84..b2e245327 100644 --- a/greenfield/src/server/domains/jobs/actionExecutors.ts +++ b/greenfield/src/server/domains/jobs/actionExecutors.ts @@ -10,6 +10,7 @@ import { import type { JsonObject } from "../../../shared/json.ts"; import { collectSystemHostPayload } from "../cache/systemHostProvider.ts"; import { parseWorkspaceFileJobPayload } from "../files/jobPayload.ts"; +import type { MoltbookDashboardCollector } from "../moltbook/provider.ts"; import { type JobActionExecutor, type JobExecutableActionDefinition, @@ -27,6 +28,9 @@ import { const emptyPayloadSchema = v.strictObject({}); const systemHostActionPayloadSchema = v.strictObject({ key: v.literal("system.host") }); +const moltbookDashboardActionPayloadSchema = v.strictObject({ + key: v.literal("moltbook.dashboard"), +}); const logMaintenanceActionPayloadSchema = v.pipe( v.strictObject({ dryRun: v.optional(v.boolean("Log maintenance mode is invalid"), false), @@ -112,73 +116,128 @@ export interface SystemHostExecutorDependencies { readonly monotonicNowMs?: () => number; } -/** - * Creates the worker-only system.host executor with injectable host boundaries. - * @param dependencies Optional host collector and monotonic clock overrides. - * @returns A worker action executor for the system.host cache provider. - */ -export function createSystemHostExecutor( - dependencies: SystemHostExecutorDependencies = {} +interface CacheRefreshExecutorSpec { + readonly collect: (signal: AbortSignal) => Promise; + readonly failureCode: string; + readonly failureMessage: string; + readonly key: string; + readonly metadata: JsonObject; + readonly monotonicNowMs: () => number; + readonly schemaId: string; + readonly source: string; + readonly ttlMs: number; + readonly validatePayload: (payload: JsonObject) => void; +} + +function createCacheRefreshExecutor( + spec: CacheRefreshExecutorSpec ): JobActionExecutor { - const collect = dependencies.collect ?? collectSystemHostPayload; - const monotonicNowMs = dependencies.monotonicNowMs ?? (() => performance.now()); return (context, payload) => Effect.suspend(() => { - v.parse(systemHostActionPayloadSchema, payload); - const startedAt = monotonicNowMs(); + spec.validatePayload(payload); + const startedAt = spec.monotonicNowMs(); + const durationMs = (): number => + Math.max(0, Math.floor(spec.monotonicNowMs() - startedAt)); const collected = Effect.tryPromise({ catch: (error) => new JobActionRetryableError(error), - try: () => collect(), + try: (signal) => spec.collect(signal), }).pipe( - Effect.catch((error) => { - const durationMs = Math.max( - 0, - Math.floor(monotonicNowMs() - startedAt) - ); - return Effect.tryPromise(() => + Effect.catch((error) => + Effect.tryPromise(() => context.commitCacheAttempt({ - durationMs, - failureCode: "provider/system-host-unavailable", - failureMessage: - "System host projection could not be collected.", - key: "system.host", + durationMs: durationMs(), + failureCode: spec.failureCode, + failureMessage: spec.failureMessage, + key: spec.key, kind: "failed", }) - ).pipe(Effect.andThen(Effect.fail(error))); - }) + ).pipe(Effect.andThen(Effect.fail(error))) + ) ); return collected.pipe( - Effect.flatMap((hostPayload) => { - const durationMs = Math.max( - 0, - Math.floor(monotonicNowMs() - startedAt) - ); - return Effect.tryPromise(() => + Effect.flatMap((cachePayload) => + Effect.tryPromise(() => context.commitCacheAttempt({ - durationMs, + durationMs: durationMs(), entries: [ { - key: "system.host", - metadata: { kind: "host" }, - payload: hostPayload, - schemaId: "system.host.v1", - source: "system.host", - ttlMs: 86_400_000, + key: spec.key, + metadata: spec.metadata, + payload: cachePayload, + schemaId: spec.schemaId, + source: spec.source, + ttlMs: spec.ttlMs, }, ], kind: "succeeded", }) ).pipe( Effect.as({ - cacheKeys: ["system.host"], + cacheKeys: [spec.key], completedAtMs: context.nowMs(), }) - ); - }) + ) + ) ); }); } +/** + * Creates the worker-only system.host executor with injectable host boundaries. + * @param dependencies Optional host collector and monotonic clock overrides. + * @returns A worker action executor for the system.host cache provider. + */ +export function createSystemHostExecutor( + dependencies: SystemHostExecutorDependencies = {} +): JobActionExecutor { + const collect = dependencies.collect ?? collectSystemHostPayload; + const monotonicNowMs = dependencies.monotonicNowMs ?? (() => performance.now()); + return createCacheRefreshExecutor({ + collect: () => collect(), + failureCode: "provider/system-host-unavailable", + failureMessage: "System host projection could not be collected.", + key: "system.host", + metadata: { kind: "host" }, + monotonicNowMs, + schemaId: "system.host.v1", + source: "system.host", + ttlMs: 86_400_000, + validatePayload: (payload) => { + v.parse(systemHostActionPayloadSchema, payload); + }, + }); +} + +export interface MoltbookDashboardExecutorDependencies { + readonly collector: MoltbookDashboardCollector; + readonly monotonicNowMs?: () => number; +} + +/** + * Creates the worker-only all-or-nothing Moltbook cache refresh executor. + * @param dependencies Fixed collector and optional monotonic test clock. + * @returns Claim-fenced cache job executor. + */ +export function createMoltbookDashboardExecutor( + dependencies: MoltbookDashboardExecutorDependencies +): JobActionExecutor { + const monotonicNowMs = dependencies.monotonicNowMs ?? (() => performance.now()); + return createCacheRefreshExecutor({ + collect: (signal) => dependencies.collector.collect(signal), + failureCode: "provider/moltbook-unavailable", + failureMessage: "Moltbook dashboard projection could not be collected.", + key: "moltbook.dashboard", + metadata: { kind: "dashboard" }, + monotonicNowMs, + schemaId: "moltbook.dashboard.v1", + source: "moltbook.api", + ttlMs: 30 * 60_000, + validatePayload: (payload) => { + v.parse(moltbookDashboardActionPayloadSchema, payload); + }, + }); +} + /** * Adapts the fixed worker log-maintenance port to one schema-validated durable action. * The payload can select only a reviewed policy identity and never carries host paths. @@ -303,10 +362,16 @@ export function createJobWorkerActionRegistry( * Web code can import pure definitions without gaining log-maintenance authority. * @returns A fail-closed resolver containing every reviewed worker action. */ +export interface JobWorkerActionResolverDependencies { + readonly logMaintenance: LogMaintenanceExecutionPort; + readonly moltbook: MoltbookDashboardCollector; + readonly workspaceFiles?: WorkspaceFileWriteExecutionPort; +} + export function createJobWorkerActionResolver( - logMaintenance: LogMaintenanceExecutionPort, - workspaceFiles?: WorkspaceFileWriteExecutionPort + dependencies: JobWorkerActionResolverDependencies ): JobWorkerActionResolver { + const workspaceFiles = dependencies.workspaceFiles; const definitions = workspaceFiles === undefined ? jobActionDefinitions @@ -320,9 +385,15 @@ export function createJobWorkerActionResolver( actionKey: "cache.refresh.system-host", execute: systemHostExecutor, }), + Object.freeze({ + actionKey: "cache.refresh.moltbook-dashboard", + execute: createMoltbookDashboardExecutor({ + collector: dependencies.moltbook, + }), + }), Object.freeze({ actionKey: logMaintenanceJobActionKey, - execute: createLogMaintenanceJobExecutor(logMaintenance), + execute: createLogMaintenanceJobExecutor(dependencies.logMaintenance), }), Object.freeze({ actionKey: "system.worker-smoke", diff --git a/greenfield/src/server/domains/jobs/actionRegistry.ts b/greenfield/src/server/domains/jobs/actionRegistry.ts index 46f84f51f..df5ec4065 100644 --- a/greenfield/src/server/domains/jobs/actionRegistry.ts +++ b/greenfield/src/server/domains/jobs/actionRegistry.ts @@ -33,6 +33,10 @@ export type JobCacheAttemptWriteResult = "committed" | "lost-claim"; export { logMaintenanceJobActionKey } from "../../../shared/logMaintenanceUnits.ts"; /** Automatic schedule runs only the custom managed application/container policy. */ export const logMaintenanceJobScheduleId = "maintenance.rotate-managed-logs"; +/** Worker-only fixed-host Moltbook snapshot refresh identity. */ +export const moltbookDashboardCacheJobActionKey = "cache.refresh.moltbook-dashboard"; +/** Durable schedule identity for the all-or-nothing Moltbook projection. */ +export const moltbookDashboardCacheJobScheduleId = "cache.moltbook-dashboard"; /** Worker-only dynamic action used for one already-spooled structural file write. */ export const workspaceFileWriteJobActionKey = "workspace-files.apply-write"; /** Retry-safe worker action backed by a durable replace intent and atomic exchange. */ @@ -290,6 +294,29 @@ const systemHostCacheDefinition = validateJobActionDefinition({ timeoutMs: 30_000, }); +const moltbookDashboardCacheDefinition = validateJobActionDefinition({ + actionKey: moltbookDashboardCacheJobActionKey, + actionPayload: Object.freeze({ key: "moltbook.dashboard" }), + attemptLimit: 3, + cancellationPolicy: "cooperative", + defaultEnabled: true, + defaultSchedule: Object.freeze({ + intervalMs: 30 * 60_000, + kind: "interval", + }), + description: + "Projects a bounded all-or-nothing Moltbook home, feed, profile, and authored-content snapshot.", + displayName: "Moltbook dashboard cache", + initialDue: "immediate", + manualExposure: "cache-write", + priority: 0, + resourceClass: "light", + resourceKeys: Object.freeze(["network.moltbook"]), + retrySafe: true, + scheduleId: moltbookDashboardCacheJobScheduleId, + timeoutMs: 30_000, +}); + const logMaintenanceDefinition = validateJobActionDefinition({ actionKey: logMaintenanceJobActionKey, actionPayload: Object.freeze({ policyId: "docker-managed" }), @@ -349,6 +376,7 @@ export const workspaceFileReplaceJobActionDefinition = /** Complete reviewed pure-definition registry for this slice. */ export const jobActionDefinitions = Object.freeze([ systemHostCacheDefinition, + moltbookDashboardCacheDefinition, logMaintenanceDefinition, workerSmokeDefinition, ]); diff --git a/greenfield/src/server/domains/jobs/coordinator.test.ts b/greenfield/src/server/domains/jobs/coordinator.test.ts index 3f59d004b..9f8b77679 100644 --- a/greenfield/src/server/domains/jobs/coordinator.test.ts +++ b/greenfield/src/server/domains/jobs/coordinator.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect"; import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; +import { testMoltbookCollector } from "../../test/support/moltbook.ts"; import { createJobWorkerActionResolver } from "./actionExecutors.ts"; import { type JobActionRegistration, @@ -40,7 +41,8 @@ import { import { createJobRealtimeSideEffects } from "./sideEffects.ts"; const findJobWorkerAction = createJobWorkerActionResolver({ - run: () => Promise.resolve(undefined), + logMaintenance: { run: () => Promise.resolve(undefined) }, + moltbook: testMoltbookCollector, }); const releaseId = "a".repeat(40); diff --git a/greenfield/src/server/domains/jobs/repository.test.ts b/greenfield/src/server/domains/jobs/repository.test.ts index 3ca0e8955..c84af9c63 100644 --- a/greenfield/src/server/domains/jobs/repository.test.ts +++ b/greenfield/src/server/domains/jobs/repository.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { asc, count, eq } from "drizzle-orm"; -import { jobWorkerSummaryMaximum } from "../../../contracts/jobModel.ts"; +import { jobWorkerSummaryMaximum } from "../../../contracts/jobLimits.ts"; import { jobRunEvents } from "../../database/schema/jobRunEvents.ts"; import { jobRuns } from "../../database/schema/jobRuns.ts"; import { realtimeEvents } from "../../database/schema/realtime.ts"; @@ -2212,6 +2212,160 @@ describe("durable jobs repository", () => { } }); + test("aggregates health state beyond the bounded worker inventory on indexed reads", async () => { + const database = await openFreshMigratedDatabase(); + const repository = createJobRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); + const matchingReleaseId = "b".repeat(40); + const minimumHeartbeatAt = new Date(1000); + try { + await repository.reconcileSchedules({ + at: new Date(1000), + schedules: [schedule()], + sideEffectsForSchedule: () => noSideEffects, + }); + const run = queuedRun(20); + await repository.enqueueManualRun({ + ...noSideEffects, + queuedEvent: queuedEvent(run), + run, + }); + for (let index = 0; index <= jobWorkerSummaryMaximum; index += 1) { + await repository.registerWorker({ + ...noSideEffects, + worker: { + ...worker(uuid(500 + index), 2), + ...(index === jobWorkerSummaryMaximum + ? { releaseId: matchingReleaseId } + : {}), + }, + }); + } + + expect( + repository + .readQueueState({ minimumHeartbeatAt }) + .workers.some( + ({ worker: observedWorker }) => + observedWorker.releaseId === matchingReleaseId + ) + ).toBe(false); + expect( + repository.readHealthState({ + expectedReleaseId: matchingReleaseId, + minimumHeartbeatAt, + }) + ).toMatchObject({ + oldestQueuedAt: new Date(1020), + queuedRunCount: 1, + runningRunCount: 0, + workers: { + capacity: (jobWorkerSummaryMaximum + 1) * 2, + drainingCount: 0, + exactReleaseOnline: true, + freshCount: jobWorkerSummaryMaximum + 1, + onlineCount: jobWorkerSummaryMaximum + 1, + }, + }); + + const queuedPlan = database.sqlite + .query( + "EXPLAIN QUERY PLAN SELECT count(*), min(queued_at) FROM job_runs WHERE state = 'queued'" + ) + .all() + .map((row) => JSON.stringify(row)) + .join(" "); + const workerPlan = database.sqlite + .query( + "EXPLAIN QUERY PLAN SELECT count(*), coalesce(sum(capacity), 0) FROM worker_instances WHERE state IN ('draining', 'online') AND heartbeat_at >= ?" + ) + .all(minimumHeartbeatAt.getTime()) + .map((row) => JSON.stringify(row)) + .join(" "); + expect(queuedPlan).toContain("job_runs_claim_idx"); + expect(queuedPlan).not.toContain("USE TEMP B-TREE"); + expect(workerPlan).toContain("worker_instances_heartbeat_id_idx"); + } finally { + database.sqlite.close(true); + } + }); + + test("gates exact-release worker health at the durable heartbeat boundary", async () => { + const database = await openFreshMigratedDatabase(); + const repository = createJobRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); + const minimumHeartbeatAt = new Date(10_000); + const boundaryReleaseId = "b".repeat(40); + const excludedReleaseId = "c".repeat(40); + try { + database.orm + .insert(workerInstances) + .values([ + { + ...worker(uuid(700)), + heartbeatAt: minimumHeartbeatAt, + releaseId: boundaryReleaseId, + startedAt: new Date(9000), + }, + { + ...worker(uuid(701)), + heartbeatAt: new Date(minimumHeartbeatAt.getTime() - 1), + releaseId: excludedReleaseId, + startedAt: new Date(9000), + }, + { + ...worker(uuid(702)), + heartbeatAt: minimumHeartbeatAt, + releaseId: "a".repeat(40), + startedAt: new Date(9000), + }, + { + ...worker(uuid(703)), + drainingAt: minimumHeartbeatAt, + heartbeatAt: minimumHeartbeatAt, + releaseId: excludedReleaseId, + startedAt: new Date(9000), + state: "draining" as const, + }, + { + ...worker(uuid(704)), + drainingAt: new Date(9500), + heartbeatAt: new Date(9500), + releaseId: excludedReleaseId, + startedAt: new Date(9000), + state: "stopped" as const, + stoppedAt: minimumHeartbeatAt, + }, + ]) + .run(); + + expect( + repository.readHealthState({ + expectedReleaseId: boundaryReleaseId, + minimumHeartbeatAt, + }).workers + ).toEqual({ + capacity: 3, + drainingCount: 1, + exactReleaseOnline: true, + freshCount: 3, + onlineCount: 2, + }); + expect( + repository.readHealthState({ + expectedReleaseId: excludedReleaseId, + minimumHeartbeatAt, + }).workers.exactReleaseOnline + ).toBe(false); + } finally { + database.sqlite.close(true); + } + }); + test("reserves the terminal event when payload consumes the byte budget", async () => { const database = await openFreshMigratedDatabase(); const repository = createJobRepository( diff --git a/greenfield/src/server/domains/jobs/repository.ts b/greenfield/src/server/domains/jobs/repository.ts index 1426a9fa6..bfa476200 100644 --- a/greenfield/src/server/domains/jobs/repository.ts +++ b/greenfield/src/server/domains/jobs/repository.ts @@ -12,6 +12,7 @@ import { isNull, lt, lte, + min, or, sql, type SQL, @@ -139,6 +140,31 @@ export interface JobQueueState { readonly workers: readonly JobQueueWorkerRecord[]; } +/** Constant-size queue and worker aggregates consumed only by health diagnostics. */ +export interface JobHealthState { + readonly control: JobWorkerControlRecord; + readonly oldestQueuedAt?: Date; + readonly queuedRunCount: number; + readonly runningRunCount: number; + readonly workers: { + readonly capacity: number; + readonly drainingCount: number; + readonly exactReleaseOnline: boolean; + readonly freshCount: number; + readonly onlineCount: number; + }; +} + +export interface ReadJobHealthStateInput { + readonly expectedReleaseId?: string; + readonly minimumHeartbeatAt: Date; +} + +/** Narrow aggregate reader kept separate from the ordinary job-service repository port. */ +export interface JobHealthStateReader { + readHealthState(input: ReadJobHealthStateInput): JobHealthState; +} + export interface ListJobRunEventsInput { readonly beforeSequence?: number; readonly limit: number; @@ -1084,6 +1110,55 @@ class DrizzleJobReader implements JobRepositoryReader { }; } + public readHealthState(input: ReadJobHealthStateInput): JobHealthState { + const queued = this.database + .select({ oldestQueuedAt: min(jobRuns.queuedAt), value: count() }) + .from(jobRuns) + .where(eq(jobRuns.state, "queued")) + .get(); + const runningRunCount = + this.database + .select({ value: count() }) + .from(jobRuns) + .where(eq(jobRuns.state, "running")) + .get()?.value ?? 0; + const exactReleaseOnlineCount = + input.expectedReleaseId === undefined + ? sql`0` + : sql`coalesce(sum(case when ${workerInstances.state} = 'online' and ${workerInstances.releaseId} = ${input.expectedReleaseId} then 1 else 0 end), 0)`; + const workers = this.database + .select({ + capacity: sql`coalesce(sum(${workerInstances.capacity}), 0)`, + drainingCount: sql`coalesce(sum(case when ${workerInstances.state} = 'draining' then 1 else 0 end), 0)`, + exactReleaseOnlineCount, + freshCount: count(), + onlineCount: sql`coalesce(sum(case when ${workerInstances.state} = 'online' then 1 else 0 end), 0)`, + }) + .from(workerInstances) + .where( + and( + inArray(workerInstances.state, ["draining", "online"]), + gte(workerInstances.heartbeatAt, input.minimumHeartbeatAt) + ) + ) + .get(); + return { + control: this.readWorkerControl(), + ...(queued?.oldestQueuedAt === null || queued?.oldestQueuedAt === undefined + ? {} + : { oldestQueuedAt: queued.oldestQueuedAt }), + queuedRunCount: queued?.value ?? 0, + runningRunCount, + workers: { + capacity: workers?.capacity ?? 0, + drainingCount: workers?.drainingCount ?? 0, + exactReleaseOnline: (workers?.exactReleaseOnlineCount ?? 0) > 0, + freshCount: workers?.freshCount ?? 0, + onlineCount: workers?.onlineCount ?? 0, + }, + }; + } + public readWorkerControl(): JobWorkerControlRecord { const row = this.database .select() @@ -2625,7 +2700,7 @@ class DrizzleJobWriter extends DrizzleJobReader { export function createJobRepository( database: SQLiteBunDatabase, writeAdmission: ImmediateDatabaseWriteAdmission -): JobRepository { +): JobRepository & JobHealthStateReader { // Drizzle exposes the synchronous transaction overload through a conditional // return type that cannot preserve our generic callback without this narrowing. const runTransaction = database.transaction.bind(database) as unknown as ( @@ -2703,6 +2778,8 @@ export function createJobRepository( read((reader) => reader.readClaimCancellation(input)), readActionPayloadRunSnapshots: (input: ReadActionPayloadRunSnapshotsInput) => read((reader) => reader.readActionPayloadRunSnapshots(input)), + readHealthState: (input: ReadJobHealthStateInput) => + read((reader) => reader.readHealthState(input)), readQueueState: (input: ReadQueueStateInput) => read((reader) => reader.readQueueState(input)), readWorkerControl: () => read((reader) => reader.readWorkerControl()), diff --git a/greenfield/src/server/domains/jobs/workerRuntime.test.ts b/greenfield/src/server/domains/jobs/workerRuntime.test.ts index 4eb60b88f..c63bc6ac8 100644 --- a/greenfield/src/server/domains/jobs/workerRuntime.test.ts +++ b/greenfield/src/server/domains/jobs/workerRuntime.test.ts @@ -6,6 +6,7 @@ import type { TaskNotificationQueue } from "../../../shared/taskNotifications.ts import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWriteAdmission.ts"; import type { RuntimeOwnedDatabase } from "../../database/runtime/databaseService.ts"; import type { PersistentGatewayTaskNotificationTransport } from "../../platform/gateway/persistentGatewayTransport.ts"; +import { testMoltbookCollector } from "../../test/support/moltbook.ts"; import type { CacheRepository } from "../cache/repository.ts"; import type { JobWorkerCoordinator } from "./coordinator.ts"; import type { JobRepository } from "./repository.ts"; @@ -31,6 +32,7 @@ const baseRuntimeOptions = { logMaintenance: Object.freeze({ run: () => Promise.resolve(undefined), }), + moltbook: testMoltbookCollector, pid: 123, releaseId: "a".repeat(40), sideEffects: { diff --git a/greenfield/src/server/domains/jobs/workerRuntime.ts b/greenfield/src/server/domains/jobs/workerRuntime.ts index 8681f235b..418752ac5 100644 --- a/greenfield/src/server/domains/jobs/workerRuntime.ts +++ b/greenfield/src/server/domains/jobs/workerRuntime.ts @@ -14,6 +14,7 @@ import { } from "../../database/runtime/databaseService.ts"; import type { PersistentGatewayTaskNotificationTransport } from "../../platform/gateway/persistentGatewayTransport.ts"; import { createCacheRepository, type CacheRepository } from "../cache/repository.ts"; +import type { MoltbookDashboardCollector } from "../moltbook/provider.ts"; import { createTaskNotificationQueue } from "../tasks/taskNotificationQueue.ts"; import { createJobWorkerActionResolver, @@ -36,6 +37,7 @@ import { export interface DashboardWorkerRuntimeOptions { readonly database: DatabaseRuntimeLayerOptions; readonly logMaintenance: LogMaintenanceExecutionPort; + readonly moltbook: MoltbookDashboardCollector; readonly workspaceFiles?: WorkspaceFileWriteExecutionPort & { readonly dispose: () => Promise | void; }; @@ -395,10 +397,13 @@ export function createDashboardWorkerRuntime( database.database, database.writeAdmission ); - const findAction = createJobWorkerActionResolver( - options.logMaintenance, - options.workspaceFiles - ); + const findAction = createJobWorkerActionResolver({ + logMaintenance: options.logMaintenance, + moltbook: options.moltbook, + ...(options.workspaceFiles === undefined + ? {} + : { workspaceFiles: options.workspaceFiles }), + }); coordinator = dependencies.createCoordinator({ actionDefinitions: jobActionDefinitions, commitCacheAttempt: (input) => cacheRepository.commitAttempt(input), diff --git a/greenfield/src/server/domains/jobs/workerSystem.test.ts b/greenfield/src/server/domains/jobs/workerSystem.test.ts index be1c03c59..b9b5af0ab 100644 --- a/greenfield/src/server/domains/jobs/workerSystem.test.ts +++ b/greenfield/src/server/domains/jobs/workerSystem.test.ts @@ -5,6 +5,7 @@ import { eq } from "drizzle-orm"; import { realtimeEvents } from "../../database/schema/realtime.ts"; import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; +import { testMoltbookCollector } from "../../test/support/moltbook.ts"; import { createCacheRepository } from "../cache/repository.ts"; import { createJobWorkerActionResolver, @@ -30,7 +31,8 @@ const noSideEffects: JobMutationSideEffects = Object.freeze({ }); const terminalRunStates = new Set(["cancelled", "failed", "succeeded", "timed-out"]); const findJobWorkerAction = createJobWorkerActionResolver({ - run: () => Promise.resolve(undefined), + logMaintenance: { run: () => Promise.resolve(undefined) }, + moltbook: testMoltbookCollector, }); async function waitForTerminal( diff --git a/greenfield/src/server/domains/moltbook/procedures.test.ts b/greenfield/src/server/domains/moltbook/procedures.test.ts new file mode 100644 index 000000000..b729a97de --- /dev/null +++ b/greenfield/src/server/domains/moltbook/procedures.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; +import { Effect } from "effect"; + +import { testMoltbookDashboardSnapshot } from "../../test/support/moltbook.ts"; +import { captureFailure } from "../../test/support/promise.ts"; +import { + createTestApplicationRuntime, + createTestAutomationAuthentication, + createTestRequestContext, + createTestSessionAuthentication, +} from "../../test/support/requestContext.ts"; +import { appRouter } from "../../trpc/appRouter.ts"; +import { CacheNotFoundError } from "../cache/errors.ts"; +import { createTestCacheService } from "../cache/testSupport/service.ts"; + +const runId = "018f6f50-6a9e-7b88-8000-000000000001"; + +function staleEntry() { + return { + consecutiveFailures: 1, + expiresAtMs: 1_800_000, + failureCode: "provider/moltbook-unavailable", + failureMessage: "Moltbook dashboard projection could not be collected.", + freshness: "stale" as const, + key: "moltbook.dashboard", + lastAttemptAtMs: 2_000_000, + lastAttemptDurationMs: 20, + lastAttemptNumber: 2, + lastAttemptRunId: runId, + lastAttemptStatus: "failed" as const, + lastSuccessAtMs: 0, + manualRunAvailable: true, + metadata: { kind: "dashboard" }, + payload: testMoltbookDashboardSnapshot, + schemaId: "moltbook.dashboard.v1", + source: "moltbook.api", + updatedAtMs: 2_000_000, + }; +} + +async function expectCode( + operation: () => Promise, + code: TRPCError["code"] +): Promise { + const failure = await captureFailure(operation); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe(code); +} + +describe("Moltbook procedures", () => { + test("serves every projection with explicit stale last-known-good status", async () => { + let cacheReads = 0; + const cacheService = createTestCacheService({ + getEntry: () => { + cacheReads += 1; + return Effect.succeed(staleEntry()); + }, + }); + const caller = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["cache:read"]), + createTestApplicationRuntime(), + { cacheService } + ) + ).moltbook; + + expect(await caller.home({})).toMatchObject({ + home: testMoltbookDashboardSnapshot.home, + status: { + freshness: "stale", + lastAttemptStatus: "failed", + refreshFailureMessage: + "Moltbook dashboard projection could not be collected.", + }, + }); + expect(await caller.feed({ sort: "new" })).toMatchObject({ + feed: { sort: "new" }, + status: { freshness: "stale" }, + }); + expect(await caller.profile({})).toMatchObject({ + profile: { name: "mira_2026" }, + }); + expect(await caller.listMyPosts({})).toMatchObject({ + content: { comments: [], posts: [] }, + }); + expect(await caller.snapshot({ sort: "hot" })).toMatchObject({ + content: { comments: [], posts: [] }, + feed: { sort: "hot" }, + home: testMoltbookDashboardSnapshot.home, + profile: { name: "mira_2026" }, + status: { freshness: "stale" }, + }); + expect(cacheReads).toBe(5); + }); + + test("requires a cache-capable browser session and sanitizes missing state", async () => { + const automation = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication(["cache:read"]) + ) + ).moltbook; + await expectCode(() => automation.home({}), "FORBIDDEN"); + + const missing = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["cache:read"]), + createTestApplicationRuntime(), + { + cacheService: createTestCacheService({ + getEntry: () => + Effect.fail( + new CacheNotFoundError({ + key: "moltbook.dashboard", + }) + ), + }), + } + ) + ).moltbook; + await expectCode(() => missing.profile({}), "SERVICE_UNAVAILABLE"); + + const outdated = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["cache:read"]), + createTestApplicationRuntime(), + { + cacheService: createTestCacheService({ + getEntry: () => + Effect.succeed({ + ...staleEntry(), + schemaId: "moltbook.dashboard.v0", + }), + }), + } + ) + ).moltbook; + await expectCode(() => outdated.home({}), "SERVICE_UNAVAILABLE"); + }); +}); diff --git a/greenfield/src/server/domains/moltbook/procedures.ts b/greenfield/src/server/domains/moltbook/procedures.ts new file mode 100644 index 000000000..09c1a13f9 --- /dev/null +++ b/greenfield/src/server/domains/moltbook/procedures.ts @@ -0,0 +1,8 @@ +import { router } from "../../trpc/trpc.ts"; +import { moltbookRoutes } from "./routes.ts"; + +/** Leaf procedure names owned by the Moltbook router. */ +export const moltbookProcedureNames = Object.freeze(Object.keys(moltbookRoutes)); + +/** Read-only Moltbook snapshot router. */ +export const moltbookRouter = router(moltbookRoutes); diff --git a/greenfield/src/server/domains/moltbook/provider.test.ts b/greenfield/src/server/domains/moltbook/provider.test.ts new file mode 100644 index 000000000..b1a9c0b06 --- /dev/null +++ b/greenfield/src/server/domains/moltbook/provider.test.ts @@ -0,0 +1,554 @@ +import { describe, expect, test } from "bun:test"; +import { inspect } from "node:util"; + +import { Redacted } from "effect"; + +import { createMoltbookDashboardCollector, MoltbookProviderFailure } from "./provider.ts"; + +function jsonResponse(value: unknown): Response { + return Response.json(value, { + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +const providerPayloads = Object.freeze({ + home: { + activity_on_your_posts: [{ id: "activity-1" }], + explore: [{ id: "explore-1" }, { id: "explore-2" }], + latest_moltbook_announcement: { + author_name: "moltbook", + created_at: "2026-08-11T08:00:00.000Z", + post_id: "announcement-1", + preview: "Platform news", + title: "News", + }, + posts_from_accounts_you_follow: [{ id: "followed-1" }], + what_to_do_next: ["Read the feed"], + your_account: { unread_notification_count: 3 }, + your_direct_messages: { + pending_request_count: 1, + unread_message_count: 2, + }, + }, + hot: { + feed_filter: "all", + has_more: false, + posts: [ + { + author: { display_name: "Ada", name: "ada" }, + comment_count: 4, + content_preview: "A bounded preview", + created_at: "2026-08-11T09:00:00.000Z", + downvotes: 1, + id: "post-hot", + submolt_name: "agents", + title: "Hot post", + upvotes: 8, + you_follow_author: true, + }, + ], + tip: "Be kind", + }, + new: { has_more: true, posts: [] }, + profile: { + agent: { + comments_count: 6, + description: "Dashboard agent", + display_name: "Mira", + follower_count: 10, + following_count: 2, + karma: "-42", + name: "mira/2026", + posts_count: 5, + }, + recentComments: [ + { + content: "A comment", + created_at: "2026-08-11T07:00:00.000Z", + downvotes: 0, + id: "comment-1", + post: { + id: "post-1", + submolt: { name: "agents" }, + title: "Post one", + }, + upvotes: 2, + }, + ], + recentPosts: [ + { + comment_count: 1, + content_preview: "My post", + created_at: "2026-08-11T06:00:00.000Z", + downvotes: 0, + id: "post-1", + submolt: { name: "agents" }, + title: "Post one", + upvotes: 3, + }, + ], + }, +}); + +describe("Moltbook dashboard provider", () => { + test("uses only four fixed www requests and returns one strict aggregate", async () => { + const requests: Array<{ init: RequestInit; url: string }> = []; + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira%2F2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira/2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input, init) => { + const url = new URL(input); + requests.push({ init, url: url.href }); + const payload = byPath.get(`${url.pathname}${url.search}`); + return Promise.resolve( + payload === undefined + ? new Response(null, { status: 404 }) + : jsonResponse(payload) + ); + }, + nowMs: () => 1_723_365_000_000, + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(requests.map(({ url }) => url).toSorted()).toEqual( + [ + "https://www.moltbook.com/api/v1/agents/profile?name=mira%2F2026", + "https://www.moltbook.com/api/v1/feed?sort=hot&limit=25", + "https://www.moltbook.com/api/v1/feed?sort=new&limit=25", + "https://www.moltbook.com/api/v1/home", + ].toSorted() + ); + for (const request of requests) { + expect(request.init).toMatchObject({ method: "GET", redirect: "error" }); + expect(new Headers(request.init?.headers).get("authorization")).toBe( + "Bearer moltbook-secret-sentinel" + ); + } + expect(snapshot).toMatchObject({ + feeds: { + hot: { + posts: [ + { + author: { displayName: "Ada", name: "ada" }, + id: "post-hot", + submoltName: "agents", + }, + ], + sort: "hot", + }, + new: { posts: [], sort: "new" }, + }, + fetchedAtMs: 1_723_365_000_000, + home: { + activityOnYourPostsCount: 1, + unreadMessageCount: 2, + unreadNotificationCount: 3, + }, + myContent: { + comments: [{ id: "comment-1" }], + posts: [{ id: "post-1" }], + }, + profile: { karma: -42, name: "mira/2026" }, + }); + expect(JSON.stringify(snapshot)).not.toContain("activity-1"); + }); + + test("keeps a missing provider profile optional without losing authored content", async () => { + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + [ + "/api/v1/agents/profile?name=mira_2026", + { recentComments: [], recentPosts: [] }, + ], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + nowMs: () => 1_723_365_000_000, + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(snapshot.profile).toBeUndefined(); + expect(snapshot.myContent).toEqual({ comments: [], posts: [] }); + }); + + test("defaults a missing optional account notification block without losing home data", async () => { + const byPath = new Map([ + ["/api/v1/home", { ...providerPayloads.home, your_account: undefined }], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + nowMs: () => 1_723_365_000_000, + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(snapshot.home).toMatchObject({ + exploreCount: 2, + unreadMessageCount: 2, + unreadNotificationCount: 0, + }); + }); + + test("defaults a present account block without its notification count", async () => { + const byPath = new Map([ + ["/api/v1/home", { ...providerPayloads.home, your_account: {} }], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(snapshot.home.unreadNotificationCount).toBe(0); + }); + + test("defaults omitted legacy-optional home, feed, and authored-content fields", async () => { + const byPath = new Map([ + ["/api/v1/home", { your_account: {} }], + ["/api/v1/feed?sort=hot&limit=25", { has_more: false }], + ["/api/v1/feed?sort=new&limit=25", { posts: [] }], + [ + "/api/v1/agents/profile?name=mira_2026", + { agent: providerPayloads.profile.agent }, + ], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + nowMs: () => 1_723_365_000_000, + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(snapshot.home).toEqual({ + activityOnYourPostsCount: 0, + exploreCount: 0, + nextActions: [], + pendingRequestCount: 0, + postsFromAccountsYouFollowCount: 0, + unreadMessageCount: 0, + unreadNotificationCount: 0, + }); + expect(snapshot.feeds.hot).toMatchObject({ + hasMore: false, + posts: [], + }); + expect(snapshot.feeds.new).toMatchObject({ + hasMore: false, + posts: [], + }); + expect(snapshot.myContent).toEqual({ comments: [], posts: [] }); + expect(snapshot.profile).toMatchObject({ name: "mira/2026" }); + }); + + test("accepts current home collection pointers alongside legacy arrays", async () => { + const byPath = new Map([ + [ + "/api/v1/home", + { + ...providerPayloads.home, + explore: { + description: "Discover current Moltbook posts", + endpoint: "/api/v1/posts", + }, + posts_from_accounts_you_follow: { + posts: [{ id: "followed-1" }, { id: "followed-2" }], + }, + your_direct_messages: undefined, + }, + ], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + nowMs: () => 1_723_365_000_000, + }); + + const snapshot = await collector.collect(new AbortController().signal); + + expect(snapshot.home).toMatchObject({ + exploreCount: 0, + pendingRequestCount: 0, + postsFromAccountsYouFollowCount: 2, + unreadMessageCount: 0, + }); + }); + + test("rejects malformed present optional fields instead of defaulting them", async () => { + const malformedCases = [ + { + path: "/api/v1/home", + value: { explore: {} }, + }, + { + path: "/api/v1/home", + value: { posts_from_accounts_you_follow: { posts: {} } }, + }, + { + path: "/api/v1/feed?sort=hot&limit=25", + value: { posts: {} }, + }, + { + path: "/api/v1/feed?sort=new&limit=25", + value: { has_more: "false" }, + }, + { + path: "/api/v1/agents/profile?name=mira_2026", + value: { agent: providerPayloads.profile.agent, recentComments: {} }, + }, + { + path: "/api/v1/agents/profile?name=mira_2026", + value: { agent: providerPayloads.profile.agent, recentPosts: {} }, + }, + ] as const; + + for (const malformed of malformedCases) { + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + [malformed.path, malformed.value], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve( + jsonResponse(byPath.get(input.pathname + input.search)) + ), + }); + + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect((failure as MoltbookProviderFailure).reason).toBe("invalid-response"); + } + }); + + test("rejects empty endpoint envelopes instead of replacing last-known-good data", async () => { + const paths = [ + "/api/v1/home", + "/api/v1/feed?sort=hot&limit=25", + "/api/v1/feed?sort=new&limit=25", + "/api/v1/agents/profile?name=mira_2026", + ] as const; + + for (const emptyPath of paths) { + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + ["/api/v1/feed?sort=hot&limit=25", providerPayloads.hot], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + [emptyPath, {}], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve( + jsonResponse(byPath.get(input.pathname + input.search)) + ), + }); + + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect((failure as MoltbookProviderFailure).reason).toBe("invalid-response"); + } + }); + + test("aborts outstanding sibling reads after an all-or-nothing failure", async () => { + let abortedSiblings = 0; + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (url, init) => { + if (url.pathname.endsWith("/home")) { + return Promise.resolve(new Response(null, { status: 503 })); + } + const signal = init.signal; + if (!(signal instanceof AbortSignal)) { + throw new Error("Expected a composed request signal"); + } + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + abortedSiblings += 1; + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true } + ); + }); + }, + }); + + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect(abortedSiblings).toBe(3); + }); + + test("cancels response readers before releasing their locks after caller abort", async () => { + const requestController = new AbortController(); + let cancelledBodies = 0; + let abortTriggered = false; + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: () => + Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + cancelledBodies += 1; + }, + pull(controller) { + if (!abortTriggered) { + abortTriggered = true; + requestController.abort( + new Error("Caller cancelled Moltbook collection") + ); + } + controller.enqueue(new TextEncoder().encode("{}")); + }, + }), + { headers: { "content-type": "application/json" } } + ) + ), + }); + + const failure = await collector + .collect(requestController.signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(Error); + expect(cancelledBodies).toBeGreaterThan(0); + }); + + test("classifies normalized payload schema violations as invalid responses", async () => { + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + [ + "/api/v1/feed?sort=hot&limit=25", + { + ...providerPayloads.hot, + posts: [ + { + ...providerPayloads.hot.posts[0], + title: "x".repeat(501), + }, + ], + }, + ], + ["/api/v1/feed?sort=new&limit=25", providerPayloads.new], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + }); + + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect((failure as MoltbookProviderFailure).reason).toBe("invalid-response"); + }); + + test("rejects a valid projection that exceeds the cache row budget", async () => { + const largePosts = Array.from({ length: 25 }, (_, index) => ({ + ...providerPayloads.hot.posts[0], + content_preview: "x".repeat(8000), + id: `large-${index}`, + })); + const byPath = new Map([ + ["/api/v1/home", providerPayloads.home], + ["/api/v1/feed?sort=hot&limit=25", { has_more: false, posts: largePosts }], + ["/api/v1/feed?sort=new&limit=25", { has_more: false, posts: largePosts }], + ["/api/v1/agents/profile?name=mira_2026", providerPayloads.profile], + ]); + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make("moltbook-secret-sentinel"), + fetch: (input) => + Promise.resolve(jsonResponse(byPath.get(input.pathname + input.search))), + }); + + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect((failure as MoltbookProviderFailure).reason).toBe("unavailable"); + }); + + test("rejects redirects, non-JSON, and over-budget bodies without secret leakage", async () => { + const secret = "provider-secret-sentinel"; + for (const response of [ + new Response(null, { + headers: { location: "https://moltbook.com/api/v1/home" }, + status: 302, + }), + new Response("not json", { + headers: { "content-type": "text/plain" }, + }), + Response.json({ value: "x".repeat(256 * 1024) }), + ]) { + const collector = createMoltbookDashboardCollector({ + agentName: "mira_2026", + apiKey: Redacted.make(secret), + fetch: (_url, _init) => Promise.resolve(response.clone()), + }); + const failure = await collector + .collect(new AbortController().signal) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(MoltbookProviderFailure); + expect(String(failure)).not.toContain(secret); + expect(inspect(failure)).not.toContain(secret); + expect(JSON.stringify(failure)).not.toContain(secret); + } + }); +}); diff --git a/greenfield/src/server/domains/moltbook/provider.ts b/greenfield/src/server/domains/moltbook/provider.ts new file mode 100644 index 000000000..c9ab88ae9 --- /dev/null +++ b/greenfield/src/server/domains/moltbook/provider.ts @@ -0,0 +1,543 @@ +import { Redacted } from "effect"; +import * as v from "valibot"; + +import { cacheEntryPayloadSchema } from "../../../contracts/cache.ts"; +import { + type MoltbookDashboardCachePayload, + moltbookDashboardCachePayloadSchema, + moltbookFeedMaximumPosts, + moltbookNextActionsMaximum, + moltbookOwnCommentsMaximum, + moltbookOwnPostsMaximum, +} from "../../../contracts/moltbook.ts"; + +const moltbookApiOrigin = "https://www.moltbook.com"; +const moltbookApiBasePath = "/api/v1"; +const moltbookResponseMaximumBytes = 256 * 1024; +export const moltbookRequestTimeoutMs = 20_000; + +const moltbookFeedEnvelopeFields = [ + "feed_filter", + "feed_type", + "has_more", + "posts", + "tip", +] as const; +const moltbookHomeEnvelopeFields = [ + "activity_on_your_posts", + "explore", + "latest_moltbook_announcement", + "posts_from_accounts_you_follow", + "what_to_do_next", + "your_account", + "your_direct_messages", +] as const; +const moltbookProfileEnvelopeFields = ["agent", "recentComments", "recentPosts"] as const; + +export class MoltbookProviderFailure extends Error { + readonly reason: "invalid-response" | "timeout" | "unavailable"; + + constructor(reason: MoltbookProviderFailure["reason"]) { + super("Moltbook provider failed"); + this.name = "MoltbookProviderFailure"; + this.reason = reason; + } +} + +export interface MoltbookDashboardCollector { + readonly collect: (signal: AbortSignal) => Promise; +} + +interface MoltbookFetchBodyReader { + readonly cancel: (reason?: unknown) => Promise; + readonly read: () => Promise<{ + readonly done: boolean; + readonly value?: Uint8Array; + }>; + readonly releaseLock: () => void; +} + +interface MoltbookFetchBody { + readonly cancel: (reason?: unknown) => Promise; + readonly getReader: () => MoltbookFetchBodyReader; +} + +export interface MoltbookFetchResponse { + readonly body: MoltbookFetchBody | null; + readonly headers: { readonly get: (name: string) => string | null }; + readonly ok: boolean; +} + +export type MoltbookFetch = ( + url: URL, + init: RequestInit +) => Promise; + +export interface MoltbookDashboardCollectorOptions { + readonly agentName: string; + readonly apiKey: Redacted.Redacted; + readonly fetch?: MoltbookFetch; + readonly nowMs?: () => number; + readonly timeoutMs?: number; +} + +function record(value: unknown): Readonly> { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new MoltbookProviderFailure("invalid-response"); + } + return value as Readonly>; +} + +function optionalRecord(value: unknown): Readonly> | undefined { + return value === undefined || value === null ? undefined : record(value); +} + +function optionalArray(value: unknown): readonly unknown[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) throw new MoltbookProviderFailure("invalid-response"); + return value; +} + +function requireRecognizedEnvelopeField( + value: Readonly>, + fields: readonly string[] +): void { + if (!fields.some((field) => Object.hasOwn(value, field))) { + throw new MoltbookProviderFailure("invalid-response"); + } +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new MoltbookProviderFailure("invalid-response"); + } + return value; +} + +function requiredString(value: unknown): string { + const output = optionalString(value); + if (output === undefined) throw new MoltbookProviderFailure("invalid-response"); + return output; +} + +function homeExploreCount(value: unknown): number { + if (value === undefined || value === null) return 0; + if (Array.isArray(value)) return value.length; + const pointer = record(value); + if (requiredString(pointer.endpoint).trim() === "") { + throw new MoltbookProviderFailure("invalid-response"); + } + optionalString(pointer.description); + return 0; +} + +function homeFollowingPostsCount(value: unknown): number { + if (value === undefined || value === null) return 0; + if (Array.isArray(value)) return value.length; + const collection = record(value); + if (!Array.isArray(collection.posts)) { + throw new MoltbookProviderFailure("invalid-response"); + } + return collection.posts.length; +} + +function nonnegativeCount(value: unknown): number { + if (value === undefined || value === null) return 0; + const numeric = + typeof value === "string" && /^(?:0|[1-9][0-9]*)$/u.test(value) + ? Number(value) + : value; + if (typeof numeric !== "number" || !Number.isSafeInteger(numeric) || numeric < 0) { + throw new MoltbookProviderFailure("invalid-response"); + } + return numeric; +} + +function signedInteger(value: unknown): number { + const numeric = + typeof value === "string" && /^-?(?:0|[1-9][0-9]*)$/u.test(value) + ? Number(value) + : value; + if (typeof numeric !== "number" || !Number.isSafeInteger(numeric)) { + throw new MoltbookProviderFailure("invalid-response"); + } + return numeric; +} + +function timestampMilliseconds(value: unknown): number { + const parsed = Date.parse(requiredString(value)); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new MoltbookProviderFailure("invalid-response"); + } + return parsed; +} + +function optionalTimestampMilliseconds(value: unknown): number | undefined { + return value === undefined || value === null + ? undefined + : timestampMilliseconds(value); +} + +function booleanValue(value: unknown, fallback = false): boolean { + if (value === undefined || value === null) return fallback; + if (typeof value !== "boolean") { + throw new MoltbookProviderFailure("invalid-response"); + } + return value; +} + +async function cancelResponse( + response: MoltbookFetchResponse, + reason: string +): Promise { + try { + await response.body?.cancel(reason); + } catch { + // Rejected provider bodies are discarded without exposing diagnostics. + } +} + +async function boundedJsonResponse( + response: MoltbookFetchResponse, + signal: AbortSignal +): Promise { + if (!response.ok) { + await cancelResponse(response, "Moltbook provider returned an error status"); + throw new MoltbookProviderFailure("unavailable"); + } + const contentType = + response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? + ""; + if (contentType !== "application/json") { + await cancelResponse(response, "Moltbook provider response was not JSON"); + throw new MoltbookProviderFailure("invalid-response"); + } + const declared = response.headers.get("content-length")?.trim(); + if ( + declared !== undefined && + (!/^(?:0|[1-9][0-9]*)$/u.test(declared) || + Number(declared) > moltbookResponseMaximumBytes) + ) { + await cancelResponse(response, "Moltbook provider response exceeded its budget"); + throw new MoltbookProviderFailure("invalid-response"); + } + if (response.body === null) throw new MoltbookProviderFailure("invalid-response"); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + signal.throwIfAborted(); + const result = await reader.read(); + if (result.done) break; + const chunk = result.value as Uint8Array; + totalBytes += chunk.byteLength; + if (totalBytes > moltbookResponseMaximumBytes) { + await reader + .cancel("Moltbook provider response exceeded its budget") + .catch(() => {}); + throw new MoltbookProviderFailure("invalid-response"); + } + chunks.push(chunk); + } + } catch (error) { + if (signal.aborted) { + await reader.cancel("Moltbook provider response was aborted").catch(() => {}); + } + throw error; + } finally { + reader.releaseLock(); + } + if (totalBytes < 2) { + throw new MoltbookProviderFailure("invalid-response"); + } + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new MoltbookProviderFailure("invalid-response"); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new MoltbookProviderFailure("invalid-response"); + } +} + +function normalizeFeedPost(value: unknown) { + const post = record(value); + const author = optionalRecord(post.author); + const authorName = optionalString(author?.name) ?? optionalString(post.author_name); + if (authorName === undefined) throw new MoltbookProviderFailure("invalid-response"); + const id = optionalString(post.post_id) ?? optionalString(post.id); + if (id === undefined) throw new MoltbookProviderFailure("invalid-response"); + const submolt = optionalRecord(post.submolt); + return { + author: { + ...(optionalString(author?.display_name) === undefined + ? {} + : { displayName: optionalString(author?.display_name) }), + name: authorName, + }, + commentCount: nonnegativeCount(post.comment_count), + contentPreview: + optionalString(post.content_preview) ?? optionalString(post.content) ?? "", + createdAtMs: timestampMilliseconds(post.created_at), + downvotes: nonnegativeCount(post.downvotes), + id, + submoltName: optionalString(post.submolt_name) ?? requiredString(submolt?.name), + title: requiredString(post.title), + upvotes: nonnegativeCount(post.upvotes), + ...(post.you_follow_author === undefined + ? {} + : { youFollowAuthor: booleanValue(post.you_follow_author) }), + }; +} + +function normalizeFeed(value: unknown, sort: "hot" | "new") { + const feed = record(value); + requireRecognizedEnvelopeField(feed, moltbookFeedEnvelopeFields); + return { + ...(optionalString(feed.feed_filter) === undefined + ? {} + : { filter: optionalString(feed.feed_filter) }), + hasMore: booleanValue(feed.has_more), + posts: optionalArray(feed.posts) + .slice(0, moltbookFeedMaximumPosts) + .map((post) => normalizeFeedPost(post)), + sort, + ...(optionalString(feed.tip) === undefined + ? {} + : { tip: optionalString(feed.tip) }), + }; +} + +function normalizeOwnPost(value: unknown) { + const post = record(value); + const submolt = record(post.submolt); + return { + commentCount: nonnegativeCount(post.comment_count), + contentPreview: + optionalString(post.content_preview) ?? optionalString(post.content) ?? "", + createdAtMs: timestampMilliseconds(post.created_at), + downvotes: nonnegativeCount(post.downvotes), + id: requiredString(post.id), + submoltName: requiredString(submolt.name), + title: requiredString(post.title), + upvotes: nonnegativeCount(post.upvotes), + }; +} + +function normalizeOwnComment(value: unknown) { + const comment = record(value); + const post = record(comment.post); + const submolt = record(post.submolt); + return { + content: requiredString(comment.content), + createdAtMs: timestampMilliseconds(comment.created_at), + downvotes: nonnegativeCount(comment.downvotes), + id: requiredString(comment.id), + post: { + id: requiredString(post.id), + submoltName: requiredString(submolt.name), + title: requiredString(post.title), + }, + upvotes: nonnegativeCount(comment.upvotes), + }; +} + +function nextAction(value: unknown): string | undefined { + if (typeof value === "string") return value; + const action = optionalRecord(value); + return ( + optionalString(action?.label) ?? + optionalString(action?.title) ?? + optionalString(action?.action) + ); +} + +function normalizeHome(value: unknown) { + const home = record(value); + requireRecognizedEnvelopeField(home, moltbookHomeEnvelopeFields); + const messages = optionalRecord(home.your_direct_messages); + const account = optionalRecord(home.your_account); + const announcement = optionalRecord(home.latest_moltbook_announcement); + const normalizedAnnouncement = + announcement === undefined + ? undefined + : { + ...(optionalString(announcement.author_name) === undefined + ? {} + : { authorName: optionalString(announcement.author_name) }), + ...(optionalTimestampMilliseconds(announcement.created_at) === undefined + ? {} + : { + createdAtMs: optionalTimestampMilliseconds( + announcement.created_at + ), + }), + ...(optionalString(announcement.post_id) === undefined + ? {} + : { postId: optionalString(announcement.post_id) }), + ...(optionalString(announcement.preview) === undefined + ? {} + : { previewText: optionalString(announcement.preview) }), + ...(optionalString(announcement.title) === undefined + ? {} + : { title: optionalString(announcement.title) }), + }; + return { + activityOnYourPostsCount: optionalArray(home.activity_on_your_posts).length, + exploreCount: homeExploreCount(home.explore), + ...(normalizedAnnouncement === undefined || + Object.keys(normalizedAnnouncement).length === 0 + ? {} + : { latestAnnouncement: normalizedAnnouncement }), + nextActions: optionalArray(home.what_to_do_next) + .map((action) => nextAction(action)) + .filter((action): action is string => action !== undefined) + .slice(0, moltbookNextActionsMaximum), + pendingRequestCount: nonnegativeCount(messages?.pending_request_count), + postsFromAccountsYouFollowCount: homeFollowingPostsCount( + home.posts_from_accounts_you_follow + ), + unreadMessageCount: nonnegativeCount(messages?.unread_message_count), + unreadNotificationCount: nonnegativeCount(account?.unread_notification_count), + }; +} + +function normalizeProfile(value: unknown) { + const response = record(value); + const profile = optionalRecord(response.agent); + if (profile === undefined) return; + const name = requiredString(profile.name); + return { + commentsCount: nonnegativeCount(profile.comments_count), + description: optionalString(profile.description) ?? "", + displayName: optionalString(profile.display_name) ?? name, + followerCount: nonnegativeCount(profile.follower_count), + followingCount: nonnegativeCount(profile.following_count), + karma: signedInteger(profile.karma), + name, + postsCount: nonnegativeCount(profile.posts_count), + }; +} + +function normalizeOwnContent(value: unknown) { + const response = record(value); + requireRecognizedEnvelopeField(response, moltbookProfileEnvelopeFields); + return { + comments: optionalArray(response.recentComments) + .slice(0, moltbookOwnCommentsMaximum) + .map((comment) => normalizeOwnComment(comment)), + posts: optionalArray(response.recentPosts) + .slice(0, moltbookOwnPostsMaximum) + .map((post) => normalizeOwnPost(post)), + }; +} + +function normalizeFailure( + error: unknown, + requestSignal: AbortSignal, + timeoutSignal: AbortSignal +): never { + if (requestSignal.aborted) throw error; + if (timeoutSignal.aborted) throw new MoltbookProviderFailure("timeout"); + if (error instanceof MoltbookProviderFailure) throw error; + throw new MoltbookProviderFailure("unavailable"); +} + +/** + * Creates the fixed-host, read-only Moltbook collector used only by the worker. + * @param options Redacted credential, configured agent identity, and injectable boundaries. + * @returns One all-or-nothing bounded Dashboard snapshot collector. + */ +export function createMoltbookDashboardCollector( + options: MoltbookDashboardCollectorOptions +): MoltbookDashboardCollector { + const fetchImplementation: MoltbookFetch = + options.fetch ?? ((url, init) => globalThis.fetch(url, init)); + const nowMs = options.nowMs ?? Date.now; + const timeoutMs = options.timeoutMs ?? moltbookRequestTimeoutMs; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new RangeError("Moltbook request timeout is invalid"); + } + const profileUrl = new URL( + `${moltbookApiBasePath}/agents/profile`, + moltbookApiOrigin + ); + profileUrl.searchParams.set("name", options.agentName); + const requestUrls = Object.freeze({ + home: new URL(`${moltbookApiBasePath}/home`, moltbookApiOrigin), + hot: new URL(`${moltbookApiBasePath}/feed?sort=hot&limit=25`, moltbookApiOrigin), + new: new URL(`${moltbookApiBasePath}/feed?sort=new&limit=25`, moltbookApiOrigin), + profile: profileUrl, + }); + + return Object.freeze({ + async collect(requestSignal: AbortSignal) { + requestSignal.throwIfAborted(); + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const collectionController = new AbortController(); + const signal = AbortSignal.any([ + requestSignal, + timeoutSignal, + collectionController.signal, + ]); + const fetchJson = async (url: URL): Promise => { + const response = await fetchImplementation(url, { + headers: { + accept: "application/json", + authorization: `Bearer ${Redacted.value(options.apiKey)}`, + }, + method: "GET", + redirect: "error", + signal, + }); + return boundedJsonResponse(response, signal); + }; + try { + const [home, hot, newest, profile] = await Promise.all([ + fetchJson(requestUrls.home), + fetchJson(requestUrls.hot), + fetchJson(requestUrls.new), + fetchJson(requestUrls.profile), + ]); + signal.throwIfAborted(); + const normalizedProfile = normalizeProfile(profile); + const candidate = { + feeds: { + hot: normalizeFeed(hot, "hot"), + new: normalizeFeed(newest, "new"), + }, + fetchedAtMs: nowMs(), + home: normalizeHome(home), + myContent: normalizeOwnContent(profile), + ...(normalizedProfile === undefined + ? {} + : { profile: normalizedProfile }), + }; + let snapshot: MoltbookDashboardCachePayload; + try { + snapshot = v.parse(moltbookDashboardCachePayloadSchema, candidate); + } catch { + throw new MoltbookProviderFailure("invalid-response"); + } + v.parse(cacheEntryPayloadSchema, snapshot); + return snapshot; + } catch (error) { + return normalizeFailure(error, requestSignal, timeoutSignal); + } finally { + collectionController.abort(); + } + }, + }); +} diff --git a/greenfield/src/server/domains/moltbook/routes.ts b/greenfield/src/server/domains/moltbook/routes.ts new file mode 100644 index 000000000..f3642d156 --- /dev/null +++ b/greenfield/src/server/domains/moltbook/routes.ts @@ -0,0 +1,113 @@ +import { TRPCError } from "@trpc/server"; +import { Effect } from "effect"; +import * as v from "valibot"; + +import type { CacheEntry } from "../../../contracts/cache.ts"; +import { + moltbookDashboardCachePayloadSchema, + moltbookFeedInputSchema, + moltbookFeedResultSchema, + moltbookHomeResultSchema, + moltbookOwnContentResultSchema, + moltbookProfileResultSchema, + moltbookSnapshotResultSchema, + moltbookSnapshotStatusSchema, +} from "../../../contracts/moltbook.ts"; +import { emptyInputSchema } from "../../../contracts/system.ts"; +import { sessionCapabilityProcedure } from "../../trpc/trpc.ts"; +import { CacheNotFoundError } from "../cache/errors.ts"; +import type { CacheService } from "../cache/service.ts"; + +const moltbookCacheKey = "moltbook.dashboard"; +const moltbookCacheSchemaId = "moltbook.dashboard.v1"; + +function unavailable(): TRPCError { + return new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Moltbook data is temporarily unavailable", + }); +} + +async function readSnapshot(cacheService: CacheService["Service"]) { + let entry: CacheEntry; + try { + entry = await Effect.runPromise(cacheService.getEntry({ key: moltbookCacheKey })); + } catch (error) { + if (error instanceof CacheNotFoundError) throw unavailable(); + throw error; + } + if ( + entry.freshness === "missing" || + entry.payload === undefined || + entry.lastSuccessAtMs === undefined || + entry.schemaId !== moltbookCacheSchemaId + ) { + throw unavailable(); + } + try { + const snapshot = v.parse(moltbookDashboardCachePayloadSchema, entry.payload); + const status = v.parse(moltbookSnapshotStatusSchema, { + freshness: entry.freshness, + lastAttemptAtMs: entry.lastAttemptAtMs, + lastAttemptStatus: entry.lastAttemptStatus, + lastSuccessAtMs: entry.lastSuccessAtMs, + ...(entry.lastAttemptStatus === "failed" + ? { refreshFailureMessage: entry.failureMessage } + : {}), + }); + return { snapshot, status }; + } catch (error) { + if (v.isValiError(error)) throw unavailable(); + throw error; + } +} + +const moltbookReadProcedure = sessionCapabilityProcedure("cache:read"); + +/** Session-only projections over one bounded last-known-good Moltbook snapshot. */ +export const moltbookRoutes = { + feed: moltbookReadProcedure + .input(moltbookFeedInputSchema) + .output(moltbookFeedResultSchema) + .query(async ({ ctx, input }) => { + const { snapshot, status } = await readSnapshot(ctx.cacheService); + return { feed: snapshot.feeds[input.sort], status }; + }), + home: moltbookReadProcedure + .input(emptyInputSchema) + .output(moltbookHomeResultSchema) + .query(async ({ ctx }) => { + const { snapshot, status } = await readSnapshot(ctx.cacheService); + return { home: snapshot.home, status }; + }), + listMyPosts: moltbookReadProcedure + .input(emptyInputSchema) + .output(moltbookOwnContentResultSchema) + .query(async ({ ctx }) => { + const { snapshot, status } = await readSnapshot(ctx.cacheService); + return { content: snapshot.myContent, status }; + }), + profile: moltbookReadProcedure + .input(emptyInputSchema) + .output(moltbookProfileResultSchema) + .query(async ({ ctx }) => { + const { snapshot, status } = await readSnapshot(ctx.cacheService); + return { + ...(snapshot.profile === undefined ? {} : { profile: snapshot.profile }), + status, + }; + }), + snapshot: moltbookReadProcedure + .input(moltbookFeedInputSchema) + .output(moltbookSnapshotResultSchema) + .query(async ({ ctx, input }) => { + const { snapshot, status } = await readSnapshot(ctx.cacheService); + return { + content: snapshot.myContent, + feed: snapshot.feeds[input.sort], + home: snapshot.home, + ...(snapshot.profile === undefined ? {} : { profile: snapshot.profile }), + status, + }; + }), +}; diff --git a/greenfield/src/server/domains/openClawCron/projection.ts b/greenfield/src/server/domains/openClawCron/projection.ts index 7274a1b34..f5b6abe52 100644 --- a/greenfield/src/server/domains/openClawCron/projection.ts +++ b/greenfield/src/server/domains/openClawCron/projection.ts @@ -23,6 +23,7 @@ import { openClawCronJobSchema, openClawCronPayloadTextMaximumLength, openClawCronRunSchema, + openClawCronTimestampSchema, } from "../../../contracts/openClawCron.ts"; import type { OpenClawCronActiveDisableIntent } from "./intentStore.ts"; import { @@ -332,6 +333,73 @@ function projectSynchronization( }; } +const openClawCronHeartbeatProviderJobSchema = v.object({ + enabled: v.boolean("OpenClaw cron enabled state is invalid"), + id: openClawCronJobIdSchema, + state: v.object({ + lastDurationMs: v.optional(openClawCronTimestampSchema), + lastRunAtMs: v.optional(openClawCronTimestampSchema), + lastRunStatus: v.optional(v.picklist(["error", "ok", "skipped"])), + nextRunAtMs: v.optional(openClawCronTimestampSchema), + runningAtMs: v.optional(openClawCronTimestampSchema), + }), +}); + +/** Minimal validated provider projection retained by the owned heartbeat inventory. */ +export type OpenClawCronHeartbeatJobSummary = Readonly<{ + desiredEnabled?: boolean; + enabled: boolean; + id: string; + lastDurationMs?: number; + lastRunAtMs?: number; + lastRunStatus?: "error" | "ok" | "skipped"; + nextRunAtMs?: number; + runningAtMs?: number; + synchronization: "confirmed" | "conflict" | "pending"; +}>; + +/** + * Projects only the fields needed by heartbeat without copying schedule or payload text. + * @returns A bounded identity-bearing summary for process-local correlation only. + */ +export function projectOpenClawCronHeartbeatJobSummary( + job: OpenClawCronProviderJob, + intent: OpenClawCronActiveDisableIntent | undefined, + freshness: OpenClawCronFreshness, + nowMs: number +): OpenClawCronHeartbeatJobSummary { + const parsed = parseProviderProjection(openClawCronHeartbeatProviderJobSchema, job); + const synchronization = projectSynchronization( + parsed.enabled, + intent, + freshness, + nowMs + ); + return Object.freeze({ + ...(synchronization.desiredEnabled === undefined + ? {} + : { desiredEnabled: synchronization.desiredEnabled }), + enabled: parsed.enabled, + id: parsed.id, + ...(parsed.state.lastDurationMs === undefined + ? {} + : { lastDurationMs: parsed.state.lastDurationMs }), + ...(parsed.state.lastRunAtMs === undefined + ? {} + : { lastRunAtMs: parsed.state.lastRunAtMs }), + ...(parsed.state.lastRunStatus === undefined + ? {} + : { lastRunStatus: parsed.state.lastRunStatus }), + ...(parsed.state.nextRunAtMs === undefined + ? {} + : { nextRunAtMs: parsed.state.nextRunAtMs }), + ...(parsed.state.runningAtMs === undefined + ? {} + : { runningAtMs: parsed.state.runningAtMs }), + synchronization: synchronization.state, + }); +} + export function projectOpenClawCronJob( job: OpenClawCronProviderJob, intent: OpenClawCronActiveDisableIntent | undefined, diff --git a/greenfield/src/server/domains/openClawCron/provider.ts b/greenfield/src/server/domains/openClawCron/provider.ts index 7977ce6b8..6330f806f 100644 --- a/greenfield/src/server/domains/openClawCron/provider.ts +++ b/greenfield/src/server/domains/openClawCron/provider.ts @@ -88,6 +88,8 @@ export interface OpenClawCronProviderListPage { readonly limit: number; readonly nextOffset: number | null; readonly offset: number; + /** Encoded bytes of the authenticated raw response frame before projection strips fields. */ + readonly responseBytes: number; readonly snapshotRevision: string; readonly total: number; } diff --git a/greenfield/src/server/domains/openClawCron/service.test.ts b/greenfield/src/server/domains/openClawCron/service.test.ts index 4797d42e0..1c1f63c90 100644 --- a/greenfield/src/server/domains/openClawCron/service.test.ts +++ b/greenfield/src/server/domains/openClawCron/service.test.ts @@ -14,7 +14,13 @@ import { OpenClawCronProviderError, type OpenClawCronProviderJob, } from "./provider.ts"; -import { OpenClawCronServiceError, createOpenClawCronService } from "./service.ts"; +import { + OpenClawCronServiceError, + createOpenClawCronService, + openClawCronHeartbeatFailureBackoffMs, + openClawCronHeartbeatInventoryMaximumBytes, + openClawCronHeartbeatRefreshIntervalMs, +} from "./service.ts"; const operator = { id: "019fc968-1a9b-7770-8f1b-d5b863b0e7b4", @@ -117,6 +123,7 @@ class FakeProvider implements OpenClawCronProvider { limit: input.limit, nextOffset: null, offset: input.offset, + responseBytes: 1024, snapshotRevision: `sha256:${"A".repeat(43)}`, total: jobs.length, }); @@ -224,13 +231,14 @@ class FakeProvider implements OpenClawCronProvider { } } -function fixture(clock = () => 1000) { +function fixture(clock = () => 1000, monotonicClock?: () => number) { const provider = new FakeProvider(); const intentStore = createInMemoryOpenClawCronIntentStore(); const service = createOpenClawCronService({ auditRequired: false, clock, intentStore, + ...(monotonicClock === undefined ? {} : { monotonicClock }), provider, }); return { intentStore, provider, service }; @@ -248,6 +256,45 @@ function inventoryInput() { }; } +function heartbeatJobs(count: number): OpenClawCronProviderJob[] { + return Array.from({ length: count }, (_, index) => + providerJob({ + id: `heartbeat-job-${String(index).padStart(4, "0")}`, + name: `Heartbeat job ${index}`, + }) + ); +} + +function installHeartbeatPages( + provider: FakeProvider, + jobs: readonly OpenClawCronProviderJob[], + snapshotRevision = `sha256:${"A".repeat(43)}`, + total = jobs.length +): void { + provider.list = (input) => { + provider.listCalls.push(input); + if (provider.listError !== undefined) { + return Promise.reject(fakeProviderError(provider.listError)); + } + const pageJobs = jobs.slice(input.offset, input.offset + input.limit); + const nextOffset = input.offset + pageJobs.length; + const hasMore = nextOffset < total; + return Promise.resolve({ + hasMore, + jobs: pageJobs, + limit: input.limit, + nextOffset: hasMore ? nextOffset : null, + offset: input.offset, + responseBytes: Math.max( + 1, + Buffer.byteLength(JSON.stringify(pageJobs), "utf8") + ), + snapshotRevision, + total, + }); + }; +} + describe("OpenClaw cron service", () => { test("fails closed before provider dispatch when required audit is unavailable", async () => { const provider = new FakeProvider(); @@ -523,7 +570,13 @@ describe("OpenClaw cron service", () => { offset: 0, }); expect(service.readHeartbeatProjection()).toEqual({ + pendingSync: "unknown", + state: "unavailable", + }); + await service.refreshHeartbeatProjection(); + expect(service.readHeartbeatProjection()).toMatchObject({ count: 1, + health: { inspectedCount: 1, truncated: false }, observedAtMs: 1000, pendingSync: "none", state: "fresh", @@ -545,12 +598,11 @@ describe("OpenClaw cron service", () => { observedAtMs: 1000, staleSinceMs: 2000, }); - expect(service.readHeartbeatProjection()).toEqual({ + expect(service.readHeartbeatProjection()).toMatchObject({ count: 1, observedAtMs: 1000, pendingSync: "none", - staleSinceMs: 2000, - state: "last-known-good", + state: "fresh", }); try { await service.list({ @@ -634,7 +686,7 @@ describe("OpenClaw cron service", () => { }); }); - test("does not claim no pending synchronization from a truncated inventory", async () => { + test("does not let a truncated UI inventory prime the owned heartbeat", async () => { const { provider, service } = fixture(); provider.list = (input) => { provider.listCalls.push(input); @@ -644,6 +696,7 @@ describe("OpenClaw cron service", () => { limit: input.limit, nextOffset: 1, offset: 0, + responseBytes: 1024, snapshotRevision: `sha256:${"A".repeat(43)}`, total: 2, }); @@ -651,13 +704,583 @@ describe("OpenClaw cron service", () => { await service.list({ ...inventoryInput(), limit: 1 }); expect(service.readHeartbeatProjection()).toEqual({ - count: 2, + pendingSync: "unknown", + state: "unavailable", + }); + }); + + test("owns cold refresh, success TTL, LKG fallback, and failure backoff", async () => { + let wallClockMs = 1000; + let monotonicClockMs = 0; + const { provider, service } = fixture( + () => wallClockMs, + () => monotonicClockMs + ); + + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(1); + expect(service.readHeartbeatProjection()).toMatchObject({ + count: 1, + health: { inspectedCount: 1, truncated: false }, + observedAtMs: 1000, + state: "fresh", + }); + expect(service.readHeartbeatJobProjection("nightly-report")).toMatchObject({ + enabled: true, + state: "present", + }); + expect(service.readHeartbeatJobProjection("absent-job")).toEqual({ + state: "missing", + }); + + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(1); + + monotonicClockMs = openClawCronHeartbeatRefreshIntervalMs; + wallClockMs = 2000; + provider.listError = new OpenClawCronProviderError("unavailable"); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + expect(service.readHeartbeatProjection()).toMatchObject({ + count: 1, observedAtMs: 1000, + staleSinceMs: 2000, + state: "last-known-good", + }); + expect(service.readHeartbeatJobProjection("nightly-report")).toEqual({ + state: "unavailable", + }); + + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + monotonicClockMs += openClawCronHeartbeatFailureBackoffMs; + wallClockMs = 3000; + provider.listError = undefined; + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(3); + expect(service.readHeartbeatProjection()).toMatchObject({ + observedAtMs: 3000, + state: "fresh", + }); + }); + + test("starts failure backoff when the failed refresh settles", async () => { + let monotonicClockMs = 0; + const { provider, service } = fixture( + () => 1000, + () => monotonicClockMs + ); + await service.refreshHeartbeatProjection(); + const defaultList = provider.list.bind(provider); + let failNext = true; + provider.list = (input) => { + if (!failNext) return defaultList(input); + failNext = false; + provider.listCalls.push(input); + monotonicClockMs += 8000; + return Promise.reject(new OpenClawCronProviderError("unavailable")); + }; + + monotonicClockMs = openClawCronHeartbeatRefreshIntervalMs; + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + expect(service.readHeartbeatProjection()).toMatchObject({ + state: "last-known-good", + }); + + monotonicClockMs += openClawCronHeartbeatFailureBackoffMs - 1; + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + monotonicClockMs += 1; + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(3); + expect(service.readHeartbeatProjection()).toMatchObject({ state: "fresh" }); + }); + + test("walks only coherent bounded inventories and keeps truncation truthful", async () => { + for (const count of [0, 100, 101, 1000, 1001]) { + const { provider, service } = fixture(); + const jobs = heartbeatJobs(count); + installHeartbeatPages(provider, jobs); + + await service.refreshHeartbeatProjection(); + + const inspectedCount = Math.min(count, 1000); + expect(provider.listCalls).toHaveLength( + Math.max(1, Math.ceil(inspectedCount / 100)) + ); + expect(service.readHeartbeatProjection()).toMatchObject({ + count, + health: { + inspectedCount, + truncated: count > inspectedCount, + }, + state: "fresh", + }); + if (count > 0) { + expect( + service.readHeartbeatJobProjection("heartbeat-job-0000") + ).toMatchObject({ state: "present" }); + } + expect( + service.readHeartbeatJobProjection( + `heartbeat-job-${String(count).padStart(4, "0")}` + ) + ).toEqual({ state: count > 1000 ? "unavailable" : "missing" }); + } + }); + + test("retries one revision race and never commits a mixed inventory", async () => { + const { provider, service } = fixture(); + const jobs = heartbeatJobs(101); + let walk = 0; + provider.list = (input) => { + provider.listCalls.push(input); + if (input.offset === 0) walk += 1; + const pageJobs = jobs.slice(input.offset, input.offset + input.limit); + const nextOffset = input.offset + pageJobs.length; + return Promise.resolve({ + hasMore: nextOffset < jobs.length, + jobs: pageJobs, + limit: input.limit, + nextOffset: nextOffset < jobs.length ? nextOffset : null, + offset: input.offset, + responseBytes: 1024, + snapshotRevision: `sha256:${(walk === 1 && input.offset > 0 + ? "B" + : "A" + ).repeat(43)}`, + total: jobs.length, + }); + }; + + await service.refreshHeartbeatProjection(); + + expect(provider.listCalls.map(({ offset }) => offset)).toEqual([0, 100, 0, 100]); + expect(service.readHeartbeatProjection()).toMatchObject({ + count: 101, + health: { inspectedCount: 101, truncated: false }, + state: "fresh", + }); + }); + + test("retains the whole prior snapshot when every pagination attempt is invalid", async () => { + let wallClockMs = 1000; + let monotonicClockMs = 0; + const { provider, service } = fixture( + () => wallClockMs, + () => monotonicClockMs + ); + await service.refreshHeartbeatProjection(); + const oldProjection = service.readHeartbeatProjection(); + if (oldProjection.state === "unavailable") { + throw new Error("Expected the first heartbeat refresh to commit"); + } + const jobs = heartbeatJobs(101); + monotonicClockMs = openClawCronHeartbeatRefreshIntervalMs; + wallClockMs = 2000; + provider.list = (input) => { + provider.listCalls.push(input); + const pageJobs = jobs.slice(input.offset, input.offset + input.limit); + const nextOffset = input.offset + pageJobs.length; + return Promise.resolve({ + hasMore: nextOffset < jobs.length, + jobs: pageJobs, + limit: input.limit, + nextOffset: nextOffset < jobs.length ? nextOffset : null, + offset: input.offset, + responseBytes: 1024, + snapshotRevision: `sha256:${(input.offset === 0 ? "A" : "B").repeat(43)}`, + total: jobs.length, + }); + }; + + await service.refreshHeartbeatProjection(); + + expect(service.readHeartbeatProjection()).toMatchObject({ + count: oldProjection.count, + observedAtMs: oldProjection.observedAtMs, + staleSinceMs: 2000, + state: "last-known-good", + }); + expect(service.readHeartbeatJobProjection("heartbeat-job-0000")).toEqual({ + state: "unavailable", + }); + }); + + test("rejects duplicate, total, offset, and zero-progress page walks", async () => { + for (const defect of ["duplicate", "total", "offset", "zero-progress"] as const) { + const { provider, service } = fixture(); + const jobs = heartbeatJobs(101); + provider.list = (input) => { + provider.listCalls.push(input); + const sourceJobs = jobs.slice(input.offset, input.offset + input.limit); + let pageJobs = sourceJobs; + if (input.offset === 100 && defect === "duplicate") { + pageJobs = [jobs[0]!]; + } else if (input.offset === 100 && defect === "zero-progress") { + pageJobs = []; + } + const nextOffset = input.offset + pageJobs.length; + const hasMore = nextOffset < jobs.length; + return Promise.resolve({ + hasMore, + jobs: pageJobs, + limit: input.limit, + nextOffset: hasMore ? nextOffset : null, + offset: + input.offset === 100 && defect === "offset" ? 99 : input.offset, + responseBytes: 1024, + snapshotRevision: `sha256:${"A".repeat(43)}`, + total: + input.offset === 100 && defect === "total" + ? jobs.length + 1 + : jobs.length, + }); + }; + + await service.refreshHeartbeatProjection(); + + expect(service.readHeartbeatProjection()).toEqual({ + pendingSync: "unknown", + state: "unavailable", + }); + expect(provider.listCalls.map(({ offset }) => offset)).toEqual([ + 0, 100, 0, 100, + ]); + } + }); + + test("walks pages sequentially and never starts unread siblings after failure", async () => { + const { provider, service } = fixture(); + const jobs = heartbeatJobs(201); + let activeReads = 0; + let peakActiveReads = 0; + provider.list = async (input) => { + provider.listCalls.push(input); + activeReads += 1; + peakActiveReads = Math.max(peakActiveReads, activeReads); + try { + await Promise.resolve(); + if (input.offset === 100) { + throw new OpenClawCronProviderError("unavailable"); + } + const pageJobs = jobs.slice(input.offset, input.offset + input.limit); + const nextOffset = input.offset + pageJobs.length; + return { + hasMore: nextOffset < jobs.length, + jobs: pageJobs, + limit: input.limit, + nextOffset: nextOffset < jobs.length ? nextOffset : null, + offset: input.offset, + responseBytes: 1024, + snapshotRevision: `sha256:${"A".repeat(43)}`, + total: jobs.length, + }; + } finally { + activeReads -= 1; + } + }; + + await service.refreshHeartbeatProjection(); + + expect(provider.listCalls.map(({ offset }) => offset)).toEqual([0, 100]); + expect(peakActiveReads).toBe(1); + expect(service.readHeartbeatProjection()).toEqual({ + pendingSync: "unknown", + state: "unavailable", + }); + }); + + test("rejects an aggregate inventory byte overflow without retrying it", async () => { + const { provider, service } = fixture(); + const message = "x".repeat(256 * 1024); + const jobs = heartbeatJobs(130).map((job) => + providerJob({ + ...job, + payload: { kind: "agentTurn", message }, + }) + ); + expect(message.length * jobs.length).toBeGreaterThan( + openClawCronHeartbeatInventoryMaximumBytes + ); + installHeartbeatPages(provider, jobs); + + await service.refreshHeartbeatProjection(); + + expect(provider.listCalls.map(({ offset }) => offset)).toEqual([0, 100]); + expect(service.readHeartbeatProjection()).toEqual({ pendingSync: "unknown", + state: "unavailable", + }); + }); + + test("single-flights refresh and aborts the process-owned flight on disposal", async () => { + const { provider, service } = fixture(); + const deferred = + Promise.withResolvers>>(); + provider.list = (input) => { + provider.listCalls.push(input); + return deferred.promise; + }; + const first = service.refreshHeartbeatProjection(); + const second = service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(1); + deferred.resolve({ + hasMore: false, + jobs: [providerJob()], + limit: 100, + nextOffset: null, + offset: 0, + responseBytes: 1024, + snapshotRevision: `sha256:${"A".repeat(43)}`, + total: 1, + }); + await Promise.all([first, second]); + expect(service.readHeartbeatProjection()).toMatchObject({ state: "fresh" }); + + const disposable = fixture(); + let refreshSignal: AbortSignal | undefined; + disposable.provider.list = (input) => { + disposable.provider.listCalls.push(input); + refreshSignal = input.signal; + return new Promise((_resolve, reject) => { + input.signal?.addEventListener( + "abort", + () => reject(new Error("disposed")), + { once: true } + ); + }); + }; + const pending = disposable.service.refreshHeartbeatProjection(); + await Promise.resolve(); + await disposable.service.disposeHeartbeat(); + await pending; + expect(refreshSignal?.aborted).toBeTrue(); + expect(disposable.service.readHeartbeatProjection()).toEqual({ + pendingSync: "unknown", + state: "unavailable", + }); + }); + + test("classifies aggregate disabled, conflict, failure, and stuck health", async () => { + const provider = new FakeProvider(); + const intentStore = createInMemoryOpenClawCronIntentStore(); + await intentStore.replaceActive({ + actor: operator, + externalJobId: "intended-disabled", + reason: "Maintenance", + recordedAtMs: 100, + }); + await intentStore.replaceActive({ + actor: operator, + externalJobId: "enable-conflict", + reason: "Maintenance", + recordedAtMs: 100, + }); + await intentStore.replaceActive({ + actor: operator, + expiresAtMs: 1500, + externalJobId: "expired-pending", + reason: "Short freeze", + recordedAtMs: 100, + }); + const jobs = [ + providerJob({ enabled: false, id: "intended-disabled" }), + providerJob({ enabled: false, id: "unexpected-disabled" }), + providerJob({ enabled: true, id: "enable-conflict" }), + providerJob({ enabled: false, id: "expired-pending" }), + providerJob({ + id: "stuck-failure", + state: { + lastRunStatus: "error", + runningAtMs: 1000, + }, + }), + ]; + installHeartbeatPages(provider, jobs); + const service = createOpenClawCronService({ + auditRequired: false, + clock: () => 2_000_000, + intentStore, + provider, + }); + + await service.refreshHeartbeatProjection(); + + expect(service.readHeartbeatProjection()).toMatchObject({ + health: { + disabledCount: 3, + enabledCount: 2, + inspectedCount: 5, + intendedDisabledCount: 1, + lastRunErrorCount: 1, + runningCount: 1, + staleRunningCount: 1, + synchronizationConflictCount: 1, + synchronizationPendingCount: 1, + truncated: false, + unexpectedDisabledCount: 2, + }, + pendingSync: "present", state: "fresh", }); }); + test("invalidates a successful TTL immediately after a cron mutation", async () => { + let monotonicClockMs = 0; + const { provider, service } = fixture( + () => 1000, + () => monotonicClockMs + ); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(1); + + await service.update({ + expectedConfigRevision: "revision-1", + id: "nightly-report", + patch: { name: "Morning report" }, + }); + expect(service.readHeartbeatProjection()).toMatchObject({ + state: "last-known-good", + }); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + expect(service.readHeartbeatProjection()).toMatchObject({ state: "fresh" }); + monotonicClockMs += 1; + }); + + test("discards an in-flight pre-mutation candidate and refreshes waiting callers", async () => { + let monotonicClockMs = 0; + const { provider, service } = fixture( + () => 1000, + () => monotonicClockMs + ); + await service.refreshHeartbeatProjection(); + const defaultList = provider.list.bind(provider); + const heldPage = + Promise.withResolvers>>(); + let holdPage = true; + provider.list = (input) => { + if (!holdPage) return defaultList(input); + provider.listCalls.push(input); + return heldPage.promise; + }; + + monotonicClockMs = openClawCronHeartbeatRefreshIntervalMs; + const staleFlight = service.refreshHeartbeatProjection(); + await Promise.resolve(); + await service.update({ + expectedConfigRevision: "revision-1", + id: "nightly-report", + patch: { name: "Morning report" }, + }); + const waitingRefresh = service.refreshHeartbeatProjection(); + holdPage = false; + heldPage.resolve({ + hasMore: false, + jobs: [providerJob()], + limit: 100, + nextOffset: null, + offset: 0, + responseBytes: 1024, + snapshotRevision: `sha256:${"A".repeat(43)}`, + total: 1, + }); + + await Promise.all([staleFlight, waitingRefresh]); + expect(provider.listCalls).toHaveLength(3); + expect(service.readHeartbeatProjection()).toMatchObject({ state: "fresh" }); + }); + + test("invalidates TTL for pending, conflicting, absent, and expired state changes", async () => { + { + const { provider, service } = fixture(); + await service.refreshHeartbeatProjection(); + provider.updateError = new OpenClawCronProviderError("unavailable"); + await service.setEnabled( + { + disableIntent: { reason: "Maintenance" }, + enabled: false, + expectedConfigRevision: "revision-1", + id: "nightly-report", + }, + operator + ); + expect(service.readHeartbeatProjection()).toMatchObject({ + pendingSync: "present", + state: "last-known-good", + }); + provider.updateError = undefined; + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + } + + { + const { intentStore, provider, service } = fixture(); + provider.currentJob = providerJob({ enabled: false }); + await intentStore.replaceActive({ + actor: operator, + externalJobId: "nightly-report", + reason: "Maintenance", + recordedAtMs: 100, + }); + await service.refreshHeartbeatProjection(); + provider.holdUpdateReadback = true; + const failure = await captureFailure(() => + service.setEnabled( + { + disableIntent: null, + enabled: true, + expectedConfigRevision: "revision-1", + id: "nightly-report", + }, + operator + ) + ); + expect(failure).toMatchObject({ reason: "conflict" }); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + } + + { + const { provider, service } = fixture(); + await service.refreshHeartbeatProjection(); + provider.currentJob = undefined; + await service.delete( + { + expectedConfigRevision: "revision-1", + id: "nightly-report", + }, + operator + ); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + expect(service.readHeartbeatProjection()).toMatchObject({ + count: 0, + state: "fresh", + }); + } + + { + let wallClockMs = 1000; + const { intentStore, provider, service } = fixture(() => wallClockMs); + await intentStore.replaceActive({ + actor: operator, + expiresAtMs: 1500, + externalJobId: "nightly-report", + reason: "Short freeze", + recordedAtMs: 500, + }); + await service.refreshHeartbeatProjection(); + wallClockMs = 2000; + await service.get({ id: "nightly-report" }); + expect(await intentStore.getActive("nightly-report")).toBeUndefined(); + await service.refreshHeartbeatProjection(); + expect(provider.listCalls).toHaveLength(2); + } + }); + test("enriches fresh and LKG provider pages from the exact open Dashboard task projection", async () => { const provider = new FakeProvider(); const intentStore = createInMemoryOpenClawCronIntentStore(); @@ -966,7 +1589,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); expect( await captureFailure(() => service.reconcileExpired({ id: "nightly-report" })) @@ -1044,7 +1667,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); const result = await service.setEnabled( { @@ -1105,7 +1728,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); const result = await service.setEnabled( { @@ -1230,7 +1853,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); expect( await captureFailure(() => @@ -1342,7 +1965,7 @@ describe("OpenClaw cron service", () => { intentStore: createInMemoryOpenClawCronIntentStore(), provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); expect( await captureFailure(() => @@ -1395,6 +2018,7 @@ describe("OpenClaw cron service", () => { intentStore: createInMemoryOpenClawCronIntentStore(), provider, }); + await service.refreshHeartbeatProjection(); await service.list(inventoryInput()); const work = @@ -1589,7 +2213,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); expect( await captureFailure(() => @@ -1652,7 +2276,7 @@ describe("OpenClaw cron service", () => { intentStore, provider, }); - await service.list(inventoryInput()); + await service.refreshHeartbeatProjection(); const work = operation === "enable" diff --git a/greenfield/src/server/domains/openClawCron/service.ts b/greenfield/src/server/domains/openClawCron/service.ts index f0535694f..efadef89e 100644 --- a/greenfield/src/server/domains/openClawCron/service.ts +++ b/greenfield/src/server/domains/openClawCron/service.ts @@ -19,6 +19,7 @@ import { getOpenClawCronInputSchema, listOpenClawCronInputSchema, listOpenClawCronRunsInputSchema, + openClawCronPageMaximum, openClawCronTimestampSchema, runOpenClawCronInputSchema, runOpenClawCronResultSchema, @@ -41,9 +42,11 @@ import { freshOpenClawCronSource, lastKnownGoodOpenClawCronSource, projectOpenClawCronGetResult, + projectOpenClawCronHeartbeatJobSummary, projectOpenClawCronJob, projectOpenClawCronListResult, projectOpenClawCronRunsResult, + type OpenClawCronHeartbeatJobSummary, } from "./projection.ts"; import { type OpenClawCronProvider, @@ -98,6 +101,8 @@ export interface OpenClawCronServiceOptions { readonly expirySystemActorId?: string; readonly intentStore: OpenClawCronIntentStore; readonly linkedTaskReader?: OpenClawCronLinkedTaskReader; + /** Monotonic process clock used only for refresh TTLs and retry admission. */ + readonly monotonicClock?: () => number; readonly onAuditSettlementFailure?: (failure: { readonly operation: OpenClawCronAuditOperation; readonly settlement: OpenClawCronTerminalAuditSettlement; @@ -166,21 +171,62 @@ export type OpenClawCronHeartbeatProjection = }> | Readonly<{ count: number; + health: OpenClawCronHeartbeatHealth; observedAtMs: number; pendingSync: "none" | "present" | "unknown"; state: "fresh"; }> | Readonly<{ count: number; + health: OpenClawCronHeartbeatHealth; observedAtMs: number; pendingSync: "none" | "present" | "unknown"; staleSinceMs: number; state: "last-known-good"; }>; +export interface OpenClawCronHeartbeatHealth { + readonly disabledCount: number; + readonly enabledCount: number; + readonly inspectedCount: number; + readonly intendedDisabledCount: number; + readonly lastRunErrorCount: number; + readonly runningCount: number; + readonly staleRunningCount: number; + readonly synchronizationConflictCount: number; + readonly synchronizationPendingCount: number; + readonly truncated: boolean; + readonly unexpectedDisabledCount: number; +} + +/** Identity-free state of one task-linked cron, never its provider id or name. */ +export type OpenClawCronHeartbeatJobProjection = + | Readonly<{ state: "missing" | "unavailable" }> + | Readonly<{ + desiredEnabled?: boolean; + enabled: boolean; + lastDurationMs?: number; + lastRunAtMs?: number; + lastRunStatus?: "error" | "ok" | "skipped" | "unknown"; + nextRunAtMs?: number; + runningAtMs?: number; + state: "present"; + synchronization: "confirmed" | "conflict" | "pending"; + }>; + +type OpenClawCronPresentHeartbeatJobProjection = Extract< + OpenClawCronHeartbeatJobProjection, + { readonly state: "present" } +>; + /** Non-fetching global summary seam with no job names, payloads, or identifiers. */ export interface OpenClawCronHeartbeatReader { + readonly disposeHeartbeat: () => Promise; + readonly readHeartbeatJobProjection: ( + id: string + ) => OpenClawCronHeartbeatJobProjection; readonly readHeartbeatProjection: () => OpenClawCronHeartbeatProjection; + readonly refreshHeartbeatProjection: () => Promise; } function parseInput( @@ -313,25 +359,116 @@ function deferredVoid(): Readonly<{ return { promise, resolve: resolvePromise }; } -function isGlobalInventory(input: ListOpenClawCronInput): boolean { - return ( - input.enabled === "all" && - input.lastRunStatus === "all" && - input.offset === 0 && - input.query === undefined && - input.scheduleKind === "all" - ); +/** Minimum age before heartbeat performs another owned Gateway inventory read. */ +export const openClawCronHeartbeatRefreshIntervalMs = 60_000; +/** Short retry gate after an unsuccessful refresh, without making stale data fresh. */ +export const openClawCronHeartbeatFailureBackoffMs = 10_000; +/** Shared deadline below the HTTP listener ceiling for one owned refresh. */ +export const openClawCronHeartbeatRefreshTimeoutMs = 8000; +/** Maximum complete cron rows inspected by one heartbeat refresh. */ +export const openClawCronHeartbeatInventoryMaximum = 1000; +/** Maximum cumulative authenticated response-frame bytes admitted by one refresh. */ +export const openClawCronHeartbeatInventoryMaximumBytes = 32 * 1024 * 1024; +/** A running cron older than this threshold is surfaced as potentially stuck. */ +export const openClawCronHeartbeatStaleRunningMs = 1_800_000; + +class OpenClawCronHeartbeatInventoryBudgetError extends OpenClawCronServiceError { + constructor() { + super("provider-data-invalid"); + this.name = "OpenClawCronHeartbeatInventoryBudgetError"; + } } -function pendingSyncState( - result: ListOpenClawCronResult -): "none" | "present" | "unknown" { - if ( - result.jobs.some(({ synchronization }) => synchronization.state !== "confirmed") - ) { - return "present"; +function heartbeatSummary( + jobs: ReadonlyMap, + total: number, + observedAtMs: number +): Readonly<{ + health: OpenClawCronHeartbeatHealth; + pendingSync: "none" | "present" | "unknown"; +}> { + let disabledCount = 0; + let enabledCount = 0; + let intendedDisabledCount = 0; + let lastRunErrorCount = 0; + let runningCount = 0; + let staleRunningCount = 0; + let synchronizationConflictCount = 0; + let synchronizationPendingCount = 0; + for (const job of jobs.values()) { + if (job.enabled) enabledCount += 1; + else disabledCount += 1; + if (!job.enabled && job.desiredEnabled === false) { + intendedDisabledCount += 1; + } + if (job.lastRunStatus === "error") lastRunErrorCount += 1; + if (job.runningAtMs !== undefined) { + runningCount += 1; + if ( + job.runningAtMs <= + Math.max(0, observedAtMs - openClawCronHeartbeatStaleRunningMs) + ) { + staleRunningCount += 1; + } + } + if (job.synchronization === "conflict") { + synchronizationConflictCount += 1; + } else if (job.synchronization === "pending") { + synchronizationPendingCount += 1; + } } - return result.hasMore ? "unknown" : "none"; + const health = Object.freeze({ + disabledCount, + enabledCount, + inspectedCount: jobs.size, + intendedDisabledCount, + lastRunErrorCount, + runningCount, + staleRunningCount, + synchronizationConflictCount, + synchronizationPendingCount, + truncated: jobs.size < total, + unexpectedDisabledCount: disabledCount - intendedDisabledCount, + }); + let pendingSync: "none" | "present" | "unknown" = "none"; + if (synchronizationConflictCount + synchronizationPendingCount > 0) { + pendingSync = "present"; + } else if (jobs.size < total) { + pendingSync = "unknown"; + } + return Object.freeze({ health, pendingSync }); +} + +function heartbeatJobProjection( + job: OpenClawCronHeartbeatJobSummary +): OpenClawCronPresentHeartbeatJobProjection { + return Object.freeze({ + ...(job.desiredEnabled === undefined + ? {} + : { desiredEnabled: job.desiredEnabled }), + enabled: job.enabled, + ...(job.lastDurationMs === undefined + ? {} + : { lastDurationMs: job.lastDurationMs }), + ...(job.lastRunAtMs === undefined ? {} : { lastRunAtMs: job.lastRunAtMs }), + ...(job.lastRunStatus === undefined ? {} : { lastRunStatus: job.lastRunStatus }), + ...(job.nextRunAtMs === undefined ? {} : { nextRunAtMs: job.nextRunAtMs }), + ...(job.runningAtMs === undefined ? {} : { runningAtMs: job.runningAtMs }), + state: "present", + synchronization: job.synchronization, + }); +} + +function heartbeatInventoryInput(offset: number): ListOpenClawCronInput { + return { + enabled: "all", + lastRunStatus: "all", + limit: openClawCronPageMaximum, + offset, + scheduleKind: "all", + sortBy: "name", + sortDir: "asc", + }; } function defaultAmbiguousPendingSync( @@ -350,6 +487,7 @@ export function createOpenClawCronService( options: OpenClawCronServiceOptions ): OpenClawCronService & OpenClawCronHeartbeatReader { const now = options.clock ?? Date.now; + const monotonicNow = options.monotonicClock ?? (() => performance.now()); const auditRequired = options.auditRequired ?? true; const expirySystemActor = { id: options.expirySystemActorId ?? "openclaw-cron-expiry", @@ -366,33 +504,19 @@ export function createOpenClawCronService( const getCache = new Map>(); const runsCache = new Map>(); const jobLocks = new Map>(); + let heartbeatJobProjections: ReadonlyMap< + string, + OpenClawCronPresentHeartbeatJobProjection + > = new Map(); + let heartbeatDisposed = false; + let heartbeatNextAttemptAtMonotonicMs: number | undefined; + let heartbeatRefreshController: AbortController | undefined; + let heartbeatRefreshPromise: Promise | undefined; + let heartbeatSnapshotGeneration = 0; let heartbeatProjection: OpenClawCronHeartbeatProjection = Object.freeze({ pendingSync: "unknown", state: "unavailable", }); - let nextHeartbeatProjectionGeneration = 0; - let committedHeartbeatProjectionGeneration = 0; - - function rememberHeartbeatProjection( - result: ListOpenClawCronResult, - generation: number - ): void { - if (generation < committedHeartbeatProjectionGeneration) return; - committedHeartbeatProjectionGeneration = generation; - const shared = { - count: result.total, - observedAtMs: result.freshness.observedAtMs, - pendingSync: pendingSyncState(result), - }; - heartbeatProjection = - result.freshness.kind === "fresh" - ? Object.freeze({ ...shared, state: "fresh" }) - : Object.freeze({ - ...shared, - staleSinceMs: result.freshness.staleSinceMs, - state: "last-known-good", - }); - } function markHeartbeatProjectionStale( candidateCheckedAtMs?: number, @@ -419,6 +543,7 @@ export function createOpenClawCronService( } heartbeatProjection = Object.freeze({ count: current.count, + health: current.health, observedAtMs: current.observedAtMs, pendingSync: current.pendingSync === "present" || pendingSync === "present" @@ -437,15 +562,287 @@ export function createOpenClawCronService( cause: unknown, pendingSync?: "present" | "unknown" ): OpenClawCronServiceError { - committedHeartbeatProjectionGeneration = nextHeartbeatProjectionGeneration += 1; - markHeartbeatProjectionStale(undefined, pendingSync); + invalidateHeartbeatProjection(undefined, pendingSync); return new OpenClawCronServiceError("unknown-outcome", { cause, id }); } + function invalidateHeartbeatProjection( + candidateCheckedAtMs?: number, + pendingSync?: "present" | "unknown" + ): void { + heartbeatSnapshotGeneration += 1; + heartbeatNextAttemptAtMonotonicMs = undefined; + markHeartbeatProjectionStale(candidateCheckedAtMs, pendingSync); + } + function readHeartbeatProjection(): OpenClawCronHeartbeatProjection { return heartbeatProjection; } + function readHeartbeatJobProjection(id: string): OpenClawCronHeartbeatJobProjection { + if (heartbeatProjection.state !== "fresh") { + return Object.freeze({ state: "unavailable" }); + } + const present = heartbeatJobProjections.get(id); + if (present !== undefined) return present; + return Object.freeze({ + state: heartbeatProjection.health.truncated ? "unavailable" : "missing", + }); + } + + let lastHeartbeatMonotonicMs = 0; + + function heartbeatMonotonicMs(): number { + const candidate = monotonicNow(); + if (!Number.isFinite(candidate) || candidate < 0) { + throw new RangeError("OpenClaw cron heartbeat monotonic clock is invalid"); + } + lastHeartbeatMonotonicMs = Math.max(lastHeartbeatMonotonicMs, candidate); + return lastHeartbeatMonotonicMs; + } + + async function readFreshHeartbeatPage( + offset: number, + signal: AbortSignal + ): Promise { + signal.throwIfAborted(); + try { + const page = await options.provider.list({ + ...heartbeatInventoryInput(offset), + compact: false, + includeDeliveryPreviews: false, + signal, + }); + signal.throwIfAborted(); + return page; + } catch (error) { + if (signal.aborted) throw error; + throw serviceError(error); + } + } + + function validateHeartbeatPage( + page: OpenClawCronProviderListPage, + index: number, + inspectedTotal: number, + snapshotRevision: string, + total: number, + ids: Set + ): void { + const expectedOffset = index * openClawCronPageMaximum; + const expectedLength = Math.min( + openClawCronPageMaximum, + Math.max(0, inspectedTotal - expectedOffset) + ); + const expectedNextOffset = expectedOffset + expectedLength; + const expectedHasMore = expectedNextOffset < total; + if ( + page.limit !== openClawCronPageMaximum || + page.offset !== expectedOffset || + !Number.isSafeInteger(page.responseBytes) || + page.responseBytes < 1 || + page.total !== total || + page.snapshotRevision !== snapshotRevision || + page.jobs.length !== expectedLength || + page.hasMore !== expectedHasMore || + page.nextOffset !== (expectedHasMore ? expectedNextOffset : null) + ) { + throw new OpenClawCronServiceError("provider-data-invalid"); + } + for (const { id } of page.jobs) { + if (ids.has(id)) { + throw new OpenClawCronServiceError("provider-data-invalid", { + id, + }); + } + ids.add(id); + } + } + + async function readFreshHeartbeatCandidate(signal: AbortSignal): Promise<{ + readonly health: OpenClawCronHeartbeatHealth; + readonly jobs: ReadonlyMap; + readonly observedAtMs: number; + readonly pendingSync: "none" | "present" | "unknown"; + readonly total: number; + }> { + let lastFailure: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + const attemptController = new AbortController(); + const abortAttempt = () => attemptController.abort(); + if (signal.aborted) { + abortAttempt(); + } else { + signal.addEventListener("abort", abortAttempt, { once: true }); + } + try { + let currentPage: OpenClawCronProviderListPage | undefined = + await readFreshHeartbeatPage(0, attemptController.signal); + if ( + !Number.isSafeInteger(currentPage.total) || + currentPage.total < 0 || + !/^sha256:[A-Za-z0-9_-]{43}$/u.test(currentPage.snapshotRevision) + ) { + throw new OpenClawCronServiceError("provider-data-invalid"); + } + const total = currentPage.total; + const snapshotRevision = currentPage.snapshotRevision; + const inspectedTotal = Math.min( + total, + openClawCronHeartbeatInventoryMaximum + ); + const pageCount = Math.max( + 1, + Math.ceil(inspectedTotal / openClawCronPageMaximum) + ); + const observedAtMs = v.parse(openClawCronTimestampSchema, now()); + const freshness = freshOpenClawCronSource(observedAtMs); + const ids = new Set(); + const jobs = new Map(); + let admittedResponseBytes = 0; + for (let index = 0; index < pageCount; index += 1) { + attemptController.signal.throwIfAborted(); + if (index > 0) { + currentPage = await readFreshHeartbeatPage( + index * openClawCronPageMaximum, + attemptController.signal + ); + } + const page = currentPage; + if (page === undefined) { + throw new OpenClawCronServiceError("provider-data-invalid"); + } + validateHeartbeatPage( + page, + index, + inspectedTotal, + snapshotRevision, + total, + ids + ); + if ( + page.responseBytes > + openClawCronHeartbeatInventoryMaximumBytes - admittedResponseBytes + ) { + throw new OpenClawCronHeartbeatInventoryBudgetError(); + } + admittedResponseBytes += page.responseBytes; + for (const job of page.jobs) { + const summary = projectOpenClawCronHeartbeatJobSummary( + job, + await getActiveIntent(job.id, attemptController.signal), + freshness, + observedAtMs + ); + jobs.set(summary.id, heartbeatJobProjection(summary)); + } + currentPage = undefined; + } + const summary = heartbeatSummary(jobs, total, observedAtMs); + return { + health: summary.health, + jobs, + observedAtMs, + pendingSync: summary.pendingSync, + total, + }; + } catch (error) { + if (signal.aborted) throw error; + const failure = serviceError(error); + lastFailure = failure; + if ( + attempt === 0 && + failure.reason === "provider-data-invalid" && + !(error instanceof OpenClawCronHeartbeatInventoryBudgetError) + ) { + continue; + } + throw failure; + } finally { + signal.removeEventListener("abort", abortAttempt); + attemptController.abort(); + } + } + throw serviceError(lastFailure); + } + + async function refreshHeartbeatProjection(): Promise { + for (;;) { + if (heartbeatDisposed) return; + const active = heartbeatRefreshPromise; + if (active === undefined) break; + await active; + } + let startedAtMonotonicMs: number; + try { + startedAtMonotonicMs = heartbeatMonotonicMs(); + } catch { + markHeartbeatProjectionStale(); + return; + } + if ( + heartbeatNextAttemptAtMonotonicMs !== undefined && + startedAtMonotonicMs < heartbeatNextAttemptAtMonotonicMs + ) { + return; + } + + const generation = heartbeatSnapshotGeneration; + const controller = new AbortController(); + heartbeatRefreshController = controller; + const timeout = setTimeout( + () => controller.abort(), + openClawCronHeartbeatRefreshTimeoutMs + ); + timeout.unref?.(); + const flight = (async () => { + try { + const candidate = await readFreshHeartbeatCandidate(controller.signal); + if (heartbeatDisposed || generation !== heartbeatSnapshotGeneration) { + return; + } + heartbeatJobProjections = candidate.jobs; + heartbeatProjection = Object.freeze({ + count: candidate.total, + health: candidate.health, + observedAtMs: candidate.observedAtMs, + pendingSync: candidate.pendingSync, + state: "fresh", + }); + heartbeatNextAttemptAtMonotonicMs = + heartbeatMonotonicMs() + openClawCronHeartbeatRefreshIntervalMs; + } catch { + if (!heartbeatDisposed && generation === heartbeatSnapshotGeneration) { + markHeartbeatProjectionStale(); + let completedAtMonotonicMs = startedAtMonotonicMs; + try { + completedAtMonotonicMs = heartbeatMonotonicMs(); + } catch { + // Retain a bounded gate from the last valid monotonic observation. + } + heartbeatNextAttemptAtMonotonicMs = + completedAtMonotonicMs + openClawCronHeartbeatFailureBackoffMs; + } + } finally { + clearTimeout(timeout); + if (heartbeatRefreshController === controller) { + heartbeatRefreshController = undefined; + heartbeatRefreshPromise = undefined; + } + } + })(); + heartbeatRefreshPromise = flight; + await flight; + } + + async function disposeHeartbeat(): Promise { + if (heartbeatDisposed) return; + heartbeatDisposed = true; + heartbeatSnapshotGeneration += 1; + heartbeatRefreshController?.abort(); + await heartbeatRefreshPromise; + } + async function recordOperationAudit( operation: OpenClawCronAuditOperation, settlement: OpenClawCronAuditSettlement, @@ -577,6 +974,7 @@ export function createOpenClawCronService( reason: "expired", }); signal?.throwIfAborted(); + if (closed) invalidateInventory(observedAtMs); return closed ? undefined : await getActiveIntent(job.id, signal); } @@ -643,10 +1041,20 @@ export function createOpenClawCronService( return { observedAtMs, value: job }; } - function invalidateInventory(): void { + function invalidateInventory( + candidateCheckedAtMs?: number, + pendingSync?: "present" | "unknown" + ): void { listCache.clear(); - committedHeartbeatProjectionGeneration = nextHeartbeatProjectionGeneration += 1; - markHeartbeatProjectionStale(); + invalidateHeartbeatProjection(candidateCheckedAtMs, pendingSync); + } + + function clearTargetCaches(id: string): void { + getCache.delete(id); + for (const key of runsCache.keys()) { + const decoded = JSON.parse(key) as readonly unknown[]; + if (decoded[0] === id) runsCache.delete(key); + } } async function list( @@ -655,9 +1063,6 @@ export function createOpenClawCronService( ): Promise { const parsed = parseInput(listOpenClawCronInputSchema, input); const key = listCacheKey(parsed); - const heartbeatGeneration = isGlobalInventory(parsed) - ? (nextHeartbeatProjectionGeneration += 1) - : undefined; signal?.throwIfAborted(); try { const page = await options.provider.list({ @@ -679,17 +1084,11 @@ export function createOpenClawCronService( for (const job of page.jobs) { getCache.set(job.id, { observedAtMs, value: job }); } - if (heartbeatGeneration !== undefined) { - rememberHeartbeatProjection(result, heartbeatGeneration); - } return result; } catch (error) { if (signal?.aborted) throw error; const cached = listCache.get(key); if (cached === undefined) { - if (heartbeatGeneration !== undefined) { - markHeartbeatProjectionStale(); - } throw serviceError(error); } const checkedAtMs = now(); @@ -700,9 +1099,6 @@ export function createOpenClawCronService( checkedAtMs, openLinkedTasks(cached.value.jobs) ); - if (heartbeatGeneration !== undefined) { - rememberHeartbeatProjection(result, heartbeatGeneration); - } return result; } } @@ -777,7 +1173,7 @@ export function createOpenClawCronService( throw ambiguousMutation(preflight.value.id, undefined, "present"); } const checkedAtMs = now(); - markHeartbeatProjectionStale(checkedAtMs, "present"); + invalidateInventory(checkedAtMs, "present"); return Promise.resolve( projectOpenClawCronGetResult( preflight.value, @@ -949,7 +1345,7 @@ export function createOpenClawCronService( if (readback.value.enabled !== parsed.enabled) { if (parsed.enabled) { - markHeartbeatProjectionStale( + invalidateInventory( undefined, previousIntent === undefined ? undefined : "present" ); @@ -1113,6 +1509,8 @@ export function createOpenClawCronService( error.reason === "not-found" ) { await closeDeletedTarget(parsed.id, actor, signal); + invalidateInventory(); + clearTargetCaches(parsed.id); return v.parse(deleteOpenClawCronResultSchema, { deleted: true, id: parsed.id, @@ -1160,11 +1558,7 @@ export function createOpenClawCronService( throw ambiguousMutation(parsed.id, error, removalPendingSync); } invalidateInventory(); - getCache.delete(parsed.id); - for (const key of runsCache.keys()) { - const decoded = JSON.parse(key) as readonly unknown[]; - if (decoded[0] === parsed.id) runsCache.delete(key); - } + clearTargetCaches(parsed.id); return v.parse(deleteOpenClawCronResultSchema, { deleted: true, id: parsed.id, @@ -1278,10 +1672,13 @@ export function createOpenClawCronService( return Object.freeze({ delete: auditedDelete, + disposeHeartbeat, get, list, listRuns, + readHeartbeatJobProjection, readHeartbeatProjection, + refreshHeartbeatProjection, reconcileExpired, run: auditedRun, setEnabled: auditedSetEnabled, diff --git a/greenfield/src/server/domains/system/healthDiagnosticsService.test.ts b/greenfield/src/server/domains/system/healthDiagnosticsService.test.ts new file mode 100644 index 000000000..f796d6613 --- /dev/null +++ b/greenfield/src/server/domains/system/healthDiagnosticsService.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, test } from "bun:test"; + +import { jobWorkerFreshnessMs } from "../../../contracts/jobModel.ts"; +import type { ReadinessState } from "../../platform/readiness/readinessState.ts"; +import type { GatewayConnectionService } from "../gatewayConnection/service.ts"; +import type { GatewaySessionsHeartbeatReader } from "../gatewaySessions/service.ts"; +import type { + JobHealthState, + JobHealthStateReader, + ReadJobHealthStateInput, +} from "../jobs/repository.ts"; +import { + createSystemHealthDiagnosticsService, + type SystemHealthDiagnosticsServiceDependencies, +} from "./healthDiagnosticsService.ts"; + +const checkedAtMs = 1_800_000_000_000; +const releaseId = "a".repeat(40); + +function healthState(overrides: Partial = {}): JobHealthState { + return { + control: { + claimingPaused: false, + id: 1, + updatedAt: new Date(0), + updatedById: null, + updatedByKind: null, + version: 1, + }, + oldestQueuedAt: new Date(checkedAtMs - 1000), + queuedRunCount: 1, + runningRunCount: 1, + workers: { + capacity: 2, + drainingCount: 0, + exactReleaseOnline: true, + freshCount: 1, + onlineCount: 1, + }, + ...overrides, + }; +} + +const connectedGateway = Object.freeze({ + get: () => ({ + checkedAtMs, + connectedAtMs: checkedAtMs - 1000, + connectionGeneration: 1, + freshness: "fresh" as const, + lastActivityAtMs: checkedAtMs, + phase: "connected" as const, + reconnectAttempt: 0, + }), +} satisfies Pick); + +const freshSessions = Object.freeze({ + readHeartbeatProjection: () => ({ + count: 2, + observedAtMs: checkedAtMs, + state: "fresh" as const, + truncated: false, + }), +} satisfies GatewaySessionsHeartbeatReader); + +function createReader( + state: JobHealthState, + onRead?: (input: ReadJobHealthStateInput) => void +): JobHealthStateReader { + return Object.freeze({ + readHealthState(input: ReadJobHealthStateInput) { + onRead?.(input); + return state; + }, + }); +} + +function readyDependencies( + overrides: Partial = {} +): SystemHealthDiagnosticsServiceDependencies { + return { + expectedWorkerReleaseId: releaseId, + frontendReady: true, + gatewayConnectionService: connectedGateway, + gatewaySessionsReader: freshSessions, + jobHealthReader: createReader(healthState()), + nowMs: () => checkedAtMs, + readiness: { isReady: () => true }, + ...overrides, + }; +} + +describe("system health diagnostics service", () => { + test("projects one ready identity-free snapshot at the exact heartbeat boundary", () => { + let observedInput: ReadJobHealthStateInput | undefined; + const service = createSystemHealthDiagnosticsService( + readyDependencies({ + jobHealthReader: createReader(healthState(), (input) => { + observedInput = input; + }), + }) + ); + + const diagnostics = service.read(); + + expect(observedInput).toEqual({ + expectedReleaseId: releaseId, + minimumHeartbeatAt: new Date(checkedAtMs - jobWorkerFreshnessMs), + }); + expect(diagnostics).toMatchObject({ + checkedAtMs, + checks: { + application: { status: "ready" }, + database: { status: "ready" }, + frontend: { status: "ready" }, + release: { status: "verified" }, + worker: { status: "ready" }, + }, + dependencies: { + gateway: { + freshness: "fresh", + phase: "connected", + status: "observed", + }, + sessions: { count: 2, state: "fresh" }, + }, + queue: { + claimingPaused: false, + runs: { queued: 1, running: 1 }, + status: "observed", + workers: { + capacity: 2, + drainingCount: 0, + freshCount: 1, + onlineCount: 1, + }, + }, + status: "ready", + }); + expect(JSON.stringify(diagnostics)).not.toContain(releaseId); + expect(Object.isFrozen(service)).toBe(true); + }); + + test("bounds the response after reads while retaining the start-time worker cutoff", () => { + let clockReads = 0; + let observedInput: ReadJobHealthStateInput | undefined; + const service = createSystemHealthDiagnosticsService( + readyDependencies({ + jobHealthReader: createReader( + healthState({ oldestQueuedAt: new Date(checkedAtMs + 1) }), + (input) => { + observedInput = input; + } + ), + nowMs: () => checkedAtMs + Math.min(clockReads++, 1), + }) + ); + + const diagnostics = service.read(); + + expect(observedInput).toEqual({ + expectedReleaseId: releaseId, + minimumHeartbeatAt: new Date(checkedAtMs - jobWorkerFreshnessMs), + }); + expect(diagnostics).toMatchObject({ + checkedAtMs: checkedAtMs + 1, + queue: { + oldestQueuedAtMs: checkedAtMs + 1, + status: "observed", + }, + status: "ready", + }); + }); + + test("keeps Gateway degradation, stale sessions, and paused claiming non-gating", () => { + const diagnostics = createSystemHealthDiagnosticsService( + readyDependencies({ + gatewayConnectionService: { + get: () => ({ + checkedAtMs, + connectionGeneration: 2, + freshness: "stale", + lastDisconnectedAtMs: checkedAtMs - 2000, + phase: "degraded", + reconnectAttempt: 2, + }), + }, + jobHealthReader: createReader( + healthState({ + control: { + claimingPaused: true, + id: 1, + updatedAt: new Date(checkedAtMs - 1000), + updatedById: "019fc968-1a9b-7770-8f1b-d5b863b0e7b4", + updatedByKind: "user", + version: 2, + }, + }) + ), + }) + ).read(); + + expect(diagnostics.status).toBe("ready"); + expect(diagnostics.dependencies.gateway).toEqual({ + freshness: "stale", + phase: "degraded", + status: "observed", + }); + expect(diagnostics.dependencies.sessions).toEqual({ + count: 2, + observedAtMs: checkedAtMs, + staleSinceMs: checkedAtMs, + state: "last-known-good", + truncated: false, + }); + expect(diagnostics.queue).toMatchObject({ claimingPaused: true }); + }); + + test("keeps each readiness blocker explicit in the aggregate", () => { + const applicationUnavailable = createSystemHealthDiagnosticsService( + readyDependencies({ readiness: { isReady: () => false } }) + ).read(); + const frontendUnavailable = createSystemHealthDiagnosticsService( + readyDependencies({ frontendReady: false }) + ).read(); + const workerMismatch = createSystemHealthDiagnosticsService( + readyDependencies({ + jobHealthReader: createReader( + healthState({ + workers: { + capacity: 2, + drainingCount: 1, + exactReleaseOnline: false, + freshCount: 1, + onlineCount: 0, + }, + }) + ), + }) + ).read(); + const releaseUnavailable = createSystemHealthDiagnosticsService({ + frontendReady: true, + gatewayConnectionService: connectedGateway, + gatewaySessionsReader: freshSessions, + jobHealthReader: createReader(healthState()), + nowMs: () => checkedAtMs, + readiness: { isReady: () => true }, + }).read(); + const invalidQueueProjection = createSystemHealthDiagnosticsService( + readyDependencies({ + jobHealthReader: createReader(healthState({ queuedRunCount: 0 })), + }) + ).read(); + const futureQueueProjection = createSystemHealthDiagnosticsService( + readyDependencies({ + jobHealthReader: createReader( + healthState({ oldestQueuedAt: new Date(checkedAtMs + 1) }) + ), + }) + ).read(); + + expect(applicationUnavailable).toMatchObject({ + checks: { application: { status: "not-ready" } }, + status: "not-ready", + }); + expect(frontendUnavailable).toMatchObject({ + checks: { frontend: { status: "unavailable" } }, + status: "not-ready", + }); + expect(workerMismatch).toMatchObject({ + checks: { worker: { status: "not-ready" } }, + status: "not-ready", + }); + expect(releaseUnavailable).toMatchObject({ + checks: { + release: { status: "unavailable" }, + worker: { status: "unavailable" }, + }, + status: "not-ready", + }); + expect(invalidQueueProjection).toMatchObject({ + checks: { + database: { status: "unavailable" }, + worker: { status: "unavailable" }, + }, + queue: { status: "unavailable" }, + status: "not-ready", + }); + expect(futureQueueProjection).toMatchObject({ + checks: { + database: { status: "unavailable" }, + worker: { status: "unavailable" }, + }, + queue: { status: "unavailable" }, + status: "not-ready", + }); + }); + + test("degrades component reader failures without exposing their diagnostics", () => { + const secret = "private dependency diagnostic"; + const throwingReadiness: ReadinessState = { + isReady: () => { + throw new Error(secret); + }, + }; + const diagnostics = createSystemHealthDiagnosticsService({ + expectedWorkerReleaseId: releaseId, + frontendReady: true, + gatewayConnectionService: { + get: () => { + throw new Error(secret); + }, + }, + gatewaySessionsReader: { + readHeartbeatProjection: () => { + throw new Error(secret); + }, + }, + jobHealthReader: { + readHealthState: () => { + throw new Error(secret); + }, + }, + nowMs: () => checkedAtMs, + readiness: throwingReadiness, + }).read(); + + expect(diagnostics).toMatchObject({ + checks: { + application: { status: "not-ready" }, + database: { status: "unavailable" }, + worker: { status: "unavailable" }, + }, + dependencies: { + gateway: { status: "unavailable" }, + sessions: { state: "unavailable" }, + }, + queue: { status: "unavailable" }, + status: "not-ready", + }); + expect(JSON.stringify(diagnostics)).not.toContain(secret); + }); +}); diff --git a/greenfield/src/server/domains/system/healthDiagnosticsService.ts b/greenfield/src/server/domains/system/healthDiagnosticsService.ts new file mode 100644 index 000000000..aeba431c1 --- /dev/null +++ b/greenfield/src/server/domains/system/healthDiagnosticsService.ts @@ -0,0 +1,255 @@ +import * as v from "valibot"; + +import { jobWorkerFreshnessMs } from "../../../contracts/jobModel.ts"; +import { + type SystemHealthDiagnostics, + systemHealthDiagnosticsGatewaySchema, + systemHealthDiagnosticsQueueSchema, + systemHealthDiagnosticsSchema, + systemHealthDiagnosticsSessionsSchema, +} from "../../../contracts/system.ts"; +import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; +import { fullCommitShaSchema } from "../../../shared/validation.ts"; +import type { ReadinessState } from "../../platform/readiness/readinessState.ts"; +import type { GatewayConnectionService } from "../gatewayConnection/service.ts"; +import type { GatewaySessionsHeartbeatReader } from "../gatewaySessions/service.ts"; +import type { JobHealthState, JobHealthStateReader } from "../jobs/repository.ts"; + +type GatewayDiagnostics = SystemHealthDiagnostics["dependencies"]["gateway"]; +type SessionsDiagnostics = SystemHealthDiagnostics["dependencies"]["sessions"]; +type QueueDiagnostics = SystemHealthDiagnostics["queue"]; +type WorkerCheck = SystemHealthDiagnostics["checks"]["worker"]; + +/** Request-safe detailed health reader used by the session-only system procedure. */ +export interface SystemHealthDiagnosticsService { + read(): SystemHealthDiagnostics; +} + +export interface SystemHealthDiagnosticsServiceDependencies { + readonly expectedWorkerReleaseId?: string; + readonly frontendReady: boolean; + readonly gatewayConnectionService: Pick; + readonly gatewaySessionsReader: GatewaySessionsHeartbeatReader; + readonly jobHealthReader: JobHealthStateReader; + readonly nowMs?: () => number; + readonly readiness: ReadinessState; +} + +const healthDiagnosticsClockSchema = timestampMillisecondsSchema( + "System health diagnostics clock is invalid" +); + +function readGatewayDiagnostics( + service: Pick +): GatewayDiagnostics { + try { + const snapshot = service.get(); + return v.parse(systemHealthDiagnosticsGatewaySchema, { + freshness: snapshot.freshness, + phase: snapshot.phase, + status: "observed", + }); + } catch { + return { status: "unavailable" }; + } +} + +function readSessionsProjection( + reader: GatewaySessionsHeartbeatReader +): SessionsDiagnostics { + try { + return v.parse( + systemHealthDiagnosticsSessionsSchema, + reader.readHeartbeatProjection() + ); + } catch { + return { state: "unavailable" }; + } +} + +function projectSessionsDiagnostics( + sessions: SessionsDiagnostics, + gateway: GatewayDiagnostics, + checkedAtMs: number +): SessionsDiagnostics { + try { + if ( + sessions.state !== "unavailable" && + (sessions.observedAtMs > checkedAtMs || + (sessions.state === "last-known-good" && + sessions.staleSinceMs > checkedAtMs)) + ) { + return { state: "unavailable" }; + } + if ( + sessions.state === "fresh" && + (gateway.status === "unavailable" || gateway.freshness !== "fresh") + ) { + return v.parse(systemHealthDiagnosticsSessionsSchema, { + ...sessions, + staleSinceMs: checkedAtMs, + state: "last-known-good", + }); + } + return sessions; + } catch { + return { state: "unavailable" }; + } +} + +function readHealthState( + reader: JobHealthStateReader, + startedAtMs: number, + expectedWorkerReleaseId: string | undefined +): JobHealthState | undefined { + try { + return reader.readHealthState({ + ...(expectedWorkerReleaseId === undefined + ? {} + : { expectedReleaseId: expectedWorkerReleaseId }), + minimumHeartbeatAt: new Date(Math.max(0, startedAtMs - jobWorkerFreshnessMs)), + }); + } catch { + return undefined; + } +} + +function projectQueue( + queue: JobHealthState | undefined, + checkedAtMs: number +): QueueDiagnostics { + if (queue === undefined) return { status: "unavailable" }; + try { + if ( + queue.oldestQueuedAt !== undefined && + queue.oldestQueuedAt.getTime() > checkedAtMs + ) { + return { status: "unavailable" }; + } + return v.parse(systemHealthDiagnosticsQueueSchema, { + claimingPaused: queue.control.claimingPaused, + ...(queue.oldestQueuedAt === undefined + ? {} + : { oldestQueuedAtMs: queue.oldestQueuedAt.getTime() }), + runs: { + queued: queue.queuedRunCount, + running: queue.runningRunCount, + }, + status: "observed", + workers: { + capacity: queue.workers.capacity, + drainingCount: queue.workers.drainingCount, + freshCount: queue.workers.freshCount, + onlineCount: queue.workers.onlineCount, + }, + }); + } catch { + return { status: "unavailable" }; + } +} + +function readWorkerCheck( + queue: JobHealthState | undefined, + expectedWorkerReleaseId: string | undefined +): WorkerCheck { + if (queue === undefined || expectedWorkerReleaseId === undefined) { + return { status: "unavailable" }; + } + if (queue.workers.exactReleaseOnline) { + return { status: "ready" }; + } + return { status: "not-ready" }; +} + +function readApplicationStatus(readiness: ReadinessState): "not-ready" | "ready" { + try { + return readiness.isReady() ? "ready" : "not-ready"; + } catch { + return "not-ready"; + } +} + +/** + * Projects live process, dependency, and queue state onto one bounded identity-free snapshot. + * Expected dependency failures are represented per component so diagnostics remain readable. + * @param dependencies Process-owned state readers and verified composition facts. + * @returns An immutable synchronous diagnostics service. + */ +export function createSystemHealthDiagnosticsService( + dependencies: SystemHealthDiagnosticsServiceDependencies +): SystemHealthDiagnosticsService { + const nowMs = dependencies.nowMs ?? Date.now; + const expectedWorkerReleaseId = + dependencies.expectedWorkerReleaseId === undefined + ? undefined + : v.parse( + fullCommitShaSchema("Expected worker release id is invalid"), + dependencies.expectedWorkerReleaseId + ); + + return Object.freeze({ + read(): SystemHealthDiagnostics { + const startedAtMs = v.parse(healthDiagnosticsClockSchema, nowMs()); + const queueState = readHealthState( + dependencies.jobHealthReader, + startedAtMs, + expectedWorkerReleaseId + ); + const gateway = readGatewayDiagnostics(dependencies.gatewayConnectionService); + const sessionsProjection = readSessionsProjection( + dependencies.gatewaySessionsReader + ); + const checkedAtMs = Math.max( + startedAtMs, + v.parse(healthDiagnosticsClockSchema, nowMs()) + ); + const queue = projectQueue(queueState, checkedAtMs); + const checks = { + application: { + status: readApplicationStatus(dependencies.readiness), + }, + database: { + status: + queue.status === "unavailable" + ? ("unavailable" as const) + : ("ready" as const), + }, + frontend: { + status: dependencies.frontendReady + ? ("ready" as const) + : ("unavailable" as const), + }, + release: { + status: + expectedWorkerReleaseId === undefined + ? ("unavailable" as const) + : ("verified" as const), + }, + worker: + queue.status === "unavailable" + ? ({ status: "unavailable" } as const) + : readWorkerCheck(queueState, expectedWorkerReleaseId), + }; + const ready = + checks.application.status === "ready" && + checks.database.status === "ready" && + checks.frontend.status === "ready" && + checks.release.status === "verified" && + checks.worker.status === "ready"; + return v.parse(systemHealthDiagnosticsSchema, { + checkedAtMs, + checks, + dependencies: { + gateway, + sessions: projectSessionsDiagnostics( + sessionsProjection, + gateway, + checkedAtMs + ), + }, + queue, + status: ready ? "ready" : "not-ready", + }); + }, + }); +} diff --git a/greenfield/src/server/domains/system/procedures.test.ts b/greenfield/src/server/domains/system/procedures.test.ts index 4eb9949f3..b44082ddc 100644 --- a/greenfield/src/server/domains/system/procedures.test.ts +++ b/greenfield/src/server/domains/system/procedures.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { TRPCError } from "@trpc/server"; -import type { SystemMetrics } from "../../../contracts/system.ts"; +import type { + SystemHealthDiagnostics, + SystemMetrics, +} from "../../../contracts/system.ts"; import { captureFailure } from "../../test/support/promise.ts"; import { createTestApplicationRuntime, @@ -11,6 +14,7 @@ import { createTestSessionAuthentication, } from "../../test/support/requestContext.ts"; import { appRouter } from "../../trpc/appRouter.ts"; +import type { SystemHealthDiagnosticsService } from "./healthDiagnosticsService.ts"; import { SystemMetricsUnavailableError, type SystemMetricsRuntimeService, @@ -44,19 +48,74 @@ const metrics = Object.freeze({ uptimeSeconds: 12, } as const satisfies SystemMetrics); +const healthDiagnostics = Object.freeze({ + checkedAtMs: 1_800_000_000_000, + checks: { + application: { status: "not-ready" }, + database: { status: "unavailable" }, + frontend: { status: "ready" }, + release: { status: "verified" }, + worker: { status: "unavailable" }, + }, + dependencies: { + gateway: { status: "unavailable" }, + sessions: { state: "unavailable" }, + }, + queue: { status: "unavailable" }, + status: "not-ready", +} as const satisfies SystemHealthDiagnostics); + async function caller( authentication = createTestSessionAuthentication([]), systemMetrics: SystemMetricsRuntimeService = Object.freeze({ read: () => Promise.resolve(metrics), + }), + systemHealthDiagnosticsService: SystemHealthDiagnosticsService = Object.freeze({ + read: () => healthDiagnostics, }) ) { const context = await createTestRequestContext( authentication, - createTestApplicationRuntime({ systemMetrics }) + createTestApplicationRuntime({ systemMetrics }), + { systemHealthDiagnosticsService } ); return appRouter.createCaller(context).system; } +describe("system health diagnostics procedure", () => { + test("returns degraded diagnostics successfully to a browser session", async () => { + const system = await caller(); + + expect(await system.healthDiagnostics()).toEqual(healthDiagnostics); + }); + + test("rejects anonymous and automation principals before reading health", async () => { + let readCount = 0; + const service = Object.freeze({ + read: () => { + readCount += 1; + return healthDiagnostics; + }, + }); + const anonymous = await caller({ kind: "anonymous" }, undefined, service); + const automation = await caller( + createTestAutomationAuthentication([]), + undefined, + service + ); + const anonymousFailure = await captureFailure(() => + anonymous.healthDiagnostics() + ); + const automationFailure = await captureFailure(() => + automation.healthDiagnostics() + ); + + expect(anonymousFailure).toMatchObject({ code: "UNAUTHORIZED" }); + expect(automationFailure).toMatchObject({ code: "FORBIDDEN" }); + expect(readCount).toBe(0); + }); +}); + describe("system metrics procedure", () => { test("returns the runtime snapshot to an authenticated browser session", async () => { const system = await caller(); diff --git a/greenfield/src/server/domains/system/procedures.ts b/greenfield/src/server/domains/system/procedures.ts index b7dbbc339..37d1331f5 100644 --- a/greenfield/src/server/domains/system/procedures.ts +++ b/greenfield/src/server/domains/system/procedures.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import { runtimeIdentityContract, + systemHealthDiagnosticsContract, systemMetricsContract, } from "../../../contracts/system.ts"; import { readRuntimeIdentity } from "../../platform/runtime/readRuntimeIdentity.ts"; @@ -9,6 +10,10 @@ import { publicProcedure, router, sessionProcedure } from "../../trpc/trpc.ts"; import { SystemMetricsUnavailableError } from "./systemMetricsService.ts"; const systemRoutes = { + healthDiagnostics: sessionProcedure + .input(systemHealthDiagnosticsContract.input) + .output(systemHealthDiagnosticsContract.output) + .query(({ ctx }) => ctx.systemHealthDiagnosticsService.read()), metrics: sessionProcedure .input(systemMetricsContract.input) .output(systemMetricsContract.output) @@ -33,5 +38,5 @@ const systemRoutes = { /** Leaf procedure names owned by the system-router composition. */ export const systemProcedureNames = Object.freeze(Object.keys(systemRoutes)); -/** Public identity and session-only system metric procedures. */ +/** Public identity plus session-only health and system metric procedures. */ export const systemRouter = router(systemRoutes); diff --git a/greenfield/src/server/domains/tasks/heartbeatPolicy.ts b/greenfield/src/server/domains/tasks/heartbeatPolicy.ts new file mode 100644 index 000000000..76ea04408 --- /dev/null +++ b/greenfield/src/server/domains/tasks/heartbeatPolicy.ts @@ -0,0 +1,18 @@ +import { + taskAssigneeIds, + type TaskPriority, + type TaskStatus, +} from "../../../contracts/taskModel.ts"; + +/** Product policy selecting priority work assigned to the Dashboard agent. */ +export const taskHeartbeatAgentAssignee = + "mira-2026" as const satisfies (typeof taskAssigneeIds)[number]; +export const taskHeartbeatAgentPriorities = [ + "medium", + "high", +] as const satisfies readonly TaskPriority[]; + +/** Product policy selecting owner-blocked work that needs operator attention. */ +export const taskHeartbeatOwnerAssignee = + "rajohan" as const satisfies (typeof taskAssigneeIds)[number]; +export const taskHeartbeatOwnerStatus = "blocked" as const satisfies TaskStatus; diff --git a/greenfield/src/server/domains/tasks/repository.ts b/greenfield/src/server/domains/tasks/repository.ts index b35bd2331..b52a78a84 100644 --- a/greenfield/src/server/domains/tasks/repository.ts +++ b/greenfield/src/server/domains/tasks/repository.ts @@ -46,6 +46,8 @@ export function createTaskRepository( withReadTransaction((reader) => reader.listTaskProgress(input)), listOpenTasksByCronJobIds: (cronJobIds: readonly string[]) => withReadTransaction((reader) => reader.listOpenTasksByCronJobIds(cronJobIds)), + readHeartbeatCandidates: () => + withReadTransaction((reader) => reader.readHeartbeatCandidates()), listTasks: (input: ListTasksInput) => withReadTransaction((reader) => reader.listTasks(input)), withImmediateTransaction( diff --git a/greenfield/src/server/domains/tasks/repositoryReader.test.ts b/greenfield/src/server/domains/tasks/repositoryReader.test.ts index 5249556c1..a0bdb0ec6 100644 --- a/greenfield/src/server/domains/tasks/repositoryReader.test.ts +++ b/greenfield/src/server/domains/tasks/repositoryReader.test.ts @@ -6,6 +6,10 @@ import { testImmediateDatabaseWriteAdmission } from "../../test/support/database import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; import { createTaskRepository } from "./repository.ts"; +function uuid(index: number): string { + return `019fd984-63e8-7404-a7da-${String(index).padStart(12, "0")}`; +} + describe("task repository cron projection", () => { test("returns only exact unfinished task relationships in one bounded read", async () => { const database = await openFreshMigratedDatabase(); @@ -71,4 +75,86 @@ describe("task repository cron projection", () => { database.sqlite.close(true); } }); + + test("reads the exact bounded heartbeat policy in canonical order", async () => { + const database = await openFreshMigratedDatabase(); + try { + const policyRows = [ + { assignee: "mira-2026", priority: "medium", status: "todo" }, + { assignee: "rajohan", priority: "low", status: "blocked" }, + { assignee: "mira-2026", priority: "low", status: "todo" }, + { assignee: "rajohan", priority: "high", status: "in-progress" }, + { assignee: "mira-2026", priority: "high", status: "done" }, + { assignee: undefined, priority: "high", status: "done" }, + ] as const; + database.orm + .insert(tasks) + .values([ + ...policyRows.map((row, index) => ({ + ...(row.assignee === undefined ? {} : { assignee: row.assignee }), + createdAt: new Date(1000), + id: uuid(index), + priority: row.priority, + status: row.status, + title: `Private policy task ${index}`, + updatedAt: new Date(1000), + })), + ...Array.from({ length: 101 }, (_, offset) => ({ + createdAt: new Date(1000), + id: uuid(offset + 6), + priority: "low" as const, + status: "todo" as const, + title: `Private automation task ${offset}`, + updatedAt: new Date(1000), + })), + ]) + .run(); + database.orm + .insert(taskAutomationProfiles) + .values( + Array.from({ length: 101 }, (_, offset) => ({ + cronJobId: `private-cron-${offset}`, + kind: "openclaw-cron" as const, + recurring: offset % 2 === 0, + taskId: uuid(offset + 6), + })) + ) + .run(); + const repository = createTaskRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); + + const snapshot = repository.readHeartbeatCandidates(); + expect(snapshot.totalCount).toBe(103); + expect(snapshot.rows).toHaveLength(100); + expect(snapshot.rows.slice(0, 3)).toEqual([ + { + assignee: "mira-2026", + id: uuid(0), + priority: "medium", + status: "todo", + }, + { + assignee: "rajohan", + id: uuid(1), + priority: "low", + status: "blocked", + }, + { + automation: { + cronJobId: "private-cron-0", + recurring: true, + }, + id: uuid(6), + priority: "low", + status: "todo", + }, + ]); + expect(snapshot.rows.at(-1)?.id).toBe(uuid(103)); + expect(JSON.stringify(snapshot)).not.toContain("Private"); + } finally { + database.sqlite.close(true); + } + }); }); diff --git a/greenfield/src/server/domains/tasks/repositoryReader.ts b/greenfield/src/server/domains/tasks/repositoryReader.ts index ec10a8645..65af3d595 100644 --- a/greenfield/src/server/domains/tasks/repositoryReader.ts +++ b/greenfield/src/server/domains/tasks/repositoryReader.ts @@ -2,6 +2,7 @@ import { toDate } from "date-fns"; import { and, asc, + count, desc, eq, exists, @@ -26,6 +27,12 @@ import { taskAutomationProfiles } from "../../database/schema/taskAutomationProf import { taskLabels } from "../../database/schema/taskLabels.ts"; import { tasks } from "../../database/schema/tasks.ts"; import { taskUpdates } from "../../database/schema/taskUpdates.ts"; +import { + taskHeartbeatAgentAssignee, + taskHeartbeatAgentPriorities, + taskHeartbeatOwnerAssignee, + taskHeartbeatOwnerStatus, +} from "./heartbeatPolicy.ts"; import { parseTaskAutomationProfileRecord, parseTaskLabelRecord, @@ -34,12 +41,15 @@ import { } from "./repositoryRecords.ts"; import type { TaskAggregateRecord, + TaskHeartbeatCandidateSnapshot, TaskOpenCronLinkRecord, TaskPersistenceDatabase, TaskRecord, TaskRepositoryReader, } from "./repositoryTypes.ts"; +const taskHeartbeatCandidateMaximum = 100; + function assertPageLimit(limit: number, maximum: number): void { if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { throw new RangeError("Task repository page limit is invalid"); @@ -250,6 +260,69 @@ export class DrizzleTaskRepositoryReader implements TaskRepositoryReader { })); } + public readHeartbeatCandidates(): TaskHeartbeatCandidateSnapshot { + const linkedAutomation = exists( + this.database + .select({ taskId: taskAutomationProfiles.taskId }) + .from(taskAutomationProfiles) + .where(eq(taskAutomationProfiles.taskId, tasks.id)) + ); + const relevance = and( + ne(tasks.status, "done"), + or( + linkedAutomation, + and( + eq(tasks.assignee, taskHeartbeatAgentAssignee), + inArray(tasks.priority, [...taskHeartbeatAgentPriorities]) + ), + and( + eq(tasks.assignee, taskHeartbeatOwnerAssignee), + eq(tasks.status, taskHeartbeatOwnerStatus) + ) + ) + ); + const totalCount = this.database + .select({ value: count() }) + .from(tasks) + .where(relevance) + .get()?.value; + if (totalCount === undefined) { + throw new Error("Task heartbeat count returned no row"); + } + const rows = this.database + .select({ + assignee: tasks.assignee, + automationCronJobId: taskAutomationProfiles.cronJobId, + automationRecurring: taskAutomationProfiles.recurring, + id: tasks.id, + priority: tasks.priority, + status: tasks.status, + }) + .from(tasks) + .leftJoin(taskAutomationProfiles, eq(taskAutomationProfiles.taskId, tasks.id)) + .where(relevance) + .orderBy(asc(tasks.id)) + .limit(taskHeartbeatCandidateMaximum) + .all(); + return { + rows: rows.map( + ({ assignee, automationCronJobId, automationRecurring, ...task }) => ({ + ...task, + ...(assignee === null ? {} : { assignee }), + ...(automationCronJobId === null || automationRecurring === null + ? {} + : { + automation: { + cronJobId: automationCronJobId, + recurring: automationRecurring, + }, + }), + }) + ), + totalCount, + }; + } + public listTasks(input: ListTasksInput): TaskAggregateRecord[] { assertPageLimit(input.limit, taskPageMaximum); const rows = this.database diff --git a/greenfield/src/server/domains/tasks/repositoryTypes.ts b/greenfield/src/server/domains/tasks/repositoryTypes.ts index 1b333d6da..531051dd3 100644 --- a/greenfield/src/server/domains/tasks/repositoryTypes.ts +++ b/greenfield/src/server/domains/tasks/repositoryTypes.ts @@ -60,6 +60,25 @@ export interface TaskOpenCronLinkRecord { readonly task: TaskRecord; } +/** Minimal task row used only by the cache-read heartbeat declassification. */ +export interface TaskHeartbeatCandidateRecord { + readonly assignee?: NonNullable; + readonly automation?: { + /** Internal correlation key; the heartbeat response never exposes it. */ + readonly cronJobId: string; + readonly recurring: boolean; + }; + readonly id: TaskRecord["id"]; + readonly priority: TaskRecord["priority"]; + readonly status: TaskRecord["status"]; +} + +/** Exact heartbeat-relevant task count plus its bounded canonical prefix. */ +export interface TaskHeartbeatCandidateSnapshot { + readonly rows: readonly TaskHeartbeatCandidateRecord[]; + readonly totalCount: number; +} + export interface VersionedTaskMutationInput { readonly changes: TaskMutableUpdate; readonly expectedVersion: number; @@ -82,6 +101,7 @@ export interface TaskRepositoryReader { listTaskProgress(input: ListTaskProgressInput): TaskProgressRecord[]; listTasks(input: ListTasksInput): TaskAggregateRecord[]; listOpenTasksByCronJobIds(cronJobIds: readonly string[]): TaskOpenCronLinkRecord[]; + readHeartbeatCandidates(): TaskHeartbeatCandidateSnapshot; } /** Synchronous writes owned by one admitted SQLite IMMEDIATE transaction. */ diff --git a/greenfield/src/server/platform/configuration/configurationRegistry.test.ts b/greenfield/src/server/platform/configuration/configurationRegistry.test.ts index e628f90aa..791814dc7 100644 --- a/greenfield/src/server/platform/configuration/configurationRegistry.test.ts +++ b/greenfield/src/server/platform/configuration/configurationRegistry.test.ts @@ -19,6 +19,8 @@ describe("application configuration registry", () => { "MIRA_DASHBOARD_PUBLIC_ORIGIN", "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", "ELEVENLABS_API_KEY", + "MOLTBOOK_API_KEY", + "MOLTBOOK_AGENT_NAME", "OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_URL", "MIRA_DASHBOARD_WEBAUTHN_RP_ID", @@ -29,7 +31,7 @@ describe("application configuration registry", () => { "MIRA_DASHBOARD_TOTP_KEYRING", "MIRA_DASHBOARD_LOG_LEVEL", ]); - expect(applicationConfigurationRegistry).toHaveLength(17); + expect(applicationConfigurationRegistry).toHaveLength(19); expect( applicationConfigurationRegistry .map((entry) => entry.environmentName) @@ -74,6 +76,7 @@ describe("application configuration registry", () => { .map((entry) => entry.environmentName) ).toEqual([ "ELEVENLABS_API_KEY", + "MOLTBOOK_API_KEY", "OPENCLAW_GATEWAY_TOKEN", "MIRA_DASHBOARD_TOTP_KEYRING", ]); @@ -90,6 +93,8 @@ describe("application configuration registry", () => { ).toEqual({ ELEVENLABS_API_KEY: "elevenLabsApiKey", MIRA_DASHBOARD_LOG_LEVEL: "logLevel", + MOLTBOOK_AGENT_NAME: "moltbookAgentName", + MOLTBOOK_API_KEY: "moltbookApiKey", MIRA_DASHBOARD_OPENCLAW_ROOT: "openClawRoot", MIRA_DASHBOARD_PROJECT_ROOT: "projectRoot", MIRA_DASHBOARD_PUBLIC_ORIGIN: "publicOrigin", @@ -133,11 +138,14 @@ describe("application configuration registry", () => { "MIRA_DASHBOARD_PROJECT_ROOT", "MIRA_DASHBOARD_OPENCLAW_ROOT", "MIRA_DASHBOARD_WORKSPACE_ROOT", + "MOLTBOOK_API_KEY", + "MOLTBOOK_AGENT_NAME", "OPENCLAW_GATEWAY_URL", "OPENCLAW_GATEWAY_TOKEN", "MIRA_DASHBOARD_LOG_LEVEL", ]); expect(workerEnvironment).toHaveProperty("OPENCLAW_GATEWAY_TOKEN"); + expect(workerEnvironment).toHaveProperty("MOLTBOOK_API_KEY"); expect(workerEnvironment).not.toHaveProperty("MIRA_DASHBOARD_TOTP_KEYRING"); expect(workerEnvironment).not.toHaveProperty("ELEVENLABS_API_KEY"); }); diff --git a/greenfield/src/server/platform/configuration/moltbookConfiguration.ts b/greenfield/src/server/platform/configuration/moltbookConfiguration.ts new file mode 100644 index 000000000..66967cfd1 --- /dev/null +++ b/greenfield/src/server/platform/configuration/moltbookConfiguration.ts @@ -0,0 +1,38 @@ +import { Redacted } from "effect"; + +import { applicationConfigurationLimits } from "../../../shared/configuration/applicationConfigurationRegistry.ts"; +import { + type PickedApplicationEnvironment, + requiredConfigurationString, +} from "./processConfiguration.ts"; + +/** + * Parses the worker-only Moltbook API credential into a non-inspectable value. + * @param input Registry-projected worker configuration. + * @returns Frozen redacted Moltbook credential. + */ +export function configurationMoltbookApiKey( + input: PickedApplicationEnvironment +): Redacted.Redacted { + const raw = requiredConfigurationString( + input, + "MOLTBOOK_API_KEY", + applicationConfigurationLimits.moltbookApiKeyMaximumLength + ); + return Object.freeze(Redacted.make(raw, { label: "moltbook-api-key" })); +} + +/** + * Parses the profile identity later encoded into the one fixed provider URL. + * @param input Registry-projected worker configuration. + * @returns Validated Moltbook agent identity. + */ +export function configurationMoltbookAgentName( + input: PickedApplicationEnvironment +): string { + return requiredConfigurationString( + input, + "MOLTBOOK_AGENT_NAME", + applicationConfigurationLimits.moltbookAgentNameMaximumLength + ); +} diff --git a/greenfield/src/server/platform/configuration/workerConfiguration.test.ts b/greenfield/src/server/platform/configuration/workerConfiguration.test.ts index a8f881a21..f76651933 100644 --- a/greenfield/src/server/platform/configuration/workerConfiguration.test.ts +++ b/greenfield/src/server/platform/configuration/workerConfiguration.test.ts @@ -16,6 +16,7 @@ function validEnvironment(): Record { MIRA_DASHBOARD_OPENCLAW_ROOT: "/srv/openclaw", MIRA_DASHBOARD_PROJECT_ROOT: "/srv/mira-dashboard", MIRA_DASHBOARD_WORKSPACE_ROOT: "/srv/mira-workspace", + MOLTBOOK_API_KEY: "worker-moltbook-key-test-value", NODE_ENV: "production", OPENCLAW_GATEWAY_TOKEN: "worker-gateway-token-test-value", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", @@ -43,6 +44,7 @@ describe("worker application configuration", () => { expect(configuration).toMatchObject({ gatewayUrl: "ws://127.0.0.1:18789/", logLevel: "info", + moltbookAgentName: "mira_2026", nodeEnvironment: "production", openClawRoot: "/srv/openclaw", projectRoot: "/srv/mira-dashboard", @@ -54,12 +56,23 @@ describe("worker application configuration", () => { expect(JSON.stringify(configuration.gatewayToken)).toBe( '""' ); + expect(Redacted.value(configuration.moltbookApiKey)).toBe( + "worker-moltbook-key-test-value" + ); + expect(JSON.stringify(configuration.moltbookApiKey)).toBe( + '""' + ); expect(JSON.stringify(configuration)).not.toContain( "worker-gateway-token-test-value" ); + expect(JSON.stringify(configuration)).not.toContain( + "worker-moltbook-key-test-value" + ); expect(inspect(configuration)).not.toContain("worker-gateway-token-test-value"); + expect(inspect(configuration)).not.toContain("worker-moltbook-key-test-value"); expect(Object.isFrozen(configuration)).toBe(true); expect(Object.isFrozen(configuration.gatewayToken)).toBe(true); + expect(Object.isFrozen(configuration.moltbookApiKey)).toBe(true); }); test("observes only the worker registry projection", () => { @@ -105,6 +118,7 @@ describe("worker application configuration", () => { const secret = "worker-secret-sentinel"; for (const [field, value, reason] of [ ["OPENCLAW_GATEWAY_TOKEN", ` ${secret}`, "invalid"], + ["MOLTBOOK_API_KEY", ` ${secret}`, "invalid"], ["OPENCLAW_GATEWAY_URL", `ws://${secret}.example`, "invalid"], ] as const) { const environment = validEnvironment(); @@ -131,6 +145,8 @@ describe("worker application configuration", () => { ["MIRA_DASHBOARD_OPENCLAW_ROOT", undefined, "missing"], ["MIRA_DASHBOARD_WORKSPACE_ROOT", "relative", "invalid"], ["MIRA_DASHBOARD_WORKSPACE_ROOT", undefined, "missing"], + ["MOLTBOOK_AGENT_NAME", " mira_2026", "invalid"], + ["MOLTBOOK_API_KEY", undefined, "missing"], ["OPENCLAW_GATEWAY_TOKEN", undefined, "missing"], ["OPENCLAW_GATEWAY_URL", "wss://gateway.example.com", "invalid"], ] as const) { diff --git a/greenfield/src/server/platform/configuration/workerConfiguration.ts b/greenfield/src/server/platform/configuration/workerConfiguration.ts index 7ab16b1b0..1dc4efa3c 100644 --- a/greenfield/src/server/platform/configuration/workerConfiguration.ts +++ b/greenfield/src/server/platform/configuration/workerConfiguration.ts @@ -6,6 +6,10 @@ import { configurationGatewayToken, configurationGatewayUrl, } from "./gatewayConfiguration.ts"; +import { + configurationMoltbookAgentName, + configurationMoltbookApiKey, +} from "./moltbookConfiguration.ts"; import { configurationChoice, configurationOpenClawRoot, @@ -21,6 +25,8 @@ export interface WorkerConfiguration { readonly gatewayToken: Redacted.Redacted; readonly gatewayUrl: string; readonly logLevel: ApplicationLogLevel; + readonly moltbookAgentName: string; + readonly moltbookApiKey: Redacted.Redacted; readonly nodeEnvironment: ApplicationNodeEnvironment; readonly openClawRoot: string; readonly projectRoot: string; @@ -35,6 +41,8 @@ export const workerConfigurationEnvironmentSchema = v.object({ MIRA_DASHBOARD_OPENCLAW_ROOT: optionalEnvironmentValueSchema, MIRA_DASHBOARD_PROJECT_ROOT: optionalEnvironmentValueSchema, MIRA_DASHBOARD_WORKSPACE_ROOT: optionalEnvironmentValueSchema, + MOLTBOOK_AGENT_NAME: optionalEnvironmentValueSchema, + MOLTBOOK_API_KEY: optionalEnvironmentValueSchema, NODE_ENV: optionalEnvironmentValueSchema, OPENCLAW_GATEWAY_TOKEN: optionalEnvironmentValueSchema, OPENCLAW_GATEWAY_URL: optionalEnvironmentValueSchema, @@ -67,6 +75,8 @@ export function parseWorkerConfiguration( "info", "warn", ] as const), + moltbookAgentName: configurationMoltbookAgentName(input), + moltbookApiKey: configurationMoltbookApiKey(input), nodeEnvironment: configurationChoice(input, "NODE_ENV", [ "development", "production", diff --git a/greenfield/src/server/platform/gateway/persistentGatewayTransport.test.ts b/greenfield/src/server/platform/gateway/persistentGatewayTransport.test.ts index 19afb103f..057d023cd 100644 --- a/greenfield/src/server/platform/gateway/persistentGatewayTransport.test.ts +++ b/greenfield/src/server/platform/gateway/persistentGatewayTransport.test.ts @@ -470,20 +470,31 @@ describe("persistent native Gateway transport", () => { ]); expect(JSON.stringify(events)).not.toContain(secretShapedEventPayload); - const result = transport.request("sessions.list", { limit: 20 }); + let responseBytes: number | undefined; + const result = transport.request( + "sessions.list", + { limit: 20 }, + { + onResponseBytes: (candidate) => { + responseBytes = candidate; + }, + } + ); const request = sentFrame(socket, 1); expect(request).toMatchObject({ method: "sessions.list", params: { limit: 20 }, type: "req", }); - socket.receive({ + const encodedResponse = ` \n${JSON.stringify({ id: request.id, ok: true, payload: { sessions: [] }, type: "res", - }); + })}\t`; + socket.receiveRaw(encodedResponse); expect(await result).toEqual({ sessions: [] }); + expect(responseBytes).toBe(Buffer.byteLength(encodedResponse, "utf8")); expect(states.map((snapshot) => snapshot.phase)).toContain("connected"); await stopConnected(transport, socket); diff --git a/greenfield/src/server/platform/gateway/persistentGatewayTransport.ts b/greenfield/src/server/platform/gateway/persistentGatewayTransport.ts index 4a5a27f62..b4d9a2a57 100644 --- a/greenfield/src/server/platform/gateway/persistentGatewayTransport.ts +++ b/greenfield/src/server/platform/gateway/persistentGatewayTransport.ts @@ -200,6 +200,8 @@ export interface PersistentGatewayListener { } export interface PersistentGatewayRequestOptions { + /** Receives the exact authenticated response-frame byte count before payload projection. */ + readonly onResponseBytes?: (responseBytes: number) => void; readonly signal?: AbortSignal; readonly timeoutMs?: number; } @@ -364,6 +366,7 @@ interface ResolvedPersistentGatewayOptions { interface PendingRequest { readonly method: string; + readonly onResponseBytes?: (responseBytes: number) => void; readonly reject: (error: Error) => void; readonly resolve: (payload: unknown) => void; readonly signal?: AbortSignal; @@ -928,6 +931,7 @@ class GatewaySocketLane { return new Promise((resolve, reject) => { const pending: PendingRequest = { method, + onResponseBytes: options.onResponseBytes, reject, resolve, signal: options.signal, @@ -1282,7 +1286,12 @@ class GatewaySocketLane { this.#stage === "awaiting-challenge" ? persistentGatewayChallengeFrameMaximumBytes : persistentGatewayAuthenticatedFrameMaximumBytes; - if (typeof event.data !== "string" || byteLength(event.data) > maximumBytes) { + if (typeof event.data !== "string") { + this.#fail("protocol", false, policyCloseCode, "invalid gateway frame"); + return; + } + const responseBytes = byteLength(event.data); + if (responseBytes > maximumBytes) { this.#fail("protocol", false, policyCloseCode, "invalid gateway frame"); return; } @@ -1357,7 +1366,7 @@ class GatewaySocketLane { const response = parsePersistentGatewayResponse(decoded); if (response !== undefined) { this.#markActivity(); - this.#settleResponse(response); + this.#settleResponse(response, responseBytes); return; } if (this.#onAuthenticatedEvent(decoded, true)) return; @@ -1477,7 +1486,8 @@ class GatewaySocketLane { } #settleResponse( - response: NonNullable> + response: NonNullable>, + responseBytes: number ): void { const pending = this.#pending.get(response.id); if (pending === undefined) { @@ -1488,6 +1498,11 @@ class GatewaySocketLane { this.#pending.delete(response.id); this.#cleanupPending(pending); if (response.ok) { + try { + pending.onResponseBytes?.(responseBytes); + } catch { + // Byte observation is internal bookkeeping and cannot replace a response. + } pending.resolve(response.payload); return; } diff --git a/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.test.ts b/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.test.ts index dd978a2e5..a234e91dc 100644 --- a/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.test.ts +++ b/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; import { openClawCronPageMaximum } from "../../../contracts/openClawCron.ts"; +import { createInMemoryOpenClawCronIntentStore } from "../../domains/openClawCron/intentStore.ts"; import { OpenClawCronProviderError } from "../../domains/openClawCron/provider.ts"; +import { + createOpenClawCronService, + openClawCronHeartbeatInventoryMaximumBytes, +} from "../../domains/openClawCron/service.ts"; import type { PersistentGatewayAdminMethod, PersistentGatewayReadWriteMethod, @@ -80,9 +85,19 @@ class TestPersistentOpenClawCronTransport implements PersistentOpenClawCronTrans ); } response.onRespond?.(); - return response.value instanceof Error - ? Promise.reject(response.value) - : Promise.resolve(response.value); + if (response.value instanceof Error) return Promise.reject(response.value); + const encoded = JSON.stringify({ + id: "fixture-response", + ok: true, + payload: response.value, + type: "res", + }); + try { + request.options?.onResponseBytes?.(Buffer.byteLength(encoded, "utf8")); + } catch { + // Mirrors transport bookkeeping isolation. + } + return Promise.resolve(response.value); } } @@ -234,6 +249,7 @@ describe("persistent OpenClaw cron provider", () => { lane: "persistent", method: "cron.list", options: { + onResponseBytes: expect.any(Function), signal: abortController.signal, timeoutMs: persistentOpenClawCronReadTimeoutMs, }, @@ -258,6 +274,7 @@ describe("persistent OpenClaw cron provider", () => { offset: 0, total: 2, }); + expect(page.responseBytes).toBeGreaterThan(0); expect(page.jobs[0]).toEqual({ agentId: "main", configRevision: "definition-revision-1", @@ -312,6 +329,65 @@ describe("persistent OpenClaw cron provider", () => { expect(Object.isFrozen(page.jobs)).toBe(true); }); + test("budgets raw response frames before unknown job fields are stripped", async () => { + const transport = new TestPersistentOpenClawCronTransport(); + const padding = "x".repeat( + Math.floor(openClawCronHeartbeatInventoryMaximumBytes / 2) + 1024 + ); + const jobs = Array.from({ length: 201 }, (_, index) => + upstreamJob(`cron-job-${String(index).padStart(3, "0")}`) + ); + const firstJobs = jobs.slice(0, 100); + firstJobs[0] = upstreamJob("cron-job-000", { ignoredPadding: padding }); + const secondJobs = jobs.slice(100, 200); + secondJobs[0] = upstreamJob("cron-job-100", { ignoredPadding: padding }); + queue( + transport, + "cron.list", + listPage(firstJobs, { + hasMore: true, + limit: 100, + nextOffset: 100, + total: jobs.length, + }) + ); + queue( + transport, + "cron.list", + listPage(secondJobs, { + hasMore: true, + limit: 100, + nextOffset: 200, + offset: 100, + total: jobs.length, + }) + ); + queue( + transport, + "cron.list", + listPage(jobs.slice(200), { + limit: 100, + offset: 200, + total: jobs.length, + }) + ); + const service = createOpenClawCronService({ + auditRequired: false, + intentStore: createInMemoryOpenClawCronIntentStore(), + provider: createPersistentOpenClawCronProvider(transport), + }); + + await service.refreshHeartbeatProjection(); + + expect(transport.calls.map(({ parameters }) => parameters.offset)).toEqual([ + 0, 100, + ]); + expect(service.readHeartbeatProjection()).toEqual({ + pendingSync: "unknown", + state: "unavailable", + }); + }); + test("keeps broad read-only metadata complete and deep-freezes command arrays", async () => { const transport = new TestPersistentOpenClawCronTransport(); const cronExpression = "e".repeat(300); diff --git a/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.ts b/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.ts index 8fe928856..d0282974d 100644 --- a/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.ts +++ b/greenfield/src/server/platform/gateway/persistentOpenClawCronProvider.ts @@ -468,9 +468,14 @@ function parseBoundary( function requestOptions( signal: AbortSignal | undefined, - timeoutMs: number + timeoutMs: number, + onResponseBytes?: (responseBytes: number) => void ): PersistentGatewayRequestOptions { - return signal === undefined ? { timeoutMs } : { signal, timeoutMs }; + return { + ...(onResponseBytes === undefined ? {} : { onResponseBytes }), + ...(signal === undefined ? {} : { signal }), + timeoutMs, + }; } function safeAbort(): PersistentGatewayAbortError { @@ -613,15 +618,23 @@ function assertPageRelationships( function parseListPage( raw: unknown, expectedLimit: number, - expectedOffset: number + expectedOffset: number, + responseBytes: number ): OpenClawCronProviderListPage { + if (!Number.isSafeInteger(responseBytes) || responseBytes < 1) { + throw new OpenClawCronProviderError("invalid-data"); + } const page = parseBoundary(upstreamListPageSchema, raw); assertPageRelationships(page, page.jobs.length, expectedLimit, expectedOffset); const jobs = page.jobs.map(parseJob); if (new Set(jobs.map(({ id }) => id)).size !== jobs.length) { throw new OpenClawCronProviderError("invalid-data"); } - return Object.freeze({ ...page, jobs: Object.freeze(jobs) }); + return Object.freeze({ + ...page, + jobs: Object.freeze(jobs), + responseBytes, + }); } function parseRunPage( @@ -763,6 +776,7 @@ export function createPersistentOpenClawCronProvider( } = input; const parsed = parseBoundary(listOpenClawCronInputSchema, raw); return await providerOperation("list", signal, async () => { + let responseBytes: number | undefined; const response = await transport.request( "cron.list", { @@ -777,9 +791,21 @@ export function createPersistentOpenClawCronProvider( sortBy: parsed.sortBy, sortDir: parsed.sortDir, }, - requestOptions(signal, persistentOpenClawCronReadTimeoutMs) + requestOptions( + signal, + persistentOpenClawCronReadTimeoutMs, + (candidate) => { + responseBytes = + responseBytes === undefined ? candidate : Number.NaN; + } + ) + ); + return parseListPage( + response, + parsed.limit, + parsed.offset, + responseBytes ?? Number.NaN ); - return parseListPage(response, parsed.limit, parsed.offset); }); } diff --git a/greenfield/src/server/test/support/moltbook.ts b/greenfield/src/server/test/support/moltbook.ts new file mode 100644 index 000000000..dcf6bdc46 --- /dev/null +++ b/greenfield/src/server/test/support/moltbook.ts @@ -0,0 +1,37 @@ +import type { MoltbookDashboardCachePayload } from "../../../contracts/moltbook.ts"; +import type { MoltbookDashboardCollector } from "../../domains/moltbook/provider.ts"; + +/** Minimal valid fixed snapshot shared by worker composition tests. */ +export const testMoltbookDashboardSnapshot: MoltbookDashboardCachePayload = Object.freeze( + { + feeds: { + hot: { hasMore: false, posts: [], sort: "hot" as const }, + new: { hasMore: false, posts: [], sort: "new" as const }, + }, + fetchedAtMs: 1000, + home: { + activityOnYourPostsCount: 0, + exploreCount: 0, + nextActions: [], + pendingRequestCount: 0, + postsFromAccountsYouFollowCount: 0, + unreadMessageCount: 0, + unreadNotificationCount: 0, + }, + myContent: { comments: [], posts: [] }, + profile: { + commentsCount: 0, + description: "", + displayName: "Mira", + followerCount: 0, + followingCount: 0, + karma: 0, + name: "mira_2026", + postsCount: 0, + }, + } +); + +export const testMoltbookCollector: MoltbookDashboardCollector = Object.freeze({ + collect: () => Promise.resolve(testMoltbookDashboardSnapshot), +}); diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index 46cab5b16..b07d244d0 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -5,6 +5,7 @@ import type { ApplicationCapability, RequestAuthentication, } from "../../../contracts/security.ts"; +import type { SystemHealthDiagnostics } from "../../../contracts/system.ts"; import type { AgentService } from "../../domains/agents/service.ts"; import { createTestAgentService } from "../../domains/agents/testSupport/service.ts"; import type { CacheService } from "../../domains/cache/service.ts"; @@ -41,6 +42,7 @@ import type { AutomationSecurityLifecycleService } from "../../domains/security/ import type { MfaAccountLifecycleService } from "../../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../../domains/security/mfa/loginLifecycle.ts"; import type { SecurityAuditLifecycleService } from "../../domains/security/securityAuditLifecycle.ts"; +import type { SystemHealthDiagnosticsService } from "../../domains/system/healthDiagnosticsService.ts"; import { SystemMetricsUnavailableError, type SystemMetricsRuntimeService, @@ -105,6 +107,31 @@ export function createTestGatewayConnectionService(): GatewayConnectionService { }); } +const unavailableSystemHealthDiagnostics = Object.freeze({ + checkedAtMs: 1_800_000_000_000, + checks: { + application: { status: "not-ready" }, + database: { status: "unavailable" }, + frontend: { status: "unavailable" }, + release: { status: "unavailable" }, + worker: { status: "unavailable" }, + }, + dependencies: { + gateway: { status: "unavailable" }, + sessions: { state: "unavailable" }, + }, + queue: { status: "unavailable" }, + status: "not-ready", +} as const satisfies SystemHealthDiagnostics); + +/** + * Creates stable fail-closed detailed health for generic request and server tests. + * @returns An inert identity-free diagnostics service. + */ +export function createTestSystemHealthDiagnosticsService(): SystemHealthDiagnosticsService { + return Object.freeze({ read: () => unavailableSystemHealthDiagnostics }); +} + /** * Creates a stable fail-closed OpenClaw cron service for generic request tests. * @returns An inert cron service. @@ -478,6 +505,7 @@ export interface TestServerSecurityServices { readonly monitoringService: MonitoringService["Service"]; readonly openClawCronService: OpenClawCronService; readonly securityAuditLifecycle: SecurityAuditLifecycleService; + readonly systemHealthDiagnosticsService: SystemHealthDiagnosticsService; readonly taskService: TaskService["Service"]; } @@ -517,6 +545,9 @@ export function createTestServerSecurityServices( overrides.openClawCronService ?? createTestOpenClawCronService(), securityAuditLifecycle: overrides.securityAuditLifecycle ?? createTestSecurityAuditLifecycleService(), + systemHealthDiagnosticsService: + overrides.systemHealthDiagnosticsService ?? + createTestSystemHealthDiagnosticsService(), taskService: overrides.taskService ?? createTestTaskService(), }; } @@ -612,6 +643,7 @@ export function createTestRequestContext( readonly requestId?: string; readonly responseHeaders?: Headers; readonly securityAuditLifecycle?: SecurityAuditLifecycleService; + readonly systemHealthDiagnosticsService?: SystemHealthDiagnosticsService; readonly taskService?: TaskService["Service"]; } = {} ): Promise { @@ -650,6 +682,9 @@ export function createTestRequestContext( responseHeaders: options.responseHeaders ?? new Headers(), securityAuditLifecycle: options.securityAuditLifecycle ?? createTestSecurityAuditLifecycleService(), + systemHealthDiagnosticsService: + options.systemHealthDiagnosticsService ?? + createTestSystemHealthDiagnosticsService(), taskService: options.taskService ?? createTestTaskService(), }); } diff --git a/greenfield/src/server/trpc/appRouter.ts b/greenfield/src/server/trpc/appRouter.ts index 794965524..8b95d8f5b 100644 --- a/greenfield/src/server/trpc/appRouter.ts +++ b/greenfield/src/server/trpc/appRouter.ts @@ -20,6 +20,10 @@ import { scheduleRouter, } from "../domains/jobs/procedures.ts"; import { logProcedureNames, logsRouter } from "../domains/logs/procedures.ts"; +import { + moltbookProcedureNames, + moltbookRouter, +} from "../domains/moltbook/procedures.ts"; import { incidentProcedureNames, incidentRouter, @@ -83,6 +87,7 @@ export const appRouter = router({ jobs: jobRouter, logs: logsRouter, monitoring: monitoringRouter, + moltbook: moltbookRouter, notifications: notificationRouter, openClawCron: openClawCronRouter, openClawTasks: openClawTasksRouter, @@ -110,6 +115,7 @@ export const appRouterProcedureNames = Object.freeze([ ...namespacedProcedureNames("jobs", jobProcedureNames), ...namespacedProcedureNames("logs", logProcedureNames), ...namespacedProcedureNames("monitoring", monitoringProcedureNames), + ...namespacedProcedureNames("moltbook", moltbookProcedureNames), ...namespacedProcedureNames("notifications", notificationProcedureNames), ...namespacedProcedureNames("openClawCron", openClawCronProcedureNames), ...namespacedProcedureNames("openClawTasks", openClawTaskProcedureNames), diff --git a/greenfield/src/server/trpc/context.test.ts b/greenfield/src/server/trpc/context.test.ts index 0ce8ed53c..3fa687b8e 100644 --- a/greenfield/src/server/trpc/context.test.ts +++ b/greenfield/src/server/trpc/context.test.ts @@ -20,6 +20,7 @@ import { createTestMfaLoginLifecycleService, createTestOpenClawCronService, createTestSecurityAuditLifecycleService, + createTestSystemHealthDiagnosticsService, } from "../test/support/requestContext.ts"; import { createRequestContext } from "./context.ts"; @@ -84,6 +85,7 @@ describe("tRPC request context", () => { requestId: "request-context-1", responseHeaders, securityAuditLifecycle: createTestSecurityAuditLifecycleService(), + systemHealthDiagnosticsService: createTestSystemHealthDiagnosticsService(), taskService: createTestTaskService(), }); @@ -153,6 +155,7 @@ describe("tRPC request context", () => { requestId: "request-context-2", responseHeaders: new Headers(), securityAuditLifecycle: createTestSecurityAuditLifecycleService(), + systemHealthDiagnosticsService: createTestSystemHealthDiagnosticsService(), taskService: createTestTaskService(), }); @@ -199,6 +202,8 @@ describe("tRPC request context", () => { requestId: "request-context-3", responseHeaders: new Headers(), securityAuditLifecycle: createTestSecurityAuditLifecycleService(), + systemHealthDiagnosticsService: + createTestSystemHealthDiagnosticsService(), taskService: createTestTaskService(), }); } catch (error) { diff --git a/greenfield/src/server/trpc/context.ts b/greenfield/src/server/trpc/context.ts index 549e9ad14..d07b6e651 100644 --- a/greenfield/src/server/trpc/context.ts +++ b/greenfield/src/server/trpc/context.ts @@ -20,6 +20,7 @@ import type { AutomationSecurityLifecycleService } from "../domains/security/aut import type { MfaAccountLifecycleService } from "../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../domains/security/mfa/loginLifecycle.ts"; import type { SecurityAuditLifecycleService } from "../domains/security/securityAuditLifecycle.ts"; +import type { SystemHealthDiagnosticsService } from "../domains/system/healthDiagnosticsService.ts"; import type { TaskService } from "../domains/tasks/service.ts"; import type { TerminalService } from "../domains/terminal/service.ts"; import type { @@ -61,6 +62,7 @@ export interface RequestContextOptions { readonly requestId: string; readonly responseHeaders: Headers; readonly securityAuditLifecycle: SecurityAuditLifecycleService; + readonly systemHealthDiagnosticsService: SystemHealthDiagnosticsService; readonly taskService: TaskService["Service"]; readonly terminalService?: TerminalService; } @@ -90,6 +92,7 @@ export interface RequestContext { readonly requestId: string; readonly responseHeaders: Headers; readonly securityAuditLifecycle: SecurityAuditLifecycleService; + readonly systemHealthDiagnosticsService: SystemHealthDiagnosticsService; readonly taskService: TaskService["Service"]; readonly terminalService?: TerminalService; readonly services: ApplicationRuntimeServices; @@ -140,6 +143,7 @@ export async function createRequestContext( requestId: options.requestId, responseHeaders: options.responseHeaders, securityAuditLifecycle: options.securityAuditLifecycle, + systemHealthDiagnosticsService: options.systemHealthDiagnosticsService, taskService: options.taskService, ...(options.terminalService === undefined ? {} diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index 0ad372df5..58d95b9aa 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -427,6 +427,11 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "logs.requestMaintenance": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "logs.search": ["FORBIDDEN", "NOT_FOUND", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "logs.tail": ["FORBIDDEN", "NOT_FOUND", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "moltbook.feed": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "moltbook.home": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "moltbook.listMyPosts": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "moltbook.profile": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "moltbook.snapshot": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "monitoring.submitCompleteSnapshot": [ "BAD_REQUEST", "CONFLICT", @@ -492,6 +497,7 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "UNAUTHORIZED", ], "securityAudit.listEvents": ["FORBIDDEN", "UNAUTHORIZED"], + "system.healthDiagnostics": ["FORBIDDEN", "UNAUTHORIZED"], "system.metrics": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "system.runtimeIdentity": [], "tasks.addUpdate": [ diff --git a/greenfield/src/shared/browserRouteRegistry.ts b/greenfield/src/shared/browserRouteRegistry.ts index 537e57d8c..03a5a3934 100644 --- a/greenfield/src/shared/browserRouteRegistry.ts +++ b/greenfield/src/shared/browserRouteRegistry.ts @@ -74,6 +74,14 @@ export const dashboardRouteDocumentation = Object.freeze([ summary: "Reads redacted named log sources and queues fixed maintenance policies.", }, + { + access: "session", + featureOwner: "moltbook", + navigationLabel: "Moltbook", + path: "/moltbook", + summary: + "Reads the bounded worker-owned Moltbook profile, feeds, posts, and comments snapshot.", + }, { access: "session", featureOwner: "monitoring", diff --git a/greenfield/src/shared/configuration/applicationConfigurationRegistry.ts b/greenfield/src/shared/configuration/applicationConfigurationRegistry.ts index 2892986a4..0e93bc1eb 100644 --- a/greenfield/src/shared/configuration/applicationConfigurationRegistry.ts +++ b/greenfield/src/shared/configuration/applicationConfigurationRegistry.ts @@ -9,6 +9,8 @@ export const applicationConfigurationLimits = Object.freeze({ elevenLabsApiKeyMaximumLength: 4096, gatewayTokenMaximumLength: 4096, gatewayUrlMaximumLength: 2048, + moltbookAgentNameMaximumLength: 128, + moltbookApiKeyMaximumLength: 4096, openClawRootMaximumLength: 4096, port: Object.freeze({ maximum: 65_535, minimum: 1 }), projectRootMaximumLength: 4096, @@ -33,6 +35,8 @@ export type ApplicationConfigurationField = | "gatewayToken" | "gatewayUrl" | "logLevel" + | "moltbookAgentName" + | "moltbookApiKey" | "nodeEnvironment" | "openClawRoot" | "port" @@ -57,6 +61,8 @@ export const applicationConfigurationEnvironmentNames = [ "MIRA_DASHBOARD_PUBLIC_ORIGIN", "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", "ELEVENLABS_API_KEY", + "MOLTBOOK_API_KEY", + "MOLTBOOK_AGENT_NAME", "OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_URL", "MIRA_DASHBOARD_WEBAUTHN_RP_ID", @@ -96,6 +102,7 @@ export interface ApplicationConfigurationMetadata { | "environment-mode" | "http-origin" | "http-origin-list" + | "identifier" | "ip-address-list" | "json-secret" | "log-level" @@ -253,6 +260,37 @@ export const applicationConfigurationRegistry: readonly ApplicationConfiguration validationConstraints: `When present, a trimmed nonblank control-safe secret at most ${applicationConfigurationLimits.elevenLabsApiKeyMaximumLength} code units; never persisted, logged, or browser-exposed.`, valueType: "opaque-secret", }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: null, + description: + "Worker-only Moltbook API credential used by the fixed read-only cache provider.", + environmentName: "MOLTBOOK_API_KEY", + field: "moltbookApiKey", + operationalEffect: + "Authenticates four fixed-host Moltbook snapshot requests from the worker.", + restartRequired: true, + roles: Object.freeze(["worker"]), + secret: true, + validationConstraints: `Trimmed nonblank control-safe secret at most ${applicationConfigurationLimits.moltbookApiKeyMaximumLength} code units; never persisted, logged, or browser-exposed.`, + valueType: "opaque-secret", + }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: "mira_2026", + description: "Moltbook agent identity projected into the Dashboard cache.", + environmentName: "MOLTBOOK_AGENT_NAME", + field: "moltbookAgentName", + operationalEffect: + "Selects the fixed, URL-encoded profile read used for profile, posts, and comments.", + restartRequired: true, + roles: Object.freeze(["worker"]), + secret: false, + validationConstraints: `Trimmed nonblank control-safe identity at most ${applicationConfigurationLimits.moltbookAgentNameMaximumLength} code units.`, + valueType: "identifier", + }), metadata({ allowedValues: null, browserExposure: "none", diff --git a/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts b/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts index 79cd7b192..378571501 100644 --- a/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts +++ b/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts @@ -272,6 +272,7 @@ class DirectProcessController implements ProductionServiceController { MIRA_DASHBOARD_LOG_LEVEL: "debug", MIRA_DASHBOARD_OPENCLAW_ROOT: openClawRoot, MIRA_DASHBOARD_PROJECT_ROOT: this.#projectRoot, + MOLTBOOK_API_KEY: "moltbook-key-test-value", NODE_ENV: "production", ...gatewayTestEnvironment, }, diff --git a/greenfield/src/test/parity/fixtures/frontend-routes.json b/greenfield/src/test/parity/fixtures/frontend-routes.json index a8bec1c76..44df32e56 100644 --- a/greenfield/src/test/parity/fixtures/frontend-routes.json +++ b/greenfield/src/test/parity/fixtures/frontend-routes.json @@ -129,7 +129,7 @@ "searchNormalizer": null, "sourceRouteName": "jobs", "target": { - "delivery": "planned", + "delivery": "implemented", "path": "/jobs", "phase": "phase-3" } @@ -177,7 +177,7 @@ "searchNormalizer": null, "sourceRouteName": "moltbook", "target": { - "delivery": "planned", + "delivery": "implemented", "path": "/moltbook", "phase": "phase-5" } diff --git a/greenfield/src/test/parity/fixtures/greenfield-contracts.json b/greenfield/src/test/parity/fixtures/greenfield-contracts.json index 6876a1079..ab01cccdf 100644 --- a/greenfield/src/test/parity/fixtures/greenfield-contracts.json +++ b/greenfield/src/test/parity/fixtures/greenfield-contracts.json @@ -326,6 +326,26 @@ "kind": "query", "name": "logs.tail" }, + { + "kind": "query", + "name": "moltbook.feed" + }, + { + "kind": "query", + "name": "moltbook.home" + }, + { + "kind": "query", + "name": "moltbook.listMyPosts" + }, + { + "kind": "query", + "name": "moltbook.profile" + }, + { + "kind": "query", + "name": "moltbook.snapshot" + }, { "kind": "mutation", "name": "monitoring.submitCompleteSnapshot" @@ -434,6 +454,10 @@ "kind": "query", "name": "securityAudit.listEvents" }, + { + "kind": "query", + "name": "system.healthDiagnostics" + }, { "kind": "query", "name": "system.metrics" diff --git a/greenfield/src/test/parity/fixtures/legacy-endpoints.json b/greenfield/src/test/parity/fixtures/legacy-endpoints.json index da7dca18a..b727ff6ce 100644 --- a/greenfield/src/test/parity/fixtures/legacy-endpoints.json +++ b/greenfield/src/test/parity/fixtures/legacy-endpoints.json @@ -283,7 +283,7 @@ "id": "GET /api/cache/heartbeat", "method": "GET", "path": "/api/cache/heartbeat", - "purpose": "Reads schema v3 cache envelopes plus compact task, OpenClaw cron, and Dashboard-job projections.", + "purpose": "Reads legacy schema v3 payload-bearing cache envelopes plus identifiable task, Dashboard-job, and OpenClaw-cron diagnostics. cache.getHeartbeat schema v4 is a bounded secure operational summary, but this legacy row remains planned until every diagnostic capability and the external consumer migration are preserved without loss.", "section": "Backups, Cache, Metrics, Ops", "target": { "delivery": "planned", @@ -366,7 +366,7 @@ "purpose": "Lists OpenClaw cron jobs and open linked tasks.", "section": "Jobs And Cron", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["openClawCron.list"], "phase": "phase-4" @@ -546,10 +546,10 @@ "id": "GET /api/health/diagnostics", "method": "GET", "path": "/api/health/diagnostics", - "purpose": "Authenticated readiness details and dependency status.", + "purpose": "Authenticated readiness details and dependency status. The replacement preserves application, database, frontend, verified-release, exact-release-worker, Gateway, cached-session, and queue health through a bounded identity-free projection; wider legacy application observability remains tracked separately by GET /api/metrics.", "section": "Health", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["system.healthDiagnostics"], "phase": "phase-1" @@ -705,7 +705,7 @@ "id": "GET /api/metrics", "method": "GET", "path": "/api/metrics", - "purpose": "Reads host metrics.", + "purpose": "Reads legacy host and application observability, HTTP counters, polling-snapshot state, token projections, and the wider counters deferred from legacy health diagnostics. The bounded system.metrics host gauges are implemented, but this row remains planned until the complete capability is preserved.", "section": "Backups, Cache, Metrics, Ops", "target": { "delivery": "planned", @@ -721,7 +721,7 @@ "purpose": "Reads feed with query params.", "section": "Moltbook And Voice", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["moltbook.feed"], "phase": "phase-5" @@ -734,7 +734,7 @@ "purpose": "Reads Moltbook home cache/API.", "section": "Moltbook And Voice", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["moltbook.home"], "phase": "phase-5" @@ -747,7 +747,7 @@ "purpose": "Reads own content.", "section": "Moltbook And Voice", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["moltbook.listMyPosts"], "phase": "phase-5" @@ -760,7 +760,7 @@ "purpose": "Reads profile.", "section": "Moltbook And Voice", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["moltbook.profile"], "phase": "phase-5" @@ -1425,7 +1425,7 @@ "purpose": "Deletes an OpenClaw cron job.", "section": "Jobs And Cron", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["openClawCron.delete"], "phase": "phase-4" @@ -1438,7 +1438,7 @@ "purpose": "Runs an OpenClaw cron job.", "section": "Jobs And Cron", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["openClawCron.run"], "phase": "phase-4" @@ -1451,7 +1451,7 @@ "purpose": "Enables/disables an OpenClaw cron job and updates its Dashboard-owned disable intent.", "section": "Jobs And Cron", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["openClawCron.setEnabled"], "phase": "phase-4" @@ -1464,7 +1464,7 @@ "purpose": "Updates an OpenClaw cron job patch.", "section": "Jobs And Cron", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["openClawCron.update"], "phase": "phase-4" @@ -2053,7 +2053,7 @@ "purpose": "Browser Dashboard socket for Gateway-backed live updates.", "section": "Sessions And Chat", "target": { - "delivery": "planned", + "delivery": "implemented", "kind": "procedure", "names": ["events.stream"], "phase": "phase-4" diff --git a/greenfield/src/test/parity/parityInventory.test.ts b/greenfield/src/test/parity/parityInventory.test.ts index 699a8e827..0db01e311 100644 --- a/greenfield/src/test/parity/parityInventory.test.ts +++ b/greenfield/src/test/parity/parityInventory.test.ts @@ -57,8 +57,10 @@ describe("reviewed pre-cutover parity inventory", () => { "/agents", "/chat", "/files", + "/jobs", "/login", "/logs", + "/moltbook", "/reports", "/sessions", "/tasks", @@ -160,6 +162,84 @@ describe("reviewed pre-cutover parity inventory", () => { ).toEqual([]); }); + test("keeps the reviewed Sessions and Chat slice closed", async () => { + const reviewed = await loadReviewedParityInventory(); + const sessionsAndChatEndpoints = reviewed.legacyEndpoints.endpoints.filter( + ({ section }) => section === "Sessions And Chat" + ); + + expect( + sessionsAndChatEndpoints.map(({ id, target }) => [ + id, + target.kind === "reviewed-removal" ? target.kind : target.delivery, + target.kind === "procedure" ? target.names : undefined, + ]) + ).toEqual([ + ["DELETE /api/sessions/:id", "implemented", ["gatewaySessions.delete"]], + ["GET /api/sessions/list", "implemented", ["gatewaySessions.list"]], + ["GET /api/sessions/stats", "implemented", ["gatewaySessions.list"]], + [ + "POST /api/sessions/:id/action", + "implemented", + ["gatewaySessions.compact", "gatewaySessions.reset"], + ], + ["WebSocket /ws", "implemented", ["events.stream"]], + ]); + }); + + test("keeps full legacy heartbeat diagnostics planned beside schema v4", async () => { + const reviewed = await loadReviewedParityInventory(); + const heartbeat = reviewed.legacyEndpoints.endpoints.find( + ({ id }) => id === "GET /api/cache/heartbeat" + ); + + expect(heartbeat?.purpose).toContain("legacy schema v3 payload-bearing"); + expect(heartbeat?.purpose).toContain("schema v4"); + expect(heartbeat?.purpose).toContain("without loss"); + expect(heartbeat?.target).toEqual({ + delivery: "planned", + kind: "procedure", + names: ["cache.getHeartbeat"], + phase: "phase-4", + }); + }); + + test("records the identity-free health diagnostics replacement precisely", async () => { + const reviewed = await loadReviewedParityInventory(); + const diagnostics = reviewed.legacyEndpoints.endpoints.find( + ({ id }) => id === "GET /api/health/diagnostics" + ); + + expect(diagnostics?.purpose).toContain("exact-release-worker"); + expect(diagnostics?.purpose).toContain("identity-free"); + expect(diagnostics?.purpose).toContain("GET /api/metrics"); + expect(diagnostics?.target).toEqual({ + delivery: "implemented", + kind: "procedure", + names: ["system.healthDiagnostics"], + phase: "phase-1", + }); + }); + + test("keeps the wider legacy metrics capability planned explicitly", async () => { + const reviewed = await loadReviewedParityInventory(); + const metrics = reviewed.legacyEndpoints.endpoints.find( + ({ id }) => id === "GET /api/metrics" + ); + + expect(metrics?.purpose).toContain("application observability"); + expect(metrics?.purpose).toContain("HTTP counters"); + expect(metrics?.purpose).toContain("polling-snapshot"); + expect(metrics?.purpose).toContain("token projections"); + expect(metrics?.purpose).toContain("health diagnostics"); + expect(metrics?.target).toEqual({ + delivery: "planned", + kind: "procedure", + names: ["system.metrics"], + phase: "phase-3", + }); + }); + test("keeps the reviewed Phase 5 Logs slice closed", async () => { const reviewed = await loadReviewedParityInventory(); const logsRoute = reviewed.frontend.routes.find(({ path }) => path === "/logs"); @@ -261,4 +341,73 @@ describe("reviewed pre-cutover parity inventory", () => { phase: "phase-5", }); }); + + test("keeps the reviewed Phase 5 Moltbook slice closed", async () => { + const reviewed = await loadReviewedParityInventory(); + const moltbookRoute = reviewed.frontend.routes.find( + ({ path }) => path === "/moltbook" + ); + const endpoints = reviewed.legacyEndpoints.endpoints.filter(({ id }) => + [ + "GET /api/moltbook/feed", + "GET /api/moltbook/home", + "GET /api/moltbook/my-posts", + "GET /api/moltbook/profile", + ].includes(id) + ); + + expect(moltbookRoute?.target.delivery).toBe("implemented"); + expect( + endpoints.map(({ id, target }) => [ + id, + target.kind === "reviewed-removal" ? target.kind : target.delivery, + target.kind === "procedure" ? target.names : undefined, + ]) + ).toEqual([ + ["GET /api/moltbook/feed", "implemented", ["moltbook.feed"]], + ["GET /api/moltbook/home", "implemented", ["moltbook.home"]], + ["GET /api/moltbook/my-posts", "implemented", ["moltbook.listMyPosts"]], + ["GET /api/moltbook/profile", "implemented", ["moltbook.profile"]], + ]); + }); + + test("keeps the reviewed jobs and cron slice closed", async () => { + const reviewed = await loadReviewedParityInventory(); + const jobsRoute = reviewed.frontend.routes.find(({ path }) => path === "/jobs"); + const jobsAndCronEndpoints = reviewed.legacyEndpoints.endpoints.filter( + ({ section }) => section === "Jobs And Cron" + ); + + expect(jobsRoute?.target.delivery).toBe("implemented"); + expect( + jobsAndCronEndpoints.map(({ id, target }) => [ + id, + target.kind === "reviewed-removal" ? target.kind : target.delivery, + target.kind === "procedure" ? target.names : undefined, + ]) + ).toEqual([ + ["GET /api/cron/jobs", "implemented", ["openClawCron.list"]], + ["GET /api/job-executions", "implemented", ["jobs.listRuns"]], + ["GET /api/job-executions/:id", "implemented", ["jobs.getRun"]], + ["GET /api/jobs", "implemented", ["schedules.list"]], + ["GET /api/jobs/:id", "implemented", ["schedules.get"]], + ["GET /api/jobs/:id/runs", "implemented", ["schedules.listRuns"]], + [ + "PATCH /api/job-executions/claims", + "implemented", + ["jobs.setClaimingPaused"], + ], + ["PATCH /api/jobs/:id", "implemented", ["schedules.update"]], + ["POST /api/cron/jobs/:id/delete", "implemented", ["openClawCron.delete"]], + ["POST /api/cron/jobs/:id/run", "implemented", ["openClawCron.run"]], + [ + "POST /api/cron/jobs/:id/toggle", + "implemented", + ["openClawCron.setEnabled"], + ], + ["POST /api/cron/jobs/:id/update", "implemented", ["openClawCron.update"]], + ["POST /api/job-executions/:id/cancel", "implemented", ["jobs.cancelRun"]], + ["POST /api/jobs/:id/run", "implemented", ["schedules.run"]], + ]); + }); }); diff --git a/greenfield/systemd/mira-dashboard-web.service b/greenfield/systemd/mira-dashboard-web.service index 75ff2f299..700b3e4d9 100644 --- a/greenfield/systemd/mira-dashboard-web.service +++ b/greenfield/systemd/mira-dashboard-web.service @@ -11,7 +11,8 @@ Environment=NODE_ENV=production Environment=MIRA_DASHBOARD_PROJECT_ROOT=%h/projects/mira-dashboard Environment=MIRA_DASHBOARD_OPENCLAW_ROOT=%h/.openclaw Environment=MIRA_DASHBOARD_WORKSPACE_ROOT=%h/.openclaw/workspace -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/web.js +UnsetEnvironment=MOLTBOOK_API_KEY MOLTBOOK_AGENT_NAME +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --only-secrets NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT,PORT,MIRA_DASHBOARD_PUBLIC_ORIGIN,MIRA_DASHBOARD_TRUSTED_PROXY_IPS,ELEVENLABS_API_KEY,OPENCLAW_GATEWAY_URL,OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_WEBAUTHN_RP_ID,MIRA_DASHBOARD_WEBAUTHN_ORIGINS,MIRA_DASHBOARD_WEBAUTHN_RP_NAME,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES,MIRA_DASHBOARD_TOTP_KEYRING,MIRA_DASHBOARD_LOG_LEVEL --no-exit-on-missing-only-secrets --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/web.js StandardOutput=append:%h/projects/mira-dashboard/production/state/logs/web-stdout.log StandardError=append:%h/projects/mira-dashboard/production/state/logs/web-stderr.log Restart=on-failure diff --git a/greenfield/systemd/mira-dashboard-worker.service b/greenfield/systemd/mira-dashboard-worker.service index 81807cec9..e321722d3 100644 --- a/greenfield/systemd/mira-dashboard-worker.service +++ b/greenfield/systemd/mira-dashboard-worker.service @@ -11,7 +11,8 @@ Environment=NODE_ENV=production Environment=MIRA_DASHBOARD_PROJECT_ROOT=%h/projects/mira-dashboard Environment=MIRA_DASHBOARD_OPENCLAW_ROOT=%h/.openclaw Environment=MIRA_DASHBOARD_WORKSPACE_ROOT=%h/.openclaw/workspace -ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/worker.js +UnsetEnvironment=ELEVENLABS_API_KEY MIRA_DASHBOARD_TOTP_KEYRING +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --only-secrets NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT,MOLTBOOK_API_KEY,MOLTBOOK_AGENT_NAME,OPENCLAW_GATEWAY_URL,OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_LOG_LEVEL --no-exit-on-missing-only-secrets --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/worker.js StandardOutput=append:%h/projects/mira-dashboard/production/state/logs/worker-stdout.log StandardError=append:%h/projects/mira-dashboard/production/state/logs/worker-stderr.log Restart=on-failure