From 511a1edb5d6ddf25081e3cfdf23807c427dc4922 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Fri, 7 Aug 2026 15:19:56 +0200 Subject: [PATCH 1/3] feat(rewrite): add Phase 3 agent directory --- greenfield/.gitignore | 4 +- .../application-architecture.md | 7 +- .../greenfield-rewrite/data-and-security.md | 27 +- .../greenfield-rewrite/progress.md | 22 + greenfield/docs/generated/procedures.md | 7 +- .../agents.getConfiguration.input.schema.json | 8 + ...agents.getConfiguration.output.schema.json | 70 ++++ .../agents.getStatus.input.schema.json | 17 + .../agents.getStatus.output.schema.json | 77 ++++ .../agents.listStatuses.input.schema.json | 8 + .../agents.listStatuses.output.schema.json | 92 +++++ .../agents.listTaskHistory.input.schema.json | 43 ++ .../agents.listTaskHistory.output.schema.json | 159 +++++++ .../agents.updateMetadata.input.schema.json | 39 ++ .../agents.updateMetadata.output.schema.json | 77 ++++ ...Security.createPrincipal.input.schema.json | 4 +- ...ecurity.createPrincipal.output.schema.json | 8 +- ...curity.disablePrincipal.output.schema.json | 8 +- ...Security.listPrincipals.output.schema.json | 8 +- ...rity.replaceCapabilities.input.schema.json | 4 +- ...ity.replaceCapabilities.output.schema.json | 8 +- .../schemas/events.stream.input.schema.json | 1 + .../schemas/events.stream.output.schema.json | 52 +++ ...ecurityAudit.listEvents.output.schema.json | 8 +- .../migration.sql | 61 ++- .../snapshot.json | 233 ++++++++++- .../productionReleaseActivation.test.ts | 4 +- .../scripts/documentation/artifacts.test.ts | 16 +- .../scripts/documentation/jsonSchema.test.ts | 34 +- .../scripts/documentation/jsonSchema.ts | 41 ++ greenfield/scripts/documentation/markdown.ts | 18 +- greenfield/src/app/dashboardServer.ts | 14 +- greenfield/src/app/server.ts | 3 + greenfield/src/app/trpcHttpHandler.ts | 3 + .../src/browser/agents/AgentHistoryTable.tsx | 126 ++++++ .../src/browser/agents/AgentStatusGrid.tsx | 126 ++++++ .../src/browser/agents/AgentsRoute.test.tsx | 237 +++++++++++ greenfield/src/browser/agents/AgentsRoute.tsx | 108 +++++ .../src/browser/agents/agentCollections.ts | 60 +++ greenfield/src/browser/agents/agentQueries.ts | 35 ++ .../agents/useAgentRealtimeInvalidation.ts | 16 + greenfield/src/browser/api/trpcClient.ts | 4 + .../api/useRealtimeQueryInvalidation.ts | 65 +++ greenfield/src/browser/application.test.tsx | 25 +- greenfield/src/browser/application.tsx | 27 +- .../auth/AuthenticationBoundary.test.tsx | 29 +- .../src/browser/auth/LoginRoute.test.tsx | 34 +- .../src/browser/data/dashboardCollections.ts | 38 ++ .../data/dashboardCollectionsContext.tsx | 21 + .../data/dashboardCollectionsContextValue.ts | 20 + .../src/browser/layout/DashboardShell.tsx | 3 +- greenfield/src/browser/lib/dashboardRoutes.ts | 1 + greenfield/src/browser/router.tsx | 5 + greenfield/src/browser/routes/agents.lazy.tsx | 14 + .../security/AccountSecurityRoute.test.tsx | 34 +- .../src/browser/tasks/TaskBoardRoute.test.tsx | 34 +- .../tasks/useTaskRealtimeInvalidation.ts | 49 +-- greenfield/src/browser/ui/DataTable.tsx | 1 + greenfield/src/browser/ui/PageHeader.tsx | 28 +- greenfield/src/contracts/agentModel.test.ts | 63 +++ greenfield/src/contracts/agentModel.ts | 191 +++++++++ greenfield/src/contracts/agentRealtime.ts | 40 ++ greenfield/src/contracts/agents.test.ts | 123 ++++++ greenfield/src/contracts/agents.ts | 243 +++++++++++ greenfield/src/contracts/contractRegistry.ts | 2 + greenfield/src/contracts/events.test.ts | 1 + greenfield/src/contracts/events.ts | 14 +- greenfield/src/contracts/security.ts | 2 + .../migrations/agentTaskRunsSchema.test.ts | 126 ++++++ .../migrations/migrationGraph.test.ts | 1 + .../securityIdentitySchema.automation.test.ts | 18 +- .../server/database/schema/agentTaskRuns.ts | 86 ++++ .../schema/automationPrincipalCapabilities.ts | 2 +- .../server/database/schema/drizzleSchema.ts | 1 + .../database/validation/agentTaskRuns.ts | 99 +++++ .../src/server/domains/agents/directory.ts | 60 +++ .../src/server/domains/agents/errors.ts | 13 + .../server/domains/agents/procedures.test.ts | 111 +++++ .../src/server/domains/agents/procedures.ts | 8 + .../src/server/domains/agents/repository.ts | 304 ++++++++++++++ .../src/server/domains/agents/routes.ts | 76 ++++ .../src/server/domains/agents/service.test.ts | 221 ++++++++++ .../src/server/domains/agents/service.ts | 388 ++++++++++++++++++ .../agents/testSupport/agentService.ts | 73 ++++ .../domains/agents/testSupport/service.ts | 50 +++ .../requestAuthenticationSession.test.ts | 8 +- .../src/server/test/support/requestContext.ts | 6 + greenfield/src/server/trpc/appRouter.ts | 3 + greenfield/src/server/trpc/context.test.ts | 4 + greenfield/src/server/trpc/context.ts | 4 + .../src/server/trpc/procedureErrorPolicy.ts | 10 + .../src/shared/databaseMigrationManifest.ts | 4 +- .../test/parity/fixtures/frontend-routes.json | 2 +- .../parity/fixtures/greenfield-contracts.json | 20 + .../parity/fixtures/legacy-endpoints.json | 10 +- .../src/test/parity/parityInventory.test.ts | 2 +- 96 files changed, 4538 insertions(+), 176 deletions(-) create mode 100644 greenfield/docs/generated/schemas/agents.getConfiguration.input.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.getConfiguration.output.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.getStatus.input.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.getStatus.output.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.listStatuses.input.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.listStatuses.output.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.listTaskHistory.input.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.listTaskHistory.output.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.updateMetadata.input.schema.json create mode 100644 greenfield/docs/generated/schemas/agents.updateMetadata.output.schema.json create mode 100644 greenfield/src/browser/agents/AgentHistoryTable.tsx create mode 100644 greenfield/src/browser/agents/AgentStatusGrid.tsx create mode 100644 greenfield/src/browser/agents/AgentsRoute.test.tsx create mode 100644 greenfield/src/browser/agents/AgentsRoute.tsx create mode 100644 greenfield/src/browser/agents/agentCollections.ts create mode 100644 greenfield/src/browser/agents/agentQueries.ts create mode 100644 greenfield/src/browser/agents/useAgentRealtimeInvalidation.ts create mode 100644 greenfield/src/browser/api/useRealtimeQueryInvalidation.ts create mode 100644 greenfield/src/browser/data/dashboardCollections.ts create mode 100644 greenfield/src/browser/data/dashboardCollectionsContext.tsx create mode 100644 greenfield/src/browser/data/dashboardCollectionsContextValue.ts create mode 100644 greenfield/src/browser/routes/agents.lazy.tsx create mode 100644 greenfield/src/contracts/agentModel.test.ts create mode 100644 greenfield/src/contracts/agentModel.ts create mode 100644 greenfield/src/contracts/agentRealtime.ts create mode 100644 greenfield/src/contracts/agents.test.ts create mode 100644 greenfield/src/contracts/agents.ts create mode 100644 greenfield/src/server/database/migrations/agentTaskRunsSchema.test.ts create mode 100644 greenfield/src/server/database/schema/agentTaskRuns.ts create mode 100644 greenfield/src/server/database/validation/agentTaskRuns.ts create mode 100644 greenfield/src/server/domains/agents/directory.ts create mode 100644 greenfield/src/server/domains/agents/errors.ts create mode 100644 greenfield/src/server/domains/agents/procedures.test.ts create mode 100644 greenfield/src/server/domains/agents/procedures.ts create mode 100644 greenfield/src/server/domains/agents/repository.ts create mode 100644 greenfield/src/server/domains/agents/routes.ts create mode 100644 greenfield/src/server/domains/agents/service.test.ts create mode 100644 greenfield/src/server/domains/agents/service.ts create mode 100644 greenfield/src/server/domains/agents/testSupport/agentService.ts create mode 100644 greenfield/src/server/domains/agents/testSupport/service.ts diff --git a/greenfield/.gitignore b/greenfield/.gitignore index e93f3a4f2..0e1e3a3cc 100644 --- a/greenfield/.gitignore +++ b/greenfield/.gitignore @@ -12,7 +12,9 @@ lerna-debug.log* node_modules dist release-manifest.json -data/ +/data/ +!/src/browser/data/ +!/src/browser/data/** .test-openclaw/ .test-data dist-ssr diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index 6d9f7c641..2876244f7 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -387,7 +387,7 @@ once. Reusable procedure builders are limited to: Expected errors use a small stable code set such as `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `NOT_FOUND`, `PRECONDITION_FAILED`, `TOO_MANY_REQUESTS`, and `SERVICE_UNAVAILABLE` with safe -structured details. The `ContractErrorCode` union, all 36 actual router paths, the server-owned +structured details. The `ContractErrorCode` union, all 55 actual router paths, the server-owned runtime allowlist, and generated contract metadata must match exactly. The base procedure middleware enforces that allowlist for immediate and deferred subscription failures; an implemented procedure missing from the policy or an undeclared code becomes a redacted internal @@ -602,6 +602,11 @@ cache key. A server snapshot always wins over conflicting speculative collection - Feature modules own their query option factories, mutation option factories, collection adapter, components, and tests. - Shared UI contains presentation primitives, not domain-specific orchestration. +- The reviewed Dashboard agent directory is code-owned configuration. Gateway discovery may + enrich future live availability, but cannot add identities or grant agent capabilities. +- Agent current-task writes require an `agents:write` automation principal and retain durable + actor attribution. Browser sessions consume the read projection and history; they cannot + impersonate the task-tracking caller. - React Compiler remains enabled. Manual memoization is used only where stable identity is an external contract and a profiler or test justifies it. - Lists with unbounded rows use TanStack Virtual; tables use TanStack Table; neither becomes a diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index b7dfa1df3..85a77d105 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -122,6 +122,25 @@ Moltbook remain external systems. Dashboard persists only configuration, bounded audit/history, job state, or recovery state that it owns. It does not mirror entire external databases. +### Task and agent ownership + +The reviewed agent directory is application configuration rather than database or Gateway +discovery state. `agent_task_runs` persists only current-task intervals owned by Dashboard: + +- one partial unique index permits at most one active interval per configured agent; +- run identity, agent, task, start time, and originating actor are immutable; +- completed intervals are append-only history and cannot be reopened or rewritten; +- user actors use UUIDv7 identities, automation actors use canonical scoped-principal IDs; +- start, activity, and completion timestamps are monotonic and bounded; and +- newest-first global and per-agent indexes support strict `(started_at, id)` keyset pages. + +An `agents:write` caller can target only an identity in the reviewed directory. It never creates +an agent. Start, heartbeat, replace, and clear transitions run inside an admitted immediate +transaction. State-changing start, replace, and clear transitions append the matching realtime +event atomically; a same-task heartbeat only advances durable activity. The production +task-tracking credential must receive this capability during the delivery/provisioning slice; no +browser session can invoke the mutation. + ### Incident and notification lifecycle Heartbeat and other monitors can report many simultaneous problems across tasks, jobs, system @@ -173,6 +192,7 @@ queryable lifecycle. | Task board | `tasks(status, priority, updated_at_ms DESC)` | | Task label filter | `task_labels(label, task_id)` | | Task timeline | `task_updates(task_id, created_at_ms, id)` and equivalent event index | +| Agent task history | unique active-agent partial index plus `(agent_id, started_at_ms, id)` | | Latest reports | `reports(kind, occurred_at_ms DESC, id DESC)` | | Heartbeat stream | `reports(source, source_job_id, occurred_at_ms DESC, id DESC)` | | Active incidents | partial `incidents(monitor_key, last_seen_at_ms DESC) WHERE state = 'active'` | @@ -201,9 +221,10 @@ Drizzle Kit v1 stores the migration graph as timestamped directories containing one evolving `*_dashboard-foundation` baseline generated from the complete current Drizzle schema. The generated SQL includes the security identity objects, SQLite `STRICT` table options, canonical NUL-free constraints, bounded migration-ledger identity fields, and deliberate -`audit_events WITHOUT ROWID` hardening. The custom audit metadata, append-only audit/migration -ledger, monitoring-JSON, and automation replacement-integrity triggers are reviewed additions -because Drizzle does not model them. +`audit_events WITHOUT ROWID` and `agent_task_runs WITHOUT ROWID` hardening. The custom audit +metadata, append-only audit/migration ledger, immutable completed agent-run history, +monitoring-JSON, and automation replacement-integrity triggers are reviewed additions because +Drizzle does not model them. There is no compatibility preflight or upgrade path for an intermediate rewrite database: every test and the final cutover start empty and apply this one baseline. Each schema slice regenerates the baseline, reviews the complete SQL/snapshot diff, and updates the explicit manifest checksums. diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index b2e141466..4fbfab5e8 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -819,3 +819,25 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o - The reviewed parity inventory now marks the 11 task operations and `/tasks` route implemented. This closes only the task portion of Phase 3. Agent, report, incident, notification, job, monitoring API, overview, cache/metrics, and real worker execution remain explicit gates. + +### 2026-08-07 — Phase 3 agent status and task-history slice + +- A reviewed, code-owned directory defines the five Dashboard automation agents independently of + Gateway connection state. Typed `agents:read` and `agents:write` capabilities expose exact + configuration, one/all current statuses, keyset-paginated task history, and scoped metadata + updates without treating mutable Gateway discovery as application authorization. +- `agent_task_runs` retains one active interval per configured agent and immutable completed + history in a strict `WITHOUT ROWID` table. Every transition revalidates persisted rows, records + the user or automation actor, and runs behind immediate-write admission. State changes append a + durable `agents.status` realtime invalidation in the same transaction and wake delivery only + after commit; same-task heartbeats update activity without unbounded realtime-event growth. +- `/agents` uses the shared Dashboard shell and presentation primitives, query-backed TanStack DB + collections for normalized definitions and live statuses, TanStack Query for keyset-paginated + history, TanStack Table, and the shared virtualizer. Durable realtime events invalidate the + relevant collection/query roots; a 30-second fallback begins only after the terminal event + stream closes. Current-task mutation remains an authenticated automation boundary rather than a + browser editing control. +- The parity inventory now marks the five agent operations and `/agents` route implemented. + Persistent OpenClaw/Gateway availability and session state remain Phase 4 work; reports, + incidents, notifications, schedules/jobs, overview, cache/metrics, and the real worker remain + open Phase 3 gates. diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index dcf473a33..a56580581 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -18,6 +18,11 @@ | `accountSecurity.stepUpTotp` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpTotp.input.schema.json) | [output](./schemas/accountSecurity.stepUpTotp.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Rotates the session after a fresh TOTP proof. | | `accountSecurity.stepUpWebAuthn` | mutation | account-security | Authenticated browser session | [input](./schemas/accountSecurity.stepUpWebAuthn.input.schema.json) | [output](./schemas/accountSecurity.stepUpWebAuthn.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `mfa_enrollment_required` | Consumes a WebAuthn challenge and rotates the verified session. | | `accountSecurity.summary` | query | account-security | Authenticated browser session | [input](./schemas/accountSecurity.summary.input.schema.json) | [output](./schemas/accountSecurity.summary.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns MFA inventory and server-relative recent-auth state. | +| `agents.getConfiguration` | query | agents | Authenticated: agents:read | [input](./schemas/agents.getConfiguration.input.schema.json) | [output](./schemas/agents.getConfiguration.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns the reviewed Dashboard-owned agent directory. | +| `agents.getStatus` | query | agents | Authenticated: agents:read | [input](./schemas/agents.getStatus.input.schema.json) | [output](./schemas/agents.getStatus.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Returns the current task projection for one configured agent. | +| `agents.listStatuses` | query | agents | Authenticated: agents:read | [input](./schemas/agents.listStatuses.input.schema.json) | [output](./schemas/agents.listStatuses.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns current task projections for all configured agents. | +| `agents.listTaskHistory` | query | agents | Authenticated: agents:read | [input](./schemas/agents.listTaskHistory.input.schema.json) | [output](./schemas/agents.listTaskHistory.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Lists durable newest-first agent current-task history. | +| `agents.updateMetadata` | mutation | agents | Authenticated automation principal: agents:write | [input](./schemas/agents.updateMetadata.input.schema.json) | [output](./schemas/agents.updateMetadata.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Atomically starts, touches, replaces, or clears one agent current task. | | `auth.beginWebAuthnLogin` | mutation | auth | Pending MFA login | [input](./schemas/auth.beginWebAuthnLogin.input.schema.json) | [output](./schemas/auth.beginWebAuthnLogin.output.schema.json) | `CONFLICT`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Creates one pending-login-bound WebAuthn assertion challenge. | | `auth.bootstrap` | mutation | auth | Public | [input](./schemas/auth.bootstrap.input.schema.json) | [output](./schemas/auth.bootstrap.output.schema.json) | `CONFLICT`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Verifies the Gateway credential and creates the sole first user. | | `auth.changePassword` | mutation | auth | Browser session when MFA is disabled; recent MFA when enabled | [input](./schemas/auth.changePassword.input.schema.json) | [output](./schemas/auth.changePassword.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `step_up_required` | Changes the password, rotates the current session, and revokes the rest. | @@ -40,7 +45,7 @@ | `automationSecurity.replaceCapabilities` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.replaceCapabilities.input.schema.json) | [output](./schemas/automationSecurity.replaceCapabilities.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Atomically replaces a principal's least-privilege capability set. | | `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. | -| `events.stream` | subscription | events | Authenticated; per-topic: notifications:read, reports:read, tasks:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | +| `events.stream` | subscription | events | Authenticated; per-topic: agents:read, notifications:read, reports:read, tasks:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | | `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.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/schemas/agents.getConfiguration.input.schema.json b/greenfield/docs/generated/schemas/agents.getConfiguration.input.schema.json new file mode 100644 index 000000000..dfee2a4bd --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.getConfiguration.input.schema.json @@ -0,0 +1,8 @@ +{ + "$id": "urn:mira-dashboard:agents.getConfiguration.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.getConfiguration.output.schema.json b/greenfield/docs/generated/schemas/agents.getConfiguration.output.schema.json new file mode 100644 index 000000000..660df3e4b --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.getConfiguration.output.schema.json @@ -0,0 +1,70 @@ +{ + "$id": "urn:mira-dashboard:agents.getConfiguration.output", + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string", + "maxLength": 256, + "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\\u202A-\\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]*$" + } + ] + }, + "displayName": { + "type": "string", + "maxLength": 64, + "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\\u202A-\\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]*$" + } + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "role": { + "enum": [ + "primary", + "specialist" + ], + "type": "string" + } + }, + "required": [ + "description", + "displayName", + "id", + "role" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 16, + "$comment": "Live Valibot validation additionally requires every reviewed agent ID to be unique." + } + }, + "required": [ + "agents" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.getStatus.input.schema.json b/greenfield/docs/generated/schemas/agents.getStatus.input.schema.json new file mode 100644 index 000000000..4a9757e97 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.getStatus.input.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "urn:mira-dashboard:agents.getStatus.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.getStatus.output.schema.json b/greenfield/docs/generated/schemas/agents.getStatus.output.schema.json new file mode 100644 index 000000000..c07d22ca4 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.getStatus.output.schema.json @@ -0,0 +1,77 @@ +{ + "$id": "urn:mira-dashboard:agents.getStatus.output", + "oneOf": [ + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "idle" + } + }, + "required": [ + "agentId", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "currentTask": { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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]*$" + } + ] + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "working" + } + }, + "required": [ + "agentId", + "currentTask", + "lastActivityAtMs", + "startedAtMs", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires working-status activity not to precede task start." + } + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.listStatuses.input.schema.json b/greenfield/docs/generated/schemas/agents.listStatuses.input.schema.json new file mode 100644 index 000000000..23ea368c4 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.listStatuses.input.schema.json @@ -0,0 +1,8 @@ +{ + "$id": "urn:mira-dashboard:agents.listStatuses.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.listStatuses.output.schema.json b/greenfield/docs/generated/schemas/agents.listStatuses.output.schema.json new file mode 100644 index 000000000..45ba2e33d --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.listStatuses.output.schema.json @@ -0,0 +1,92 @@ +{ + "$id": "urn:mira-dashboard:agents.listStatuses.output", + "type": "object", + "properties": { + "statuses": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "idle" + } + }, + "required": [ + "agentId", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "currentTask": { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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]*$" + } + ] + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "working" + } + }, + "required": [ + "agentId", + "currentTask", + "lastActivityAtMs", + "startedAtMs", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires working-status activity not to precede task start." + } + ] + }, + "minItems": 1, + "maxItems": 16, + "$comment": "Live Valibot validation additionally requires one canonically ordered status per configured agent ID." + } + }, + "required": [ + "statuses" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.listTaskHistory.input.schema.json b/greenfield/docs/generated/schemas/agents.listTaskHistory.input.schema.json new file mode 100644 index 000000000..00036f545 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.listTaskHistory.input.schema.json @@ -0,0 +1,43 @@ +{ + "$id": "urn:mira-dashboard:agents.listTaskHistory.input", + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "cursor": { + "type": "object", + "properties": { + "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}$" + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "startedAtMs" + ], + "additionalProperties": false + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.listTaskHistory.output.schema.json b/greenfield/docs/generated/schemas/agents.listTaskHistory.output.schema.json new file mode 100644 index 000000000..a6e7f1031 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.listTaskHistory.output.schema.json @@ -0,0 +1,159 @@ +{ + "$id": "urn:mira-dashboard:agents.listTaskHistory.output", + "type": "object", + "properties": { + "nextCursor": { + "type": "object", + "properties": { + "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}$" + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "startedAtMs" + ], + "additionalProperties": false + }, + "runs": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "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}$" + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "status": { + "const": "active" + }, + "task": { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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": [ + "agentId", + "id", + "lastActivityAtMs", + "startedAtMs", + "status", + "task" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires active-run activity not to precede task start." + }, + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "completedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "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}$" + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "status": { + "const": "completed" + }, + "task": { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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": [ + "agentId", + "completedAtMs", + "id", + "lastActivityAtMs", + "startedAtMs", + "status", + "task" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires ordered start, activity, and completion timestamps." + } + ] + }, + "maxItems": 100, + "$comment": "Live Valibot validation additionally requires strict newest-first agent task-run ordering by start timestamp and ID." + } + }, + "required": [ + "runs" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires an agent task-history cursor to identify the returned last row.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.updateMetadata.input.schema.json b/greenfield/docs/generated/schemas/agents.updateMetadata.input.schema.json new file mode 100644 index 000000000..f2a6bd9a1 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.updateMetadata.input.schema.json @@ -0,0 +1,39 @@ +{ + "$id": "urn:mira-dashboard:agents.updateMetadata.input", + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "currentTask": { + "anyOf": [ + { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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]*$" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "agentId", + "currentTask" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/agents.updateMetadata.output.schema.json b/greenfield/docs/generated/schemas/agents.updateMetadata.output.schema.json new file mode 100644 index 000000000..d9b8d0207 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.updateMetadata.output.schema.json @@ -0,0 +1,77 @@ +{ + "$id": "urn:mira-dashboard:agents.updateMetadata.output", + "oneOf": [ + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "idle" + } + }, + "required": [ + "agentId", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "currentTask": { + "type": "string", + "maxLength": 512, + "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\\u202A-\\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]*$" + } + ] + }, + "lastActivityAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "working" + } + }, + "required": [ + "agentId", + "currentTask", + "lastActivityAtMs", + "startedAtMs", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires working-status activity not to precede task start." + } + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json index 18281d19f..caa1b3eef 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json @@ -6,6 +6,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -13,7 +15,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "id": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json index ec1802fe0..ee69c82a8 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json @@ -75,6 +75,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -82,7 +84,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { @@ -146,6 +148,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -153,7 +157,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json index f109aefb8..6a9f473ce 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json @@ -24,6 +24,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -31,7 +33,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { @@ -95,6 +97,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -102,7 +106,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json index 08b454687..883779c99 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json @@ -49,6 +49,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -56,7 +58,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { @@ -120,6 +122,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -127,7 +131,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json index d9a694b00..8784a52b9 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json @@ -17,6 +17,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -24,7 +26,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true } }, diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json index aeb8308ed..b9e4a26e3 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json @@ -24,6 +24,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -31,7 +33,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { @@ -95,6 +97,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -102,7 +106,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/events.stream.input.schema.json b/greenfield/docs/generated/schemas/events.stream.input.schema.json index 2cc44b3bf..4eddcfedc 100644 --- a/greenfield/docs/generated/schemas/events.stream.input.schema.json +++ b/greenfield/docs/generated/schemas/events.stream.input.schema.json @@ -12,6 +12,7 @@ "type": "array", "items": { "enum": [ + "agents.status", "monitoring.incidents", "monitoring.notifications", "monitoring.reports", diff --git a/greenfield/docs/generated/schemas/events.stream.output.schema.json b/greenfield/docs/generated/schemas/events.stream.output.schema.json index 848e71d2e..acf81be25 100644 --- a/greenfield/docs/generated/schemas/events.stream.output.schema.json +++ b/greenfield/docs/generated/schemas/events.stream.output.schema.json @@ -9,6 +9,58 @@ "properties": { "event": { "oneOf": [ + { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "entityType": { + "const": "agent" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "operation": { + "enum": [ + "updated" + ], + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "topic": { + "const": "agents.status" + } + }, + "required": [ + "entityId", + "entityType", + "occurredAtMs", + "operation", + "payload", + "topic" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json index 6fd0f79cb..b7c16afc9 100644 --- a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json +++ b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json @@ -133,6 +133,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -140,7 +142,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "method": { @@ -183,6 +185,8 @@ "type": "array", "items": { "enum": [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", @@ -190,7 +194,7 @@ ], "type": "string" }, - "maxItems": 4, + "maxItems": 6, "uniqueItems": true }, "replacementCredentialId": { diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index dd4a94dba..0f3f2bf86 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql +++ b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql @@ -139,7 +139,7 @@ CREATE TABLE `automation_principal_capabilities` ( `principal_id` text NOT NULL, CONSTRAINT `automation_principal_capabilities_pk` PRIMARY KEY(`principal_id`, `capability`), CONSTRAINT `fk_automation_principal_capabilities_principal_id_automation_principals_id_fk` FOREIGN KEY (`principal_id`) REFERENCES `automation_principals`(`id`) ON DELETE CASCADE, - CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('notifications:read', 'reports:read', 'tasks:read', 'tasks:write')), + CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')), CONSTRAINT "automation_principal_capabilities_granted_at_check" CHECK("granted_at" BETWEEN 0 AND 8640000000000000) ) STRICT; --> statement-breakpoint @@ -482,6 +482,65 @@ CREATE INDEX `task_updates_task_created_id_idx` ON `task_updates` (`task_id`,`cr CREATE INDEX `tasks_updated_id_idx` ON `tasks` (`updated_at`,`id`);--> statement-breakpoint CREATE INDEX `tasks_status_priority_updated_id_idx` ON `tasks` (`status`,`priority`,`updated_at`,`id`);--> statement-breakpoint CREATE INDEX `tasks_assignee_status_updated_id_idx` ON `tasks` (`assignee`,`status`,`updated_at`,`id`);--> statement-breakpoint +CREATE TABLE `agent_task_runs` ( + `agent_id` text NOT NULL, + `completed_at` integer, + `completed_by_id` text, + `completed_by_kind` text, + `id` text PRIMARY KEY NOT NULL, + `last_activity_at` integer NOT NULL, + `last_updated_by_id` text NOT NULL, + `last_updated_by_kind` text NOT NULL, + `started_at` integer NOT NULL, + `started_by_id` text NOT NULL, + `started_by_kind` text NOT NULL, + `task` text NOT NULL, + CONSTRAINT "agent_task_runs_agent_id_check" CHECK(length("agent_id") BETWEEN 1 AND 64 AND instr("agent_id", char(0)) = 0 AND "agent_id" = lower("agent_id") AND substr("agent_id", 1, 1) GLOB '[a-z0-9]' AND "agent_id" NOT GLOB '*[^a-z0-9._-]*'), + CONSTRAINT "agent_task_runs_completed_actor_check" CHECK(("completed_at" IS NULL AND "completed_by_kind" IS NULL AND "completed_by_id" IS NULL) OR ("completed_at" IS NOT NULL AND "completed_by_kind" IS NOT NULL AND "completed_by_id" IS NOT NULL AND (("completed_by_kind" = 'user' AND length("completed_by_id") = 36 AND instr("completed_by_id", char(0)) = 0 AND length(replace("completed_by_id", '-', '')) = 32 AND replace("completed_by_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr("completed_by_id", 9, 1) = '-' AND substr("completed_by_id", 14, 1) = '-' AND substr("completed_by_id", 15, 1) = '7' AND substr("completed_by_id", 19, 1) = '-' AND substr("completed_by_id", 20, 1) GLOB '[89ab]' AND substr("completed_by_id", 24, 1) = '-') OR ("completed_by_kind" = 'automation' AND length("completed_by_id") BETWEEN 1 AND 64 AND instr("completed_by_id", char(0)) = 0 AND "completed_by_id" = lower("completed_by_id") AND substr("completed_by_id", 1, 1) GLOB '[a-z0-9]' AND "completed_by_id" NOT GLOB '*[^a-z0-9._-]*')))), + CONSTRAINT "agent_task_runs_id_check" CHECK(length("id") = 36 AND instr("id", char(0)) = 0 AND length(replace("id", '-', '')) = 32 AND replace("id", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr("id", 9, 1) = '-' AND substr("id", 14, 1) = '-' AND substr("id", 15, 1) = '7' AND substr("id", 19, 1) = '-' AND substr("id", 20, 1) GLOB '[89ab]' AND substr("id", 24, 1) = '-'), + CONSTRAINT "agent_task_runs_last_updated_actor_check" CHECK(("last_updated_by_kind" = 'user' AND length("last_updated_by_id") = 36 AND instr("last_updated_by_id", char(0)) = 0 AND length(replace("last_updated_by_id", '-', '')) = 32 AND replace("last_updated_by_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr("last_updated_by_id", 9, 1) = '-' AND substr("last_updated_by_id", 14, 1) = '-' AND substr("last_updated_by_id", 15, 1) = '7' AND substr("last_updated_by_id", 19, 1) = '-' AND substr("last_updated_by_id", 20, 1) GLOB '[89ab]' AND substr("last_updated_by_id", 24, 1) = '-') OR ("last_updated_by_kind" = 'automation' AND length("last_updated_by_id") BETWEEN 1 AND 64 AND instr("last_updated_by_id", char(0)) = 0 AND "last_updated_by_id" = lower("last_updated_by_id") AND substr("last_updated_by_id", 1, 1) GLOB '[a-z0-9]' AND "last_updated_by_id" NOT GLOB '*[^a-z0-9._-]*')), + CONSTRAINT "agent_task_runs_started_actor_check" CHECK(("started_by_kind" = 'user' AND length("started_by_id") = 36 AND instr("started_by_id", char(0)) = 0 AND length(replace("started_by_id", '-', '')) = 32 AND replace("started_by_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr("started_by_id", 9, 1) = '-' AND substr("started_by_id", 14, 1) = '-' AND substr("started_by_id", 15, 1) = '7' AND substr("started_by_id", 19, 1) = '-' AND substr("started_by_id", 20, 1) GLOB '[89ab]' AND substr("started_by_id", 24, 1) = '-') OR ("started_by_kind" = 'automation' AND length("started_by_id") BETWEEN 1 AND 64 AND instr("started_by_id", char(0)) = 0 AND "started_by_id" = lower("started_by_id") AND substr("started_by_id", 1, 1) GLOB '[a-z0-9]' AND "started_by_id" NOT GLOB '*[^a-z0-9._-]*')), + CONSTRAINT "agent_task_runs_task_check" CHECK(length("task") BETWEEN 1 AND 512 AND instr("task", char(0)) = 0 AND length(trim("task", char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279))) > 0 AND "task" NOT GLOB ('*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || char(173) || char(1536) || '-' || char(1541) || char(1564) || char(1757) || char(1807) || char(2192) || '-' || char(2193) || char(2274) || char(6158) || char(8203) || '-' || char(8207) || char(8234) || '-' || char(8238) || char(8288) || '-' || char(8292) || char(8294) || '-' || char(8303) || char(65279) || char(65529) || '-' || char(65531) || char(69821) || char(69837) || char(78896) || '-' || char(78911) || char(113824) || '-' || char(113827) || char(119155) || '-' || char(119162) || char(917505) || char(917536) || '-' || char(917631) || ']*')), + CONSTRAINT "agent_task_runs_time_check" CHECK("started_at" BETWEEN 0 AND 8640000000000000 AND "last_activity_at" BETWEEN 0 AND 8640000000000000 AND "last_activity_at" >= "started_at" AND ("completed_at" IS NULL OR ("completed_at" BETWEEN 0 AND 8640000000000000 AND "completed_at" >= "last_activity_at"))) +) STRICT, WITHOUT ROWID; +--> statement-breakpoint +CREATE UNIQUE INDEX `agent_task_runs_one_active_agent_idx` ON `agent_task_runs` (`agent_id`) WHERE "agent_task_runs"."completed_at" IS NULL;--> statement-breakpoint +CREATE INDEX `agent_task_runs_started_id_idx` ON `agent_task_runs` (`started_at`,`id`);--> statement-breakpoint +CREATE INDEX `agent_task_runs_agent_started_id_idx` ON `agent_task_runs` (`agent_id`,`started_at`,`id`); +--> statement-breakpoint +CREATE TRIGGER agent_task_runs_reject_replace +BEFORE INSERT ON agent_task_runs +WHEN EXISTS (SELECT 1 FROM agent_task_runs WHERE id = NEW.id) +BEGIN + SELECT RAISE(ABORT, 'agent_task_runs identity is immutable'); +END; +--> statement-breakpoint +CREATE TRIGGER agent_task_runs_reject_identity_update +BEFORE UPDATE OF agent_id, id, started_at, started_by_id, started_by_kind, task ON agent_task_runs +BEGIN + SELECT RAISE(ABORT, 'agent_task_runs identity is immutable'); +END; +--> statement-breakpoint +CREATE TRIGGER agent_task_runs_reject_activity_regression +BEFORE UPDATE OF last_activity_at ON agent_task_runs +WHEN NEW.last_activity_at < OLD.last_activity_at +BEGIN + SELECT RAISE(ABORT, 'agent_task_runs activity is monotonic'); +END; +--> statement-breakpoint +CREATE TRIGGER agent_task_runs_reject_completed_update +BEFORE UPDATE ON agent_task_runs +WHEN OLD.completed_at IS NOT NULL +BEGIN + SELECT RAISE(ABORT, 'completed agent_task_runs are immutable'); +END; +--> statement-breakpoint +CREATE TRIGGER agent_task_runs_reject_delete +BEFORE DELETE ON agent_task_runs +BEGIN + SELECT RAISE(ABORT, 'agent_task_runs history cannot be deleted'); +END; +--> statement-breakpoint CREATE TRIGGER automation_credentials_validate_replacement_insert BEFORE INSERT ON automation_credentials WHEN NEW.replaces_credential_id IS NOT NULL diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index 73dfc934d..7ae3f156b 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json +++ b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json @@ -1,9 +1,13 @@ { "version": "7", "dialect": "sqlite", - "id": "7f6021f7-3f3d-4d7f-ab30-5ccdf723711c", + "id": "17f69d2d-da53-412b-ba2b-4f803e96050c", "prevIds": [], "ddl": [ + { + "name": "agent_task_runs", + "entityType": "tables" + }, { "name": "audit_events", "entityType": "tables" @@ -104,6 +108,126 @@ "name": "users", "entityType": "tables" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent_id", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "completed_at", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "completed_by_id", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "completed_by_kind", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_activity_at", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_updated_by_id", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_updated_by_kind", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_at", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_by_id", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_by_kind", + "entityType": "columns", + "table": "agent_task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "task", + "entityType": "columns", + "table": "agent_task_runs" + }, { "type": "text", "notNull": true, @@ -2519,6 +2643,15 @@ "entityType": "pks", "table": "task_labels" }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "agent_task_runs_pk", + "table": "agent_task_runs", + "entityType": "pks" + }, { "columns": [ "id" @@ -2726,6 +2859,60 @@ "table": "users", "entityType": "pks" }, + { + "columns": [ + { + "value": "agent_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"agent_task_runs\".\"completed_at\" IS NULL", + "origin": "manual", + "name": "agent_task_runs_one_active_agent_idx", + "entityType": "indexes", + "table": "agent_task_runs" + }, + { + "columns": [ + { + "value": "started_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_task_runs_started_id_idx", + "entityType": "indexes", + "table": "agent_task_runs" + }, + { + "columns": [ + { + "value": "agent_id", + "isExpression": false + }, + { + "value": "started_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_task_runs_agent_started_id_idx", + "entityType": "indexes", + "table": "agent_task_runs" + }, { "columns": [ { @@ -3698,6 +3885,48 @@ "entityType": "indexes", "table": "users" }, + { + "value": "length(\"agent_id\") BETWEEN 1 AND 64 AND instr(\"agent_id\", char(0)) = 0 AND \"agent_id\" = lower(\"agent_id\") AND substr(\"agent_id\", 1, 1) GLOB '[a-z0-9]' AND \"agent_id\" NOT GLOB '*[^a-z0-9._-]*'", + "name": "agent_task_runs_agent_id_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "(\"completed_at\" IS NULL AND \"completed_by_kind\" IS NULL AND \"completed_by_id\" IS NULL) OR (\"completed_at\" IS NOT NULL AND \"completed_by_kind\" IS NOT NULL AND \"completed_by_id\" IS NOT NULL AND ((\"completed_by_kind\" = 'user' AND length(\"completed_by_id\") = 36 AND instr(\"completed_by_id\", char(0)) = 0 AND length(replace(\"completed_by_id\", '-', '')) = 32 AND replace(\"completed_by_id\", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(\"completed_by_id\", 9, 1) = '-' AND substr(\"completed_by_id\", 14, 1) = '-' AND substr(\"completed_by_id\", 15, 1) = '7' AND substr(\"completed_by_id\", 19, 1) = '-' AND substr(\"completed_by_id\", 20, 1) GLOB '[89ab]' AND substr(\"completed_by_id\", 24, 1) = '-') OR (\"completed_by_kind\" = 'automation' AND length(\"completed_by_id\") BETWEEN 1 AND 64 AND instr(\"completed_by_id\", char(0)) = 0 AND \"completed_by_id\" = lower(\"completed_by_id\") AND substr(\"completed_by_id\", 1, 1) GLOB '[a-z0-9]' AND \"completed_by_id\" NOT GLOB '*[^a-z0-9._-]*')))", + "name": "agent_task_runs_completed_actor_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "length(\"id\") = 36 AND instr(\"id\", char(0)) = 0 AND length(replace(\"id\", '-', '')) = 32 AND replace(\"id\", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(\"id\", 9, 1) = '-' AND substr(\"id\", 14, 1) = '-' AND substr(\"id\", 15, 1) = '7' AND substr(\"id\", 19, 1) = '-' AND substr(\"id\", 20, 1) GLOB '[89ab]' AND substr(\"id\", 24, 1) = '-'", + "name": "agent_task_runs_id_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "(\"last_updated_by_kind\" = 'user' AND length(\"last_updated_by_id\") = 36 AND instr(\"last_updated_by_id\", char(0)) = 0 AND length(replace(\"last_updated_by_id\", '-', '')) = 32 AND replace(\"last_updated_by_id\", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(\"last_updated_by_id\", 9, 1) = '-' AND substr(\"last_updated_by_id\", 14, 1) = '-' AND substr(\"last_updated_by_id\", 15, 1) = '7' AND substr(\"last_updated_by_id\", 19, 1) = '-' AND substr(\"last_updated_by_id\", 20, 1) GLOB '[89ab]' AND substr(\"last_updated_by_id\", 24, 1) = '-') OR (\"last_updated_by_kind\" = 'automation' AND length(\"last_updated_by_id\") BETWEEN 1 AND 64 AND instr(\"last_updated_by_id\", char(0)) = 0 AND \"last_updated_by_id\" = lower(\"last_updated_by_id\") AND substr(\"last_updated_by_id\", 1, 1) GLOB '[a-z0-9]' AND \"last_updated_by_id\" NOT GLOB '*[^a-z0-9._-]*')", + "name": "agent_task_runs_last_updated_actor_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "(\"started_by_kind\" = 'user' AND length(\"started_by_id\") = 36 AND instr(\"started_by_id\", char(0)) = 0 AND length(replace(\"started_by_id\", '-', '')) = 32 AND replace(\"started_by_id\", '-', '') NOT GLOB '*[^0-9a-f]*' AND substr(\"started_by_id\", 9, 1) = '-' AND substr(\"started_by_id\", 14, 1) = '-' AND substr(\"started_by_id\", 15, 1) = '7' AND substr(\"started_by_id\", 19, 1) = '-' AND substr(\"started_by_id\", 20, 1) GLOB '[89ab]' AND substr(\"started_by_id\", 24, 1) = '-') OR (\"started_by_kind\" = 'automation' AND length(\"started_by_id\") BETWEEN 1 AND 64 AND instr(\"started_by_id\", char(0)) = 0 AND \"started_by_id\" = lower(\"started_by_id\") AND substr(\"started_by_id\", 1, 1) GLOB '[a-z0-9]' AND \"started_by_id\" NOT GLOB '*[^a-z0-9._-]*')", + "name": "agent_task_runs_started_actor_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "length(\"task\") BETWEEN 1 AND 512 AND instr(\"task\", char(0)) = 0 AND length(trim(\"task\", char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279))) > 0 AND \"task\" NOT GLOB ('*[' || char(1) || '-' || char(31) || char(127) || '-' || char(159) || char(173) || char(1536) || '-' || char(1541) || char(1564) || char(1757) || char(1807) || char(2192) || '-' || char(2193) || char(2274) || char(6158) || char(8203) || '-' || char(8207) || char(8234) || '-' || char(8238) || char(8288) || '-' || char(8292) || char(8294) || '-' || char(8303) || char(65279) || char(65529) || '-' || char(65531) || char(69821) || char(69837) || char(78896) || '-' || char(78911) || char(113824) || '-' || char(113827) || char(119155) || '-' || char(119162) || char(917505) || char(917536) || '-' || char(917631) || ']*')", + "name": "agent_task_runs_task_check", + "entityType": "checks", + "table": "agent_task_runs" + }, + { + "value": "\"started_at\" BETWEEN 0 AND 8640000000000000 AND \"last_activity_at\" BETWEEN 0 AND 8640000000000000 AND \"last_activity_at\" >= \"started_at\" AND (\"completed_at\" IS NULL OR (\"completed_at\" BETWEEN 0 AND 8640000000000000 AND \"completed_at\" >= \"last_activity_at\"))", + "name": "agent_task_runs_time_check", + "entityType": "checks", + "table": "agent_task_runs" + }, { "value": "length(\"action\") BETWEEN 1 AND 128 AND instr(\"action\", char(0)) = 0 AND substr(\"action\", 1, 1) GLOB '[a-z0-9]' AND \"action\" = lower(\"action\") AND \"action\" NOT GLOB '*[^a-z0-9._-]*'", "name": "audit_events_action_check", @@ -3981,7 +4210,7 @@ "table": "automation_credentials" }, { - "value": "\"capability\" IN ('notifications:read', 'reports:read', 'tasks:read', 'tasks:write')", + "value": "\"capability\" IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')", "name": "automation_principal_capabilities_capability_check", "entityType": "checks", "table": "automation_principal_capabilities" diff --git a/greenfield/scripts/delivery/productionReleaseActivation.test.ts b/greenfield/scripts/delivery/productionReleaseActivation.test.ts index b68937a79..44db0116e 100644 --- a/greenfield/scripts/delivery/productionReleaseActivation.test.ts +++ b/greenfield/scripts/delivery/productionReleaseActivation.test.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { lstat, readdir } from "node:fs/promises"; import path from "node:path"; @@ -54,6 +54,8 @@ const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ }); const temporaryDirectories: string[] = []; +setDefaultTimeout(8000); + afterEach(async () => { await removeProductionDeliveryFixtures(temporaryDirectories); }); diff --git a/greenfield/scripts/documentation/artifacts.test.ts b/greenfield/scripts/documentation/artifacts.test.ts index e39899ecd..011d46f71 100644 --- a/greenfield/scripts/documentation/artifacts.test.ts +++ b/greenfield/scripts/documentation/artifacts.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { monitoringRealtimeTopics } from "../../src/contracts/monitoringRealtime.ts"; +import { + realtimeStreamCapabilities, + realtimeStreamTopics, +} from "../../src/contracts/events.ts"; import { realtimeSubscriptionMaximumTopics } from "../../src/contracts/realtime.ts"; -import { taskRealtimeTopic } from "../../src/contracts/taskRealtime.ts"; import { buildDocumentationArtifacts } from "./artifacts.ts"; const packageManifest = { @@ -69,12 +71,15 @@ describe("generated contract documentation", () => { expect(procedureDocumentation).toContain( "| `auth.status` | query | auth | Public |" ); + expect(procedureDocumentation).toContain( + "| `agents.updateMetadata` | mutation | agents | Authenticated automation principal: agents:write |" + ); expect(procedureDocumentation).toContain( "| None | None | Returns bootstrap, pending MFA" ); expect(procedureDocumentation).toContain("`events.stream`"); expect(procedureDocumentation).toContain( - "Authenticated; per-topic: notifications:read, reports:read" + `Authenticated; per-topic: ${realtimeStreamCapabilities.join(", ")}` ); expect(procedureDocumentation).toContain("`system.runtimeIdentity`"); const rawHttpDocumentation = first.get("raw-http.md"); @@ -128,10 +133,7 @@ describe("generated contract documentation", () => { }), topics: { items: { - enum: [ - ...Object.values(monitoringRealtimeTopics), - taskRealtimeTopic, - ], + enum: realtimeStreamTopics, type: "string", }, maxItems: realtimeSubscriptionMaximumTopics, diff --git a/greenfield/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts index c1fcda725..26b9d5e30 100644 --- a/greenfield/scripts/documentation/jsonSchema.test.ts +++ b/greenfield/scripts/documentation/jsonSchema.test.ts @@ -8,6 +8,8 @@ import { isValidFactorLabel, totpFactorLabelSchema, } from "../../src/contracts/accountSecurity.ts"; +import { agentConfigurationSchema } from "../../src/contracts/agentModel.ts"; +import { listAgentTaskHistoryResultSchema } from "../../src/contracts/agents.ts"; import { authPasswordMaximumLength, authPasswordMinimumLength, @@ -100,9 +102,16 @@ describe("contract JSON Schema conversion", () => { ) ).toMatchObject({ items: { - enum: ["notifications:read", "reports:read", "tasks:read", "tasks:write"], + enum: [ + "agents:read", + "agents:write", + "notifications:read", + "reports:read", + "tasks:read", + "tasks:write", + ], }, - maxItems: 4, + maxItems: 6, type: "array", uniqueItems: true, }); @@ -225,6 +234,27 @@ describe("contract JSON Schema conversion", () => { expect(document).toContain("audit continuation cursor"); }); + test("documents agent directory and task-history refinements", () => { + const directoryDocument = JSON.stringify( + convertContractSchema( + agentConfigurationSchema, + "test.agentConfiguration", + "output" + ) + ); + expect(directoryDocument).toContain("reviewed agent ID to be unique"); + + const historyDocument = JSON.stringify( + convertContractSchema( + listAgentTaskHistoryResultSchema, + "test.agentTaskHistory", + "output" + ) + ); + expect(historyDocument).toContain("strict newest-first agent task-run ordering"); + expect(historyDocument).toContain("task-history cursor"); + }); + test("documents task bounds, canonicalization, and runtime relationships", () => { const titleDocument = JSON.stringify( convertContractSchema(taskTitleSchema, "test.taskTitle", "input") diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 6bf190142..50598ffe2 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -1,6 +1,18 @@ import { toJsonSchema } from "@valibot/to-json-schema"; import { hasValidPossessionFactorInventory } from "../../src/contracts/accountSecurity.ts"; +import { + activeRunTimeIsConsistent, + agentDefinitionsHaveUniqueIds, + canonicalAgentDefinitions, + completedRunTimeIsConsistent, + workingStatusTimeIsConsistent, +} from "../../src/contracts/agentModel.ts"; +import { + agentTaskHistoryCursorIsConsistent, + canonicalAgentStatuses, + newestAgentTaskRunOrderIsStable, +} from "../../src/contracts/agents.ts"; import { authPasswordMaximumLength, authPasswordMinimumLength, @@ -83,6 +95,34 @@ const controlSafeTextJsonSchemaPattern = `^(?![\\s\\S]*(?:${securityLabelControl const noNulJsonSchemaPattern = String.raw`^[^\u0000]*$`; const runtimeCheckComments = new Map([ + [ + agentDefinitionsHaveUniqueIds, + "Live Valibot validation additionally requires every reviewed agent ID to be unique.", + ], + [ + workingStatusTimeIsConsistent, + "Live Valibot validation additionally requires working-status activity not to precede task start.", + ], + [ + activeRunTimeIsConsistent, + "Live Valibot validation additionally requires active-run activity not to precede task start.", + ], + [ + completedRunTimeIsConsistent, + "Live Valibot validation additionally requires ordered start, activity, and completion timestamps.", + ], + [ + newestAgentTaskRunOrderIsStable, + "Live Valibot validation additionally requires strict newest-first agent task-run ordering by start timestamp and ID.", + ], + [ + agentTaskHistoryCursorIsConsistent, + "Live Valibot validation additionally requires an agent task-history cursor to identify the returned last row.", + ], + [ + canonicalAgentStatuses, + "Live Valibot validation additionally requires one canonically ordered status per configured agent ID.", + ], [ automationCredentialTimesAreOrdered, "Live Valibot validation additionally requires credential expiry after creation and revocation no earlier than creation.", @@ -385,6 +425,7 @@ export function convertContractSchema( valibotAction.type === "transform" && (operation === sortWebAuthnTransports || operation === sortApplicationCapabilities || + operation === canonicalAgentDefinitions || operation === canonicalizeTaskStrings || operation === freezeTaskStrings) ) { diff --git a/greenfield/scripts/documentation/markdown.ts b/greenfield/scripts/documentation/markdown.ts index 20326304b..bacb52345 100644 --- a/greenfield/scripts/documentation/markdown.ts +++ b/greenfield/scripts/documentation/markdown.ts @@ -26,12 +26,18 @@ function accessLabel(access: ContractAccess): string { if (access.capabilityPolicy === "per-topic") { return `Authenticated; per-topic: ${access.capabilities.join(", ")}`; } - if ( - access.principalKinds?.length === 1 && - access.principalKinds[0] === "session" && - access.capabilities.length === 0 - ) { - return "Authenticated browser session"; + const principalKind = + access.principalKinds?.length === 1 + ? access.principalKinds[0] + : undefined; + if (principalKind !== undefined) { + const principalLabel = + principalKind === "session" + ? "browser session" + : "automation principal"; + return access.capabilities.length === 0 + ? `Authenticated ${principalLabel}` + : `Authenticated ${principalLabel}: ${access.capabilities.join(", ")}`; } return `Authenticated: ${access.capabilities.join(", ")}`; } diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 4c290b0b2..54c3b8fed 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { Redacted } from "effect"; +import { createAgentRepository } from "../server/domains/agents/repository.ts"; +import { createAgentService } from "../server/domains/agents/service.ts"; import { createAuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import { createAuthenticationLifecycleRepository } from "../server/domains/security/authenticationLifecycleRepository.ts"; import { @@ -72,6 +74,7 @@ import { createServer, type ApplicationServer, type ServerOptions } from "./serv /** Production composition inputs above the generic Bun/tRPC server primitive. */ export interface DashboardServerOptions extends Omit< ServerOptions, + | "agentService" | "authenticateCredential" | "applicationRuntime" | "authenticationLifecycle" @@ -242,14 +245,21 @@ export async function createDashboardServer( repository: createSecurityAuditLifecycleRepository(database), sessionIdleDurationMs: options.sessionIdleDurationMs, }); - const taskNow = options.now; + const domainNow = options.now; + const agentService = createAgentService({ + ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), + repository: createAgentRepository(database, databaseRuntime), + wakeEventPump: () => + options.applicationRuntime.services.realtimeEvents.wake(), + }); const taskService = createTaskService({ - ...(taskNow === undefined ? {} : { nowMs: () => taskNow().getTime() }), + ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), repository: createTaskRepository(database, databaseRuntime), wakeEventPump: () => options.applicationRuntime.services.realtimeEvents.wake(), }); const serverOptions: ServerOptions = { + agentService, applicationRuntime: options.applicationRuntime, authenticateCredential: (credential) => authenticator.authenticate(credential), diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index a2b58e2be..015aaf7f7 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -2,6 +2,7 @@ import { secondsToMilliseconds } from "date-fns"; import * as v from "valibot"; import { healthLivenessPath, healthReadinessPath } from "../contracts/system.ts"; +import type { AgentService } from "../server/domains/agents/service.ts"; import type { AuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import type { AutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; @@ -123,6 +124,7 @@ export { /** Bun server startup dependencies and listen options. */ export interface ServerOptions { + readonly agentService: AgentService["Service"]; readonly applicationRuntime: ApplicationRuntime; readonly authenticationLifecycle: AuthenticationLifecycleService; readonly automationSecurityLifecycle: AutomationSecurityLifecycleService; @@ -169,6 +171,7 @@ export async function createServer(options: ServerOptions): Promise createRequestContext({ + agentService: options.agentService, applicationRuntime: options.applicationRuntime, authenticationClientSourceId, authenticationCredential: credentials.authentication, diff --git a/greenfield/src/browser/agents/AgentHistoryTable.tsx b/greenfield/src/browser/agents/AgentHistoryTable.tsx new file mode 100644 index 000000000..74b927ef1 --- /dev/null +++ b/greenfield/src/browser/agents/AgentHistoryTable.tsx @@ -0,0 +1,126 @@ +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; +import { History } from "lucide-react"; + +import type { AgentTaskRun } from "../../contracts/agentModel.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { DataTable } from "../ui/DataTable.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Text } from "../ui/Text.tsx"; +import { Virtualizer, type VirtualizerRenderState } from "../ui/Virtualizer.tsx"; + +const minimumVirtualizedRows = 50; +const historyTableFeatures = tableFeatures({}); +const historyColumnHelper = createColumnHelper< + typeof historyTableFeatures, + AgentTaskRun +>(); + +const historyColumns = historyColumnHelper.columns([ + historyColumnHelper.accessor("agentId", { + cell: ({ getValue }) => ( + + {getValue()} + + ), + header: "Agent", + }), + historyColumnHelper.accessor("task", { + cell: ({ getValue }) => {getValue()}, + header: "Task", + }), + historyColumnHelper.accessor("status", { + cell: ({ getValue }) => ( + + {getValue()} + + ), + header: "Status", + }), + historyColumnHelper.accessor("startedAtMs", { + cell: ({ getValue }) => ( + + ), + header: "Started", + }), + historyColumnHelper.accessor( + (run) => (run.status === "completed" ? run.completedAtMs : undefined), + { + cell: ({ getValue }) => { + const completedAtMs = getValue(); + return completedAtMs === undefined ? ( + + In progress + + ) : ( + + ); + }, + header: "Completed", + id: "completedAtMs", + } + ), +]); + +interface AgentHistoryTableProps { + readonly runs: readonly AgentTaskRun[]; +} + +/** @returns Shared table and virtual window for durable agent task history. */ +export function AgentHistoryTable({ runs }: AgentHistoryTableProps) { + const table = useTable({ + columns: historyColumns, + data: runs, + features: historyTableFeatures, + getRowId: (run) => run.id, + }); + const rows = table.getRowModel().rows; + + if (rows.length === 0) { + return ( + + ); + } + + const tableElement = (rowWindow?: VirtualizerRenderState) => ( + + ); + + return ( +
+ + Task history + +
+ {rows.length < minimumVirtualizedRows ? ( + tableElement() + ) : ( + + count={rows.length} + estimateSize={() => 72} + getItemKey={(index) => + rows[index]?.id ?? `missing-agent-history-${index}` + } + > + {(virtualization) => tableElement(virtualization)} + + )} +
+
+ ); +} diff --git a/greenfield/src/browser/agents/AgentStatusGrid.tsx b/greenfield/src/browser/agents/AgentStatusGrid.tsx new file mode 100644 index 000000000..58b0fd873 --- /dev/null +++ b/greenfield/src/browser/agents/AgentStatusGrid.tsx @@ -0,0 +1,126 @@ +import { + Bot, + CircleAlert, + CircleCheck, + LoaderCircle, + type LucideIcon, +} from "lucide-react"; + +import type { AgentDefinition, AgentStatus } from "../../contracts/agentModel.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { Text } from "../ui/Text.tsx"; + +interface AgentStatusCardProps { + readonly agent: AgentDefinition; + readonly status: AgentStatus | undefined; +} + +interface AgentStatusAppearance { + readonly icon: LucideIcon; + readonly label: string; + readonly variant: "default" | "success" | "warning"; +} + +function agentStatusAppearance(status: AgentStatus | undefined): AgentStatusAppearance { + if (status === undefined) { + return { icon: CircleAlert, label: "unavailable", variant: "warning" }; + } + if (status.state === "working") { + return { icon: LoaderCircle, label: "working", variant: "success" }; + } + return { icon: CircleCheck, label: "idle", variant: "default" }; +} + +function AgentStatusDetail({ status }: Pick) { + if (status === undefined) { + return ( + + Current status was not returned + + ); + } + if (status.state === "working") { + return ( +
+ + {status.currentTask} + + + Started {formatDashboardDateTime(status.startedAtMs)} + +
+ ); + } + return ( + + {status.lastActivityAtMs === undefined + ? "No recorded task activity" + : `Last active ${formatDashboardDateTime(status.lastActivityAtMs)}`} + + ); +} + +function AgentStatusCard({ agent, status }: AgentStatusCardProps) { + const appearance = agentStatusAppearance(status); + return ( + +
+
+ + + +
+ + {agent.displayName} + + + {agent.id} · {agent.role} + +
+
+ + + {appearance.label} + +
+ + {agent.description} + + +
+ ); +} + +interface AgentStatusGridProps { + readonly agents: readonly AgentDefinition[]; + readonly statuses: readonly AgentStatus[]; +} + +/** @returns Current status cards joined to the reviewed agent directory. */ +export function AgentStatusGrid({ agents, statuses }: AgentStatusGridProps) { + const statusesById = new Map(statuses.map((status) => [status.agentId, status])); + return ( +
+ + Current status + +
+ {agents.map((agent) => ( + + ))} +
+
+ ); +} diff --git a/greenfield/src/browser/agents/AgentsRoute.test.tsx b/greenfield/src/browser/agents/AgentsRoute.test.tsx new file mode 100644 index 000000000..745852125 --- /dev/null +++ b/greenfield/src/browser/agents/AgentsRoute.test.tsx @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; +import { act } from "react"; + +import type { AgentStatus } from "../../contracts/agentModel.ts"; +import type { AuthStatus } from "../../contracts/auth.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { DashboardBrowserApplication } from "../application.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "../data/dashboardCollections.ts"; +import { createDashboardRouter } from "../router.tsx"; +import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; +import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; + +const { render, screen } = await import("@testing-library/react"); +const userEventModule = await import("@testing-library/user-event"); +const userEvent = userEventModule.default; + +const timestampMs = 1_800_000_000_000; +const authenticatedStatus: AuthStatus = { + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + userAgent: "Agent browser test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, +}; +const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ + authenticate: () => Promise.reject(new TypeError("Unexpected authentication")), + register: () => Promise.reject(new TypeError("Unexpected registration")), +}); + +class AgentTransport implements DashboardTrpcTransport { + mainStatus: AgentStatus = { + agentId: "main", + currentTask: "Implement agents route", + lastActivityAtMs: timestampMs, + startedAtMs: timestampMs, + state: "working", + }; + + mutation(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query(path: string, _input?: unknown): Promise { + switch (path) { + case "auth.status": { + return Promise.resolve(authenticatedStatus); + } + case "agents.getConfiguration": { + return Promise.resolve({ + agents: [ + { + description: "Owns the operator conversation.", + displayName: "Mira", + id: "main", + role: "primary", + }, + { + description: "Researches verified sources.", + displayName: "Researcher", + id: "researcher", + role: "specialist", + }, + ], + }); + } + case "agents.listStatuses": { + return Promise.resolve({ + statuses: [this.mainStatus], + }); + } + case "agents.listTaskHistory": { + return Promise.resolve({ + runs: [ + { + agentId: "main", + id: "019fdc00-0000-7000-8000-000000000001", + lastActivityAtMs: timestampMs, + startedAtMs: timestampMs, + status: "active", + task: "Implement agents route", + }, + ], + }); + } + default: { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + } + } +} + +class PaginatedAgentTransport extends AgentTransport { + override query(path: string, input?: unknown): Promise { + if (path !== "agents.listTaskHistory") return super.query(path, input); + const hasCursor = + typeof input === "object" && + input !== null && + "cursor" in input && + input.cursor !== undefined; + if (hasCursor) { + return Promise.resolve({ + runs: [ + { + agentId: "main", + completedAtMs: timestampMs, + id: "019fdb00-0000-7000-8000-000000000001", + lastActivityAtMs: timestampMs, + startedAtMs: timestampMs - 1000, + status: "completed", + task: "Older agent task", + }, + ], + }); + } + return Promise.resolve({ + nextCursor: { + id: "019fdc00-0000-7000-8000-000000000002", + startedAtMs: timestampMs, + }, + runs: [ + { + agentId: "main", + id: "019fdc00-0000-7000-8000-000000000002", + lastActivityAtMs: timestampMs, + startedAtMs: timestampMs, + status: "active", + task: "Newest agent task", + }, + ], + }); + } +} + +const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; + +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); + for (const queryClient of queryClients.splice(0)) queryClient.clear(); +}); + +describe("Dashboard agents route", () => { + test("renders reviewed roles, live status, and durable history", async () => { + const transport = new AgentTransport(); + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/agents"] }) + ); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) + ); + + expect( + await screen.findByRole("heading", { level: 1, name: "Agents" }) + ).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Mira" })).toBeTruthy(); + expect(screen.getAllByText("Implement agents route")).toHaveLength(2); + expect(screen.getByText("unavailable")).toBeTruthy(); + expect(screen.getByRole("table", { name: "Agent task history" })).toBeTruthy(); + expect(screen.getByRole("link", { name: /Agents/u })).toBeTruthy(); + + transport.mainStatus = { + agentId: "main", + lastActivityAtMs: timestampMs + 1000, + state: "idle", + }; + await act(async () => { + await collections.agents.statuses.utils.refetch(); + }); + expect(screen.getByText("idle")).toBeTruthy(); + }); + + test("loads an older keyset page without replacing the newest history", async () => { + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/agents"] }) + ); + const trpcClient = createDashboardTrpcClient(new PaginatedAgentTransport()); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) + ); + const user = userEvent.setup(); + + expect(await screen.findByText("Newest agent task")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Load older tasks" })); + expect(await screen.findByText("Older agent task")).toBeTruthy(); + expect(screen.getByText("Newest agent task")).toBeTruthy(); + }); +}); diff --git a/greenfield/src/browser/agents/AgentsRoute.tsx b/greenfield/src/browser/agents/AgentsRoute.tsx new file mode 100644 index 000000000..327788a89 --- /dev/null +++ b/greenfield/src/browser/agents/AgentsRoute.tsx @@ -0,0 +1,108 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; + +import type { + AgentDefinition, + AgentStatus, + AgentTaskRun, +} from "../../contracts/agentModel.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { useDashboardBrowserCollections } from "../data/dashboardCollectionsContextValue.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { AgentHistoryTable } from "./AgentHistoryTable.tsx"; +import { agentHistoryQueryOptions } from "./agentQueries.ts"; +import { AgentStatusGrid } from "./AgentStatusGrid.tsx"; +import { useAgentRealtimeInvalidation } from "./useAgentRealtimeInvalidation.ts"; + +const emptyAgents: readonly AgentDefinition[] = Object.freeze([]); +const emptyStatuses: readonly AgentStatus[] = Object.freeze([]); +const emptyRuns: readonly AgentTaskRun[] = Object.freeze([]); + +/** @returns Dashboard-owned agent state, current tasks, and durable task history. */ +export function AgentsRoute() { + useAgentRealtimeInvalidation(); + const client = useDashboardTrpcClient(); + const collections = useDashboardBrowserCollections().agents; + const configuration = useLiveQuery(collections.definitions); + const statuses = useLiveQuery(collections.statuses); + const history = useInfiniteQuery(agentHistoryQueryOptions(client)); + const agents = configuration.data ?? emptyAgents; + const agentStatuses = statuses.data ?? emptyStatuses; + const runs = history.data?.pages.flatMap((page) => page.runs) ?? emptyRuns; + const error = + collections.definitions.utils.lastError ?? + collections.statuses.utils.lastError ?? + history.error; + const pending = configuration.isLoading || statuses.isLoading || history.isPending; + const hasCompleteData = + configuration.isReady && statuses.isReady && history.data !== undefined; + + const refresh = () => { + void Promise.all([ + collections.definitions.utils.refetch(), + collections.statuses.utils.refetch(), + history.refetch(), + ]); + }; + + return ( +
+ + + Refresh + + } + description="Reviewed agent roles, Dashboard-owned current tasks, and durable status history." + eyebrow="Operations" + title="Agents" + /> + {pending && !hasCompleteData && ( + + )} + {error !== null && ( +
+ + +
+ )} + {hasCompleteData && ( +
+ +
+ + {history.hasNextPage && ( + + )} +
+
+ )} +
+ ); +} diff --git a/greenfield/src/browser/agents/agentCollections.ts b/greenfield/src/browser/agents/agentCollections.ts new file mode 100644 index 000000000..4b1899867 --- /dev/null +++ b/greenfield/src/browser/agents/agentCollections.ts @@ -0,0 +1,60 @@ +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/react-db"; +import type { QueryClient } from "@tanstack/react-query"; + +import { + type AgentStatus, + agentDefinitionSchema, + agentStatusSchema, +} from "../../contracts/agentModel.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; +import { agentQueryKey } from "./agentQueries.ts"; + +/** + * Creates the normalized agent directory and status collections for one browser runtime. + * @param queryClient Browser-owned TanStack Query cache. + * @param trpcClient Browser-owned validated transport client. + * @returns Query-backed TanStack DB collections with stable agent keys. + */ +export function createAgentCollections( + queryClient: QueryClient, + trpcClient: DashboardTrpcClient +) { + const definitions = createCollection( + queryCollectionOptions({ + getKey: (agent) => agent.id, + queryClient, + queryFn: async ({ signal }) => { + const result = await trpcClient.query( + "agents.getConfiguration", + {}, + { signal } + ); + return result.agents.map((agent) => ({ ...agent })); + }, + queryKey: [...agentQueryKey, "configuration"], + schema: agentDefinitionSchema, + staleTime: Number.POSITIVE_INFINITY, + }) + ); + const statuses = createCollection( + queryCollectionOptions({ + getKey: (status) => status.agentId, + queryClient, + queryFn: async ({ signal }) => { + const result = await trpcClient.query( + "agents.listStatuses", + {}, + { signal } + ); + return result.statuses.map((status): AgentStatus => ({ ...status })); + }, + queryKey: [...agentQueryKey, "statuses"], + schema: agentStatusSchema, + staleTime: 10_000, + }) + ); + return Object.freeze({ definitions, statuses }); +} + +export type AgentCollections = ReturnType; diff --git a/greenfield/src/browser/agents/agentQueries.ts b/greenfield/src/browser/agents/agentQueries.ts new file mode 100644 index 000000000..9e301841e --- /dev/null +++ b/greenfield/src/browser/agents/agentQueries.ts @@ -0,0 +1,35 @@ +import { infiniteQueryOptions, type QueryClient } from "@tanstack/react-query"; + +import type { + ListAgentTaskHistoryInput, + ListAgentTaskHistoryResult, +} from "../../contracts/agents.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +type AgentHistoryCursor = NonNullable; + +export const agentQueryKey = ["agents"] as const; + +/** @returns Cursor-paginated newest-first agent task history query options. */ +export function agentHistoryQueryOptions(client: DashboardTrpcClient) { + return infiniteQueryOptions({ + initialPageParam: undefined as AgentHistoryCursor | undefined, + queryFn: ({ pageParam, signal }): Promise => + client.query( + "agents.listTaskHistory", + { + ...(pageParam === undefined ? {} : { cursor: pageParam }), + limit: 50, + }, + { signal } + ), + getNextPageParam: (lastPage) => lastPage.nextCursor, + queryKey: [...agentQueryKey, "history"], + staleTime: 10_000, + }); +} + +/** Invalidates every agent projection after one durable status transition. */ +export async function refreshAgentQueries(queryClient: QueryClient): Promise { + await queryClient.invalidateQueries({ queryKey: agentQueryKey }); +} diff --git a/greenfield/src/browser/agents/useAgentRealtimeInvalidation.ts b/greenfield/src/browser/agents/useAgentRealtimeInvalidation.ts new file mode 100644 index 000000000..14a2592e0 --- /dev/null +++ b/greenfield/src/browser/agents/useAgentRealtimeInvalidation.ts @@ -0,0 +1,16 @@ +import { agentRealtimeTopic } from "../../contracts/agentRealtime.ts"; +import { useRealtimeQueryInvalidation } from "../api/useRealtimeQueryInvalidation.ts"; +import { refreshAgentQueries } from "./agentQueries.ts"; + +export const agentRealtimeRefreshDelayMs = 100; +export const agentRealtimeFallbackRefreshIntervalMs = 30_000; + +/** Subscribes the mounted agent surface to durable status invalidations. */ +export function useAgentRealtimeInvalidation(): void { + useRealtimeQueryInvalidation({ + fallbackRefreshIntervalMs: agentRealtimeFallbackRefreshIntervalMs, + refreshDelayMs: agentRealtimeRefreshDelayMs, + refreshQueries: refreshAgentQueries, + topic: agentRealtimeTopic, + }); +} diff --git a/greenfield/src/browser/api/trpcClient.ts b/greenfield/src/browser/api/trpcClient.ts index 3dceb71d8..6b2030ad3 100644 --- a/greenfield/src/browser/api/trpcClient.ts +++ b/greenfield/src/browser/api/trpcClient.ts @@ -68,6 +68,10 @@ async function procedureContractsFor( ): Promise { const domain = name.slice(0, name.indexOf(".")); switch (domain) { + case "agents": { + const module = await import("../../contracts/agents.ts"); + return module.agentProcedureContracts; + } case "accountSecurity": { const module = await import("../../contracts/accountSecurity.ts"); return module.accountSecurityProcedureContracts; diff --git a/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts b/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts new file mode 100644 index 000000000..84f838b34 --- /dev/null +++ b/greenfield/src/browser/api/useRealtimeQueryInvalidation.ts @@ -0,0 +1,65 @@ +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { useDashboardRealtimeHub } from "./realtimeContextValue.ts"; +import type { DashboardRealtimeTopic } from "./realtimeHub.ts"; + +interface RealtimeQueryInvalidationOptions { + readonly fallbackRefreshIntervalMs: number; + readonly refreshDelayMs: number; + readonly refreshQueries: (queryClient: QueryClient) => Promise; + readonly topic: DashboardRealtimeTopic; +} + +/** + * Coalesces one feature topic into query invalidation with terminal-stream fallback. + * @param options Stable feature topic, timing policy, and cache invalidator. + */ +export function useRealtimeQueryInvalidation({ + fallbackRefreshIntervalMs, + refreshDelayMs, + refreshQueries, + topic, +}: RealtimeQueryInvalidationOptions): void { + const hub = useDashboardRealtimeHub(); + const queryClient = useQueryClient(); + + useEffect(() => { + let refreshTimer: ReturnType | undefined; + let fallbackTimer: ReturnType | undefined; + const scheduleRefresh = () => { + if (refreshTimer !== undefined) return; + refreshTimer = setTimeout(() => { + refreshTimer = undefined; + void refreshQueries(queryClient); + }, refreshDelayMs); + }; + const startFallbackRefresh = () => { + scheduleRefresh(); + fallbackTimer ??= setInterval(scheduleRefresh, fallbackRefreshIntervalMs); + }; + const subscription = hub.subscribe([topic], { + onData(output) { + if ( + output.data.kind === "resync-required" || + output.data.event.topic === topic + ) { + scheduleRefresh(); + } + }, + onError: startFallbackRefresh, + }); + return () => { + subscription.unsubscribe(); + if (refreshTimer !== undefined) clearTimeout(refreshTimer); + if (fallbackTimer !== undefined) clearInterval(fallbackTimer); + }; + }, [ + fallbackRefreshIntervalMs, + hub, + queryClient, + refreshDelayMs, + refreshQueries, + topic, + ]); +} diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx index d6e0f0b03..b4684978e 100644 --- a/greenfield/src/browser/application.test.tsx +++ b/greenfield/src/browser/application.test.tsx @@ -6,6 +6,7 @@ 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 { createDashboardRouter } from "./router.tsx"; import type { DashboardWebAuthnClient } from "./security/webauthn/webauthnClient.ts"; import { noOpDashboardRealtimeClient } from "./test/realtime.ts"; @@ -54,18 +55,20 @@ describe("Dashboard browser application", () => { }); }, }); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); - try { - render( - - ); + const view = render( + + ); + try { const heading = await screen.findByRole("heading", { level: 1, name: "Mira Dashboard", @@ -85,6 +88,8 @@ describe("Dashboard browser application", () => { user: { username: "operator" }, }); } finally { + view.unmount(); + await collections.cleanup(); queryClient.clear(); } }); diff --git a/greenfield/src/browser/application.tsx b/greenfield/src/browser/application.tsx index 0956ea96c..d863de95c 100644 --- a/greenfield/src/browser/application.tsx +++ b/greenfield/src/browser/application.tsx @@ -11,6 +11,11 @@ import { DashboardRealtimeProvider } from "./api/realtimeContext.tsx"; import { createDashboardTrpcClient, type DashboardTrpcClient } from "./api/trpcClient.ts"; import { DashboardTrpcProvider } from "./api/trpcContext.tsx"; import { AuthenticatedSessionActivity } from "./auth/AuthenticatedSessionActivity.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "./data/dashboardCollections.ts"; +import { DashboardCollectionsProvider } from "./data/dashboardCollectionsContext.tsx"; import { createDashboardRouter, type DashboardRouter } from "./router.tsx"; import { createDashboardWebAuthnClient, @@ -23,10 +28,12 @@ const queryClient = createDashboardQueryClient(); const realtimeClient = createDashboardRealtimeClient(); const router = createDashboardRouter(); const trpcClient = createDashboardTrpcClient(); +const collections = createDashboardBrowserCollections(queryClient, trpcClient); const webAuthnClient = createDashboardWebAuthnClient(); /** Browser dependencies accepted by the testable provider boundary. */ export interface DashboardBrowserApplicationProps { + readonly collections: DashboardBrowserCollections; readonly queryClient: QueryClient; readonly realtimeClient: DashboardRealtimeClient; readonly router: DashboardRouter; @@ -40,6 +47,7 @@ export interface DashboardBrowserApplicationProps { * @returns The composed browser provider graph. */ export function DashboardBrowserApplication({ + collections, queryClient, realtimeClient, router, @@ -49,14 +57,16 @@ export function DashboardBrowserApplication({ return ( - - - - - - - - + + + + + + + + + + ); @@ -69,6 +79,7 @@ export function DashboardBrowserApplication({ export default function DashboardBrowserApplicationRoot() { return ( { }, } satisfies AuthStatus); queryClient.setQueryData(authStatusQueryKey, cachedAuthenticatedStatus); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + const view = render( + + ); try { - render( - - ); - expect(await screen.findByLabelText("Authentication status")).toBeTruthy(); expect(transport.statusQueryCount).toBeGreaterThan(0); expect( @@ -132,6 +135,8 @@ describe("authenticated route boundary", () => { await screen.findByRole("heading", { level: 1, name: "Sign in" }) ).toBeTruthy(); } finally { + view.unmount(); + await collections.cleanup(); queryClient.clear(); } }); diff --git a/greenfield/src/browser/auth/LoginRoute.test.tsx b/greenfield/src/browser/auth/LoginRoute.test.tsx index a6efac209..442936c15 100644 --- a/greenfield/src/browser/auth/LoginRoute.test.tsx +++ b/greenfield/src/browser/auth/LoginRoute.test.tsx @@ -13,6 +13,10 @@ import { type DashboardTrpcTransport, } from "../api/trpcClient.ts"; import { DashboardBrowserApplication } from "../application.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "../data/dashboardCollections.ts"; import { createDashboardRouter } from "../router.tsx"; import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; @@ -82,6 +86,8 @@ class AuthenticationTransport implements DashboardTrpcTransport { } const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; function renderAuthenticationRoute( transport: AuthenticationTransport, @@ -95,14 +101,20 @@ function renderAuthenticationRoute( const router = createDashboardRouter( createMemoryHistory({ initialEntries: [options.initialEntry ?? "/login"] }) ); - render( - + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) ); return { queryClient, router }; } @@ -116,7 +128,11 @@ function cachedBrowserData(queryClient: ReturnType { +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); for (const queryClient of queryClients.splice(0)) queryClient.clear(); }); diff --git a/greenfield/src/browser/data/dashboardCollections.ts b/greenfield/src/browser/data/dashboardCollections.ts new file mode 100644 index 000000000..b18126628 --- /dev/null +++ b/greenfield/src/browser/data/dashboardCollections.ts @@ -0,0 +1,38 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { + createAgentCollections, + type AgentCollections, +} from "../agents/agentCollections.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +/** Browser-owned normalized collections and their explicit lifetime boundary. */ +export interface DashboardBrowserCollections { + readonly agents: AgentCollections; + readonly cleanup: () => Promise; +} + +/** + * Creates all normalized collections owned by one browser application runtime. + * @param queryClient Browser-owned TanStack Query cache. + * @param trpcClient Browser-owned validated transport client. + * @returns One deeply stable collection registry with idempotent cleanup. + */ +export function createDashboardBrowserCollections( + queryClient: QueryClient, + trpcClient: DashboardTrpcClient +): DashboardBrowserCollections { + const agents = createAgentCollections(queryClient, trpcClient); + let cleaned = false; + return Object.freeze({ + agents, + async cleanup(): Promise { + if (cleaned) return; + cleaned = true; + await Promise.all([ + agents.definitions.cleanup(), + agents.statuses.cleanup(), + ]); + }, + }); +} diff --git a/greenfield/src/browser/data/dashboardCollectionsContext.tsx b/greenfield/src/browser/data/dashboardCollectionsContext.tsx new file mode 100644 index 000000000..565103c92 --- /dev/null +++ b/greenfield/src/browser/data/dashboardCollectionsContext.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +import type { DashboardBrowserCollections } from "./dashboardCollections.ts"; +import { dashboardCollectionsContext as DashboardCollectionsContext } from "./dashboardCollectionsContextValue.ts"; + +interface DashboardCollectionsProviderProps { + readonly children: ReactNode; + readonly collections: DashboardBrowserCollections; +} + +/** @returns The browser-owned normalized collection registry provider. */ +export function DashboardCollectionsProvider({ + children, + collections, +}: DashboardCollectionsProviderProps) { + return ( + + {children} + + ); +} diff --git a/greenfield/src/browser/data/dashboardCollectionsContextValue.ts b/greenfield/src/browser/data/dashboardCollectionsContextValue.ts new file mode 100644 index 000000000..5720dc6e2 --- /dev/null +++ b/greenfield/src/browser/data/dashboardCollectionsContextValue.ts @@ -0,0 +1,20 @@ +import { createContext, use } from "react"; + +import type { DashboardBrowserCollections } from "./dashboardCollections.ts"; + +/** Internal context shared by the collection provider and feature hooks. */ +export const dashboardCollectionsContext = createContext< + DashboardBrowserCollections | undefined +>(undefined); + +/** + * Reads the normalized collection registry for the current browser runtime. + * @returns Browser-owned TanStack DB collections. + */ +export function useDashboardBrowserCollections(): DashboardBrowserCollections { + const collections = use(dashboardCollectionsContext); + if (collections === undefined) { + throw new TypeError("Dashboard collections provider is missing"); + } + return collections; +} diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx index d39145bc5..e530a0bd5 100644 --- a/greenfield/src/browser/layout/DashboardShell.tsx +++ b/greenfield/src/browser/layout/DashboardShell.tsx @@ -1,6 +1,6 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { Home, ListTodo, Menu, ShieldCheck, X, type LucideIcon } from "lucide-react"; +import { Bot, Home, ListTodo, Menu, ShieldCheck, X, type LucideIcon } from "lucide-react"; import { useState } from "react"; import type { DashboardNavigationPath } from "../lib/dashboardRoutes.ts"; @@ -16,6 +16,7 @@ interface NavigationItem { const navigationItems: readonly NavigationItem[] = Object.freeze([ { icon: Home, label: "Dashboard", to: "/" }, + { icon: Bot, label: "Agents", to: "/agents" }, { icon: ListTodo, label: "Tasks", to: "/tasks" }, { icon: ShieldCheck, label: "Account security", to: "/account-security" }, ]); diff --git a/greenfield/src/browser/lib/dashboardRoutes.ts b/greenfield/src/browser/lib/dashboardRoutes.ts index 123c585df..6964a90c7 100644 --- a/greenfield/src/browser/lib/dashboardRoutes.ts +++ b/greenfield/src/browser/lib/dashboardRoutes.ts @@ -2,6 +2,7 @@ export const dashboardRoutePaths = Object.freeze([ "/", "/account-security", + "/agents", "/login", "/tasks", ] as const); diff --git a/greenfield/src/browser/router.tsx b/greenfield/src/browser/router.tsx index 9f2ec1c02..aa094ecc7 100644 --- a/greenfield/src/browser/router.tsx +++ b/greenfield/src/browser/router.tsx @@ -21,6 +21,10 @@ const accountSecurityRoute = createRoute({ getParentRoute: () => rootRoute, path: "/account-security", }).lazy(() => import("./routes/accountSecurity.lazy.tsx").then((module) => module.Route)); +const agentsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/agents", +}).lazy(() => import("./routes/agents.lazy.tsx").then((module) => module.Route)); const tasksRoute = createRoute({ getParentRoute: () => rootRoute, path: "/tasks", @@ -29,6 +33,7 @@ const routeTree = rootRoute.addChildren([ overviewRoute, loginRoute, accountSecurityRoute, + agentsRoute, tasksRoute, ]); diff --git a/greenfield/src/browser/routes/agents.lazy.tsx b/greenfield/src/browser/routes/agents.lazy.tsx new file mode 100644 index 000000000..1a7652680 --- /dev/null +++ b/greenfield/src/browser/routes/agents.lazy.tsx @@ -0,0 +1,14 @@ +import { createLazyRoute } from "@tanstack/react-router"; + +import { AgentsRoute } from "../agents/AgentsRoute.tsx"; +import { AuthenticationBoundary } from "../auth/AuthenticationBoundary.tsx"; + +export const Route = createLazyRoute("/agents")({ + component: function AgentsRouteBoundary() { + return ( + + + + ); + }, +}); diff --git a/greenfield/src/browser/security/AccountSecurityRoute.test.tsx b/greenfield/src/browser/security/AccountSecurityRoute.test.tsx index e1d2fc418..4c326a37d 100644 --- a/greenfield/src/browser/security/AccountSecurityRoute.test.tsx +++ b/greenfield/src/browser/security/AccountSecurityRoute.test.tsx @@ -25,6 +25,10 @@ import { type DashboardTrpcTransport, } from "../api/trpcClient.ts"; import { DashboardBrowserApplication } from "../application.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "../data/dashboardCollections.ts"; import { createDashboardRouter } from "../router.tsx"; import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; import type { DashboardWebAuthnClient } from "./webauthn/webauthnClient.ts"; @@ -252,6 +256,8 @@ class SecurityTransport implements DashboardTrpcTransport { } const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; function renderAccountSecurity( transport: SecurityTransport, @@ -262,14 +268,20 @@ function renderAccountSecurity( const router = createDashboardRouter( createMemoryHistory({ initialEntries: ["/account-security"] }) ); - render( - + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) ); return queryClient; } @@ -298,7 +310,11 @@ async function waitForDialogExit(): Promise { expect(screen.queryByRole("dialog", { hidden: true })).toBeNull(); } -afterEach(() => { +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); for (const queryClient of queryClients.splice(0)) queryClient.clear(); }); diff --git a/greenfield/src/browser/tasks/TaskBoardRoute.test.tsx b/greenfield/src/browser/tasks/TaskBoardRoute.test.tsx index 5d0c96b5a..e58ca9a2b 100644 --- a/greenfield/src/browser/tasks/TaskBoardRoute.test.tsx +++ b/greenfield/src/browser/tasks/TaskBoardRoute.test.tsx @@ -21,6 +21,10 @@ import { type DashboardTrpcTransport, } from "../api/trpcClient.ts"; import { DashboardBrowserApplication } from "../application.tsx"; +import { + createDashboardBrowserCollections, + type DashboardBrowserCollections, +} from "../data/dashboardCollections.ts"; import { createDashboardRouter } from "../router.tsx"; import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; @@ -182,6 +186,8 @@ class TaskTransport implements DashboardTrpcTransport { } const queryClients: ReturnType[] = []; +const collectionRegistries: DashboardBrowserCollections[] = []; +const mountedViews: ReturnType[] = []; function renderTaskRoute(transport: TaskTransport) { const queryClient = createDashboardQueryClient(); @@ -189,19 +195,29 @@ function renderTaskRoute(transport: TaskTransport) { const router = createDashboardRouter( createMemoryHistory({ initialEntries: ["/tasks"] }) ); - render( - + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) ); return queryClient; } -afterEach(() => { +afterEach(async () => { + for (const view of mountedViews.splice(0)) view.unmount(); + await Promise.all( + collectionRegistries.splice(0).map((collections) => collections.cleanup()) + ); for (const queryClient of queryClients.splice(0)) queryClient.clear(); }); diff --git a/greenfield/src/browser/tasks/useTaskRealtimeInvalidation.ts b/greenfield/src/browser/tasks/useTaskRealtimeInvalidation.ts index 972a48105..05ed95aa6 100644 --- a/greenfield/src/browser/tasks/useTaskRealtimeInvalidation.ts +++ b/greenfield/src/browser/tasks/useTaskRealtimeInvalidation.ts @@ -1,8 +1,5 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; - import { taskRealtimeTopic } from "../../contracts/taskRealtime.ts"; -import { useDashboardRealtimeHub } from "../api/realtimeContextValue.ts"; +import { useRealtimeQueryInvalidation } from "../api/useRealtimeQueryInvalidation.ts"; import { refreshTaskQueries } from "./taskQueries.ts"; /** Coalesces bursts without delaying normal task interaction perceptibly. */ @@ -12,42 +9,10 @@ export const taskRealtimeFallbackRefreshIntervalMs = 30_000; /** Subscribes the mounted task surface to durable cache invalidations. */ export function useTaskRealtimeInvalidation(): void { - const hub = useDashboardRealtimeHub(); - const queryClient = useQueryClient(); - - useEffect(() => { - let refreshTimer: ReturnType | undefined; - let fallbackTimer: ReturnType | undefined; - const scheduleRefresh = () => { - if (refreshTimer !== undefined) return; - refreshTimer = setTimeout(() => { - refreshTimer = undefined; - void refreshTaskQueries(queryClient); - }, taskRealtimeRefreshDelayMs); - }; - const startFallbackRefresh = () => { - scheduleRefresh(); - fallbackTimer ??= setInterval( - scheduleRefresh, - taskRealtimeFallbackRefreshIntervalMs - ); - }; - const subscription = hub.subscribe([taskRealtimeTopic], { - onData(output) { - if (output.data.kind === "resync-required") { - scheduleRefresh(); - return; - } - if (output.data.event.topic === taskRealtimeTopic) { - scheduleRefresh(); - } - }, - onError: startFallbackRefresh, - }); - return () => { - subscription.unsubscribe(); - if (refreshTimer !== undefined) clearTimeout(refreshTimer); - if (fallbackTimer !== undefined) clearInterval(fallbackTimer); - }; - }, [hub, queryClient]); + useRealtimeQueryInvalidation({ + fallbackRefreshIntervalMs: taskRealtimeFallbackRefreshIntervalMs, + refreshDelayMs: taskRealtimeRefreshDelayMs, + refreshQueries: refreshTaskQueries, + topic: taskRealtimeTopic, + }); } diff --git a/greenfield/src/browser/ui/DataTable.tsx b/greenfield/src/browser/ui/DataTable.tsx index a0c9cc8d3..777a6d69c 100644 --- a/greenfield/src/browser/ui/DataTable.tsx +++ b/greenfield/src/browser/ui/DataTable.tsx @@ -85,6 +85,7 @@ export function DataTable diff --git a/greenfield/src/browser/ui/PageHeader.tsx b/greenfield/src/browser/ui/PageHeader.tsx index e2d16776a..25ffc0e73 100644 --- a/greenfield/src/browser/ui/PageHeader.tsx +++ b/greenfield/src/browser/ui/PageHeader.tsx @@ -4,6 +4,7 @@ import { Heading } from "./Heading.tsx"; import { Text } from "./Text.tsx"; interface PageHeaderProps { + readonly actions?: ReactNode; readonly description: ReactNode; readonly eyebrow?: ReactNode; readonly title: ReactNode; @@ -13,20 +14,23 @@ interface PageHeaderProps { * Renders the shared hierarchy for one Dashboard route heading. * @returns The route heading and description. */ -export function PageHeader({ description, eyebrow, title }: PageHeaderProps) { +export function PageHeader({ actions, description, eyebrow, title }: PageHeaderProps) { return ( -
- {eyebrow !== undefined && ( - - {eyebrow} +
+
+ {eyebrow !== undefined && ( + + {eyebrow} + + )} + + {title} + + + {description} - )} - - {title} - - - {description} - +
+ {actions !== undefined &&
{actions}
}
); } diff --git a/greenfield/src/contracts/agentModel.test.ts b/greenfield/src/contracts/agentModel.test.ts new file mode 100644 index 000000000..2b857b95d --- /dev/null +++ b/greenfield/src/contracts/agentModel.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { + agentConfigurationSchema, + agentStatusSchema, + agentTaskRunSchema, +} from "./agentModel.ts"; + +const runId = "019fdc00-0000-7000-8000-000000000001"; + +describe("agent model contracts", () => { + test("canonicalizes a unique bounded agent directory", () => { + const configuration = v.parse(agentConfigurationSchema, { + agents: [ + { + description: "Runs bounded specialist work.", + displayName: "Researcher", + id: "researcher", + role: "specialist", + }, + { + description: "Owns the operator conversation.", + displayName: "Mira", + id: "main", + role: "primary", + }, + ], + }); + + expect(configuration.agents.map(({ id }) => id)).toEqual(["main", "researcher"]); + expect(Object.isFrozen(configuration.agents)).toBeTrue(); + expect( + v.safeParse(agentConfigurationSchema, { + agents: [configuration.agents[0], configuration.agents[0]], + }).success + ).toBeFalse(); + }); + + test("rejects inconsistent status and task-run timestamps", () => { + expect( + v.safeParse(agentStatusSchema, { + agentId: "main", + currentTask: "Implement agent status", + lastActivityAtMs: 999, + startedAtMs: 1000, + state: "working", + }).success + ).toBeFalse(); + expect( + v.safeParse(agentTaskRunSchema, { + agentId: "main", + completedAtMs: 1001, + id: runId, + lastActivityAtMs: 1002, + startedAtMs: 1000, + status: "completed", + task: "Implement agent status", + }).success + ).toBeFalse(); + }); +}); diff --git a/greenfield/src/contracts/agentModel.ts b/greenfield/src/contracts/agentModel.ts new file mode 100644 index 000000000..4dc7aa2cf --- /dev/null +++ b/greenfield/src/contracts/agentModel.ts @@ -0,0 +1,191 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { + boundedControlSafeTextSchema, + compareStrings, + hasUniqueArrayItems, + lowercaseUuidV7Schema, +} from "../shared/validation.ts"; + +/** Maximum configured agents exposed by one Dashboard process. */ +export const dashboardAgentMaximum = 16; + +/** Maximum Unicode code points retained for one current-task description. */ +export const agentCurrentTaskMaximumLength = 512; + +/** Stable Dashboard-owned agent identifier. */ +export const agentIdSchema = v.pipe( + v.string("Agent id is invalid"), + v.minLength(1, "Agent id is invalid"), + v.maxLength(64, "Agent id is invalid"), + v.regex(/^[a-z0-9][a-z0-9._-]*$/u, "Agent id is invalid") +); + +/** Display label from reviewed application configuration. */ +export const agentDisplayNameSchema = boundedControlSafeTextSchema( + 64, + "Agent display name is invalid" +); + +/** Short operator-facing purpose from reviewed application configuration. */ +export const agentDescriptionSchema = boundedControlSafeTextSchema( + 256, + "Agent description is invalid" +); + +/** Current task text supplied by the scoped task-tracking caller. */ +export const agentCurrentTaskSchema = boundedControlSafeTextSchema( + agentCurrentTaskMaximumLength, + "Agent current task is invalid" +); + +/** Stable UUIDv7 identity for one durable agent task run. */ +export const agentTaskRunIdSchema = lowercaseUuidV7Schema("Agent task run id is invalid"); + +export const agentRoles = ["primary", "specialist"] as const; +export const agentRoleSchema = v.picklist(agentRoles, "Agent role is invalid"); + +/** One reviewed Dashboard agent definition, independent of Gateway state. */ +export const agentDefinitionSchema = v.strictObject({ + description: agentDescriptionSchema, + displayName: agentDisplayNameSchema, + id: agentIdSchema, + role: agentRoleSchema, +}); + +export type AgentDefinition = v.InferOutput; + +/** + * Returns whether a reviewed directory contains each agent ID exactly once. + * @param definitions Agent definitions to inspect. + * @returns Whether every agent ID is unique. + */ +export function agentDefinitionsHaveUniqueIds(definitions: AgentDefinition[]): boolean { + return hasUniqueArrayItems(definitions.map(({ id }) => id)); +} + +/** + * Sorts and freezes a reviewed agent directory into its transport form. + * @param definitions Agent definitions to canonicalize. + * @returns Canonically ordered immutable agent definitions. + */ +export function canonicalAgentDefinitions( + definitions: AgentDefinition[] +): readonly AgentDefinition[] { + const sorted = definitions.toSorted((left, right) => + compareStrings(left.id, right.id) + ); + return Object.freeze(sorted.map((definition) => Object.freeze(definition))); +} + +const agentDefinitionListSchema = v.pipe( + v.array(agentDefinitionSchema, "Agent configuration is invalid"), + v.minLength(1, "Agent configuration cannot be empty"), + v.maxLength(dashboardAgentMaximum, "Agent configuration is outside its budget"), + v.check(agentDefinitionsHaveUniqueIds, "Agent configuration ids must be unique"), + v.transform(canonicalAgentDefinitions) +); + +/** Complete reviewed agent directory returned to authenticated clients. */ +export const agentConfigurationSchema = v.strictObject({ + agents: agentDefinitionListSchema, +}); + +const agentTimestampSchema = timestampMillisecondsSchema("Agent timestamp is invalid"); + +const idleAgentStatusSchema = v.strictObject({ + agentId: agentIdSchema, + lastActivityAtMs: v.optional(agentTimestampSchema), + state: v.literal("idle"), +}); + +const workingAgentStatusSchema = v.strictObject({ + agentId: agentIdSchema, + currentTask: agentCurrentTaskSchema, + lastActivityAtMs: agentTimestampSchema, + startedAtMs: agentTimestampSchema, + state: v.literal("working"), +}); + +/** + * Returns whether a working status has monotonic task timestamps. + * @param status Working status to inspect. + * @returns Whether task activity is not earlier than task start. + */ +export function workingStatusTimeIsConsistent( + status: v.InferOutput +): boolean { + return status.lastActivityAtMs >= status.startedAtMs; +} + +/** Dashboard-owned current task projection for one configured agent. */ +export const agentStatusSchema = v.variant("state", [ + idleAgentStatusSchema, + v.pipe( + workingAgentStatusSchema, + v.check(workingStatusTimeIsConsistent, "Agent status timestamps are inconsistent") + ), +]); + +const activeAgentTaskRunSchema = v.strictObject({ + agentId: agentIdSchema, + id: agentTaskRunIdSchema, + lastActivityAtMs: agentTimestampSchema, + startedAtMs: agentTimestampSchema, + status: v.literal("active"), + task: agentCurrentTaskSchema, +}); + +const completedAgentTaskRunSchema = v.strictObject({ + agentId: agentIdSchema, + completedAtMs: agentTimestampSchema, + id: agentTaskRunIdSchema, + lastActivityAtMs: agentTimestampSchema, + startedAtMs: agentTimestampSchema, + status: v.literal("completed"), + task: agentCurrentTaskSchema, +}); + +type ActiveAgentTaskRun = v.InferOutput; +type CompletedAgentTaskRun = v.InferOutput; + +/** + * Returns whether an active task run has monotonic timestamps. + * @param run Active task run to inspect. + * @returns Whether task activity is not earlier than task start. + */ +export function activeRunTimeIsConsistent(run: ActiveAgentTaskRun): boolean { + return run.lastActivityAtMs >= run.startedAtMs; +} + +/** + * Returns whether a completed task run has monotonic timestamps. + * @param run Completed task run to inspect. + * @returns Whether start, activity, and completion are monotonically ordered. + */ +export function completedRunTimeIsConsistent(run: CompletedAgentTaskRun): boolean { + return ( + run.lastActivityAtMs >= run.startedAtMs && + run.completedAtMs >= run.lastActivityAtMs + ); +} + +/** One active or completed current-task interval retained for history. */ +export const agentTaskRunSchema = v.variant("status", [ + v.pipe( + activeAgentTaskRunSchema, + v.check(activeRunTimeIsConsistent, "Agent task run timestamps are inconsistent") + ), + v.pipe( + completedAgentTaskRunSchema, + v.check( + completedRunTimeIsConsistent, + "Agent task run timestamps are inconsistent" + ) + ), +]); + +export type AgentConfiguration = v.InferOutput; +export type AgentStatus = v.InferOutput; +export type AgentTaskRun = v.InferOutput; diff --git a/greenfield/src/contracts/agentRealtime.ts b/greenfield/src/contracts/agentRealtime.ts new file mode 100644 index 000000000..95ab5893e --- /dev/null +++ b/greenfield/src/contracts/agentRealtime.ts @@ -0,0 +1,40 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { agentIdSchema } from "./agentModel.ts"; +import type { RealtimeTopicDefinition } from "./realtime.ts"; + +/** Durable topic carrying compact agent current-task invalidations. */ +export const agentRealtimeTopic = "agents.status"; + +const agentEntityType = "agent"; +const agentOperations = ["updated"] as const; + +/** Producer routing metadata accepted by agent-domain outbox writes. */ +export const agentRealtimeRoutingSchema = v.strictObject({ + entityType: v.literal(agentEntityType), + operation: v.picklist(agentOperations), + topic: v.literal(agentRealtimeTopic), +}); + +/** Compact invalidation payload; clients refetch the authoritative status. */ +export const agentChangePayloadSchema = v.strictObject({ id: agentIdSchema }); + +/** Topic-specific capability, entity, operation, and payload policy. */ +export const agentRealtimeTopicDefinition = { + capability: "agents:read", + entityTypes: [agentEntityType], + operations: agentOperations, + payload: agentChangePayloadSchema, + topic: agentRealtimeTopic, +} as const satisfies RealtimeTopicDefinition; + +/** Client-visible validated agent status change event. */ +export const agentRealtimeChangeSchema = v.strictObject({ + entityId: agentIdSchema, + entityType: v.literal(agentEntityType), + occurredAtMs: timestampMillisecondsSchema("Agent realtime timestamp is invalid"), + operation: v.picklist(agentOperations), + payload: agentChangePayloadSchema, + topic: v.literal(agentRealtimeTopic), +}); diff --git a/greenfield/src/contracts/agents.test.ts b/greenfield/src/contracts/agents.test.ts new file mode 100644 index 000000000..25348fb6d --- /dev/null +++ b/greenfield/src/contracts/agents.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { + agentProcedureContracts, + agentTaskHistoryPageDefault, + listAgentTaskHistoryInputSchema, + listAgentTaskHistoryResultSchema, + listAgentStatusesResultSchema, + updateAgentMetadataInputSchema, +} from "./agents.ts"; + +const firstRunId = "019fdc00-0000-7000-8000-000000000001"; +const secondRunId = "019fdc00-0000-7000-8000-000000000002"; + +describe("agent procedure contracts", () => { + test("locks read and write capabilities to the intended procedures", () => { + expect( + agentProcedureContracts.map(({ access, kind, name }) => ({ + access, + kind, + name, + })) + ).toEqual([ + { + access: { + capabilities: ["agents:read"], + capabilityPolicy: "all", + kind: "authenticated", + }, + kind: "query", + name: "agents.getConfiguration", + }, + { + access: { + capabilities: ["agents:read"], + capabilityPolicy: "all", + kind: "authenticated", + }, + kind: "query", + name: "agents.getStatus", + }, + { + access: { + capabilities: ["agents:read"], + capabilityPolicy: "all", + kind: "authenticated", + }, + kind: "query", + name: "agents.listStatuses", + }, + { + access: { + capabilities: ["agents:read"], + capabilityPolicy: "all", + kind: "authenticated", + }, + kind: "query", + name: "agents.listTaskHistory", + }, + { + access: { + capabilities: ["agents:write"], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["automation"], + }, + kind: "mutation", + name: "agents.updateMetadata", + }, + ]); + }); + + test("defaults and bounds newest-first task history", () => { + expect(v.parse(listAgentTaskHistoryInputSchema, {})).toEqual({ + limit: agentTaskHistoryPageDefault, + }); + expect( + v.safeParse(listAgentTaskHistoryResultSchema, { + runs: [ + { + agentId: "main", + id: firstRunId, + lastActivityAtMs: 1000, + startedAtMs: 1000, + status: "active", + task: "Newer task", + }, + { + agentId: "main", + completedAtMs: 2001, + id: secondRunId, + lastActivityAtMs: 2001, + startedAtMs: 2000, + status: "completed", + task: "Incorrect newer row", + }, + ], + }).success + ).toBeFalse(); + }); + + test("requires canonical unique statuses and explicit task clearing", () => { + expect( + v.safeParse(listAgentStatusesResultSchema, { + statuses: [ + { agentId: "researcher", state: "idle" }, + { agentId: "main", state: "idle" }, + ], + }).success + ).toBeFalse(); + expect( + v.parse(updateAgentMetadataInputSchema, { + agentId: "main", + currentTask: null, + }) + ).toEqual({ agentId: "main", currentTask: null }); + expect( + v.safeParse(updateAgentMetadataInputSchema, { agentId: "main" }).success + ).toBeFalse(); + }); +}); diff --git a/greenfield/src/contracts/agents.ts b/greenfield/src/contracts/agents.ts new file mode 100644 index 000000000..ab72caa8f --- /dev/null +++ b/greenfield/src/contracts/agents.ts @@ -0,0 +1,243 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { hasUniqueArrayItems } from "../shared/validation.ts"; +import { + type AgentStatus, + type AgentTaskRun, + agentConfigurationSchema, + agentIdSchema, + agentStatusSchema, + agentTaskRunIdSchema, + agentTaskRunSchema, + agentCurrentTaskSchema, + dashboardAgentMaximum, +} from "./agentModel.ts"; +import type { ProcedureContract } from "./registry.ts"; + +/** Default agent-task history rows returned by one request. */ +export const agentTaskHistoryPageDefault = 50; + +/** Hard agent-task history budget for one response. */ +export const agentTaskHistoryPageMaximum = 100; + +const agentTimestampSchema = timestampMillisecondsSchema("Agent timestamp is invalid"); +export const emptyAgentInputSchema = v.strictObject({}); + +/** Exact configured-agent lookup request. */ +export const getAgentStatusInputSchema = v.strictObject({ id: agentIdSchema }); + +/** Stable newest-first cursor for durable task-run history. */ +export const agentTaskHistoryCursorSchema = v.strictObject({ + id: agentTaskRunIdSchema, + startedAtMs: agentTimestampSchema, +}); + +const agentTaskHistoryLimitSchema = v.pipe( + v.number("Agent task history limit is invalid"), + v.safeInteger("Agent task history limit is invalid"), + v.minValue(1, "Agent task history limit is invalid"), + v.maxValue( + agentTaskHistoryPageMaximum, + "Agent task history limit is outside its budget" + ) +); + +/** Bounded, optional-agent task history request. */ +export const listAgentTaskHistoryInputSchema = v.strictObject({ + agentId: v.optional(agentIdSchema), + cursor: v.optional(agentTaskHistoryCursorSchema), + limit: v.optional(agentTaskHistoryLimitSchema, agentTaskHistoryPageDefault), +}); + +/** + * Returns whether task-run rows use the canonical newest-first keyset order. + * @param runs Task-run rows to inspect. + * @returns Whether the rows use strict descending start time and ID order. + */ +export function newestAgentTaskRunOrderIsStable(runs: AgentTaskRun[]): boolean { + return runs.every((run, index) => { + const previous = runs[index - 1]; + return ( + previous === undefined || + run.startedAtMs < previous.startedAtMs || + (run.startedAtMs === previous.startedAtMs && run.id < previous.id) + ); + }); +} + +const agentTaskHistoryRowsSchema = v.pipe( + v.array(agentTaskRunSchema, "Agent task history is invalid"), + v.maxLength(agentTaskHistoryPageMaximum, "Agent task history is outside its budget"), + v.check(newestAgentTaskRunOrderIsStable, "Agent task history order is invalid") +); + +const listAgentTaskHistoryResultObjectSchema = v.strictObject({ + nextCursor: v.optional(agentTaskHistoryCursorSchema), + runs: agentTaskHistoryRowsSchema, +}); + +type AgentTaskHistoryResultValue = v.InferOutput< + typeof listAgentTaskHistoryResultObjectSchema +>; + +/** + * Returns whether a continuation cursor identifies the returned final row. + * @param result Task-history page to inspect. + * @returns Whether an optional cursor matches the page's final row. + */ +export function agentTaskHistoryCursorIsConsistent( + result: AgentTaskHistoryResultValue +): boolean { + if (result.nextCursor === undefined) return true; + const last = result.runs.at(-1); + return ( + last !== undefined && + last.id === result.nextCursor.id && + last.startedAtMs === result.nextCursor.startedAtMs + ); +} + +/** One bounded task-run history page plus an exact continuation cursor. */ +export const listAgentTaskHistoryResultSchema = v.pipe( + listAgentTaskHistoryResultObjectSchema, + v.check( + agentTaskHistoryCursorIsConsistent, + "Agent task history cursor is inconsistent" + ) +); + +/** + * Returns whether statuses contain unique IDs in canonical code-unit order. + * @param statuses Agent statuses to inspect. + * @returns Whether IDs are unique and strictly sorted. + */ +export function canonicalAgentStatuses(statuses: AgentStatus[]): boolean { + return ( + hasUniqueArrayItems(statuses.map(({ agentId }) => agentId)) && + statuses.every((status, index) => { + const previous = statuses[index - 1]; + return previous === undefined || status.agentId > previous.agentId; + }) + ); +} + +/** Complete operational projection for every configured agent. */ +export const listAgentStatusesResultSchema = v.strictObject({ + statuses: v.pipe( + v.array(agentStatusSchema, "Agent statuses are invalid"), + v.minLength(1, "Agent statuses cannot be empty"), + v.maxLength(dashboardAgentMaximum, "Agent statuses are outside their budget"), + v.check(canonicalAgentStatuses, "Agent statuses are not canonical") + ), +}); + +/** Scoped current-task update; null explicitly clears the active task. */ +export const updateAgentMetadataInputSchema = v.strictObject({ + agentId: agentIdSchema, + currentTask: v.nullable(agentCurrentTaskSchema), +}); + +const agentReadAccess = { + capabilities: ["agents:read"], + capabilityPolicy: "all", + kind: "authenticated", +} as const; +const agentWriteAccess = { + capabilities: ["agents:write"], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["automation"], +} as const; +const agentQueryTransport = { + batching: "adapter-default", + handler: "default", + requestBody: "default", +} as const; +const agentMutationTransport = { + batching: "forbidden", + handler: "default", + requestBody: "default", +} as const; + +/** Implemented Dashboard-owned agent status and task-history procedure metadata. */ +export const agentProcedureContracts = [ + { + access: agentReadAccess, + domain: "agents", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: emptyAgentInputSchema, + inputSchemaId: "agents.getConfiguration.input", + kind: "query", + name: "agents.getConfiguration", + output: agentConfigurationSchema, + outputSchemaId: "agents.getConfiguration.output", + summary: "Returns the reviewed Dashboard-owned agent directory.", + transport: agentQueryTransport, + }, + { + access: agentReadAccess, + domain: "agents", + errors: ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + input: getAgentStatusInputSchema, + inputSchemaId: "agents.getStatus.input", + kind: "query", + name: "agents.getStatus", + output: agentStatusSchema, + outputSchemaId: "agents.getStatus.output", + summary: "Returns the current task projection for one configured agent.", + transport: agentQueryTransport, + }, + { + access: agentReadAccess, + domain: "agents", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: emptyAgentInputSchema, + inputSchemaId: "agents.listStatuses.input", + kind: "query", + name: "agents.listStatuses", + output: listAgentStatusesResultSchema, + outputSchemaId: "agents.listStatuses.output", + summary: "Returns current task projections for all configured agents.", + transport: agentQueryTransport, + }, + { + access: agentReadAccess, + domain: "agents", + errors: ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + input: listAgentTaskHistoryInputSchema, + inputSchemaId: "agents.listTaskHistory.input", + kind: "query", + name: "agents.listTaskHistory", + output: listAgentTaskHistoryResultSchema, + outputSchemaId: "agents.listTaskHistory.output", + summary: "Lists durable newest-first agent current-task history.", + transport: agentQueryTransport, + }, + { + access: agentWriteAccess, + domain: "agents", + errors: ["FORBIDDEN", "NOT_FOUND", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: updateAgentMetadataInputSchema, + inputSchemaId: "agents.updateMetadata.input", + kind: "mutation", + name: "agents.updateMetadata", + output: agentStatusSchema, + outputSchemaId: "agents.updateMetadata.output", + summary: + "Atomically starts, touches, replaces, or clears one agent current task.", + transport: agentMutationTransport, + }, +] as const satisfies readonly ProcedureContract[]; + +export type GetAgentStatusInput = v.InferOutput; +export type ListAgentTaskHistoryInput = v.InferOutput< + typeof listAgentTaskHistoryInputSchema +>; +export type ListAgentTaskHistoryResult = v.InferOutput< + typeof listAgentTaskHistoryResultSchema +>; +export type ListAgentStatusesResult = v.InferOutput; +export type UpdateAgentMetadataInput = v.InferOutput< + typeof updateAgentMetadataInputSchema +>; diff --git a/greenfield/src/contracts/contractRegistry.ts b/greenfield/src/contracts/contractRegistry.ts index 4c0753af4..e5635e0af 100644 --- a/greenfield/src/contracts/contractRegistry.ts +++ b/greenfield/src/contracts/contractRegistry.ts @@ -1,4 +1,5 @@ import { accountSecurityProcedureContracts } from "./accountSecurity.ts"; +import { agentProcedureContracts } from "./agents.ts"; import { authProcedureContracts } from "./auth.ts"; import { automationSecurityProcedureContracts } from "./automationSecurity.ts"; import { eventsStreamContract } from "./events.ts"; @@ -15,6 +16,7 @@ import { taskProcedureContracts } from "./tasks.ts"; /** Implemented tRPC procedure metadata used by runtime wiring and docs. */ const registeredProcedureContracts = [ ...accountSecurityProcedureContracts, + ...agentProcedureContracts, ...authProcedureContracts, ...automationSecurityProcedureContracts, eventsStreamContract, diff --git a/greenfield/src/contracts/events.test.ts b/greenfield/src/contracts/events.test.ts index 20d9bafe8..59cc4d094 100644 --- a/greenfield/src/contracts/events.test.ts +++ b/greenfield/src/contracts/events.test.ts @@ -17,6 +17,7 @@ import { describe("realtime transport contracts", () => { test("documents only capabilities required by registered topics", () => { expect(realtimeStreamCapabilities).toEqual([ + "agents:read", "notifications:read", "reports:read", "tasks:read", diff --git a/greenfield/src/contracts/events.ts b/greenfield/src/contracts/events.ts index 562f68092..b590e86aa 100644 --- a/greenfield/src/contracts/events.ts +++ b/greenfield/src/contracts/events.ts @@ -4,6 +4,11 @@ import { canonicalNonnegativeSafeIntegerStringSchema, hasUniqueArrayItems, } from "../shared/validation.ts"; +import { + agentRealtimeChangeSchema, + agentRealtimeTopic, + agentRealtimeTopicDefinition, +} from "./agentRealtime.ts"; import { monitoringRealtimeChangeSchemas, monitoringRealtimeTopicDefinitions, @@ -20,6 +25,7 @@ import { /** All topic definitions currently accepted by the realtime transport. */ export const realtimeTopicDefinitions = Object.freeze([ + agentRealtimeTopicDefinition, ...monitoringRealtimeTopicDefinitions, taskRealtimeTopicDefinition, ] as const); @@ -35,17 +41,20 @@ export function findRealtimeTopicDefinition(topic: string) { /** Exact unique capability vocabulary used by registered realtime topics. */ export const realtimeStreamCapabilities = Object.freeze([ + "agents:read", "notifications:read", "reports:read", "tasks:read", ] as const satisfies readonly ApplicationCapability[]); -const realtimeStreamTopics = [ +/** Exact registered topic vocabulary accepted by the tracked SSE contract. */ +export const realtimeStreamTopics = Object.freeze([ + agentRealtimeTopic, monitoringRealtimeTopics.incidents, monitoringRealtimeTopics.notifications, monitoringRealtimeTopics.reports, taskRealtimeTopic, -] as const; +] as const); const realtimeCursorSchema = canonicalNonnegativeSafeIntegerStringSchema( "Realtime resume cursor is invalid" @@ -78,6 +87,7 @@ export const realtimeStreamInputSchema = v.strictObject({ export const realtimeStreamDataSchema = v.variant("kind", [ v.strictObject({ event: v.variant("topic", [ + agentRealtimeChangeSchema, ...monitoringRealtimeChangeSchemas, taskRealtimeChangeSchema, ]), diff --git a/greenfield/src/contracts/security.ts b/greenfield/src/contracts/security.ts index a8fe501f0..9d6e451d5 100644 --- a/greenfield/src/contracts/security.ts +++ b/greenfield/src/contracts/security.ts @@ -92,6 +92,8 @@ export const securityRecordIdSchema = lowercaseUuidV7Schema( /** Capabilities referenced by currently implemented authenticated contracts. */ export const applicationCapabilities = [ + "agents:read", + "agents:write", "notifications:read", "reports:read", "tasks:read", diff --git a/greenfield/src/server/database/migrations/agentTaskRunsSchema.test.ts b/greenfield/src/server/database/migrations/agentTaskRunsSchema.test.ts new file mode 100644 index 000000000..fe44aeaea --- /dev/null +++ b/greenfield/src/server/database/migrations/agentTaskRunsSchema.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; + +import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; + +const firstRunId = "019fd200-0000-7000-8000-000000000001"; +const secondRunId = "019fd200-0000-7000-8000-000000000002"; + +function insertActiveRun( + database: Awaited>, + id: string, + actorKind = "automation", + actorId = "openclaw-task-tracking" +): void { + database.sqlite.run( + `INSERT INTO agent_task_runs ( + agent_id, id, last_activity_at, last_updated_by_id, + last_updated_by_kind, started_at, started_by_id, started_by_kind, task + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["main", id, 1000, actorId, actorKind, 1000, actorId, actorKind, "Test run"] + ); +} + +describe("agent task-run baseline schema", () => { + test("enforces active-run, actor, and completed-history invariants", async () => { + const database = await openFreshMigratedDatabase(); + + try { + insertActiveRun(database, firstRunId); + expect(() => insertActiveRun(database, secondRunId)).toThrow( + "UNIQUE constraint failed" + ); + expect(() => + insertActiveRun(database, secondRunId, "user", "automation-id") + ).toThrow("agent_task_runs_last_updated_actor_check"); + expect(() => + database.sqlite.run( + "UPDATE agent_task_runs SET last_activity_at = ? WHERE id = ?", + [999, firstRunId] + ) + ).toThrow("agent_task_runs activity is monotonic"); + expect(() => + database.sqlite.run("DELETE FROM agent_task_runs WHERE id = ?", [ + firstRunId, + ]) + ).toThrow("agent_task_runs history cannot be deleted"); + + database.sqlite.run( + `UPDATE agent_task_runs + SET completed_at = ?, completed_by_id = ?, completed_by_kind = ?, + last_activity_at = ?, last_updated_by_id = ?, + last_updated_by_kind = ? + WHERE id = ?`, + [ + 2000, + "openclaw-task-tracking", + "automation", + 2000, + "openclaw-task-tracking", + "automation", + firstRunId, + ] + ); + expect(() => + database.sqlite.run( + "UPDATE agent_task_runs SET last_activity_at = ? WHERE id = ?", + [3000, firstRunId] + ) + ).toThrow("completed agent_task_runs are immutable"); + } finally { + database.sqlite.close(true); + } + }); + + test("uses the declared indexes for active and history reads", async () => { + const database = await openFreshMigratedDatabase(); + + try { + const activeRunPlan = database.sqlite + .query<{ detail: string }, [string]>(` + EXPLAIN QUERY PLAN + SELECT id + FROM agent_task_runs + WHERE agent_id = ? AND completed_at IS NULL + LIMIT 1 + `) + .all("main"); + const globalHistoryPlan = database.sqlite + .query<{ detail: string }, []>(` + EXPLAIN QUERY PLAN + SELECT id + FROM agent_task_runs + ORDER BY started_at DESC, id DESC + LIMIT 50 + `) + .all(); + const agentHistoryPlan = database.sqlite + .query<{ detail: string }, [string]>(` + EXPLAIN QUERY PLAN + SELECT id + FROM agent_task_runs + WHERE agent_id = ? + ORDER BY started_at DESC, id DESC + LIMIT 50 + `) + .all("main"); + + expect( + activeRunPlan.some(({ detail }) => + detail.includes("agent_task_runs_one_active_agent_idx") + ) + ).toBeTrue(); + expect( + globalHistoryPlan.some(({ detail }) => + detail.includes("agent_task_runs_started_id_idx") + ) + ).toBeTrue(); + expect( + agentHistoryPlan.some(({ detail }) => + detail.includes("agent_task_runs_agent_started_id_idx") + ) + ).toBeTrue(); + } finally { + database.sqlite.close(true); + } + }); +}); diff --git a/greenfield/src/server/database/migrations/migrationGraph.test.ts b/greenfield/src/server/database/migrations/migrationGraph.test.ts index 925b770ea..e2137d7e6 100644 --- a/greenfield/src/server/database/migrations/migrationGraph.test.ts +++ b/greenfield/src/server/database/migrations/migrationGraph.test.ts @@ -30,6 +30,7 @@ interface TextPrimaryKeyRow { } const expectedTables: string[] = [ + "agent_task_runs", "audit_events", "auth_challenges", "auth_pending_logins", diff --git a/greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts b/greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts index a1ca66201..ab23ea253 100644 --- a/greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts +++ b/greenfield/src/server/database/migrations/securityIdentitySchema.automation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { applicationCapabilities } from "../../../contracts/security.ts"; import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; import { insertAutomationPrincipal } from "./testSupport/securityIdentitySchema.ts"; @@ -80,13 +81,16 @@ describe("automation identity schema", () => { try { insertAutomationPrincipal(database); - database.sqlite.run(` - INSERT INTO automation_principal_capabilities ( - capability, - granted_at, - principal_id - ) VALUES ('notifications:read', 1000, 'openclaw-task-tracking') - `); + const insertCapability = database.sqlite.query(` + INSERT INTO automation_principal_capabilities ( + capability, + granted_at, + principal_id + ) VALUES (?, ?, 'openclaw-task-tracking') + `); + for (const [index, capability] of applicationCapabilities.entries()) { + insertCapability.run(capability, 1000 + index); + } database.sqlite.run(` INSERT INTO automation_principal_capabilities ( capability, diff --git a/greenfield/src/server/database/schema/agentTaskRuns.ts b/greenfield/src/server/database/schema/agentTaskRuns.ts new file mode 100644 index 000000000..2e7b9f39e --- /dev/null +++ b/greenfield/src/server/database/schema/agentTaskRuns.ts @@ -0,0 +1,86 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +import { agentCurrentTaskMaximumLength } from "../../../contracts/agentModel.ts"; +import { + boundedControlSafeTextCheck, + nulFreeTextCheck, + timestampMillisecondsCheck, + uuidV7TextCheck, +} from "./checks.ts"; + +function agentIdCheck(column: Parameters[0]) { + return sql`length(${column}) BETWEEN 1 AND 64 AND ${nulFreeTextCheck(column)} AND ${column} = lower(${column}) AND substr(${column}, 1, 1) GLOB '[a-z0-9]' AND ${column} NOT GLOB '*[^a-z0-9._-]*'`; +} + +function actorCheck( + kind: Parameters[0], + id: Parameters[0] +) { + return sql`(${kind} = 'user' AND ${uuidV7TextCheck(id)}) OR (${kind} = 'automation' AND ${agentIdCheck(id)})`; +} + +/** Mutable active interval and immutable completed history for Dashboard agent tasks. */ +export const agentTaskRuns = sqliteTable( + "agent_task_runs", + { + agentId: text("agent_id").notNull(), + completedAt: integer("completed_at", { mode: "timestamp_ms" }), + completedById: text("completed_by_id"), + completedByKind: text("completed_by_kind", { + enum: ["automation", "user"], + }), + id: text("id").notNull().primaryKey(), + lastActivityAt: integer("last_activity_at", { mode: "timestamp_ms" }).notNull(), + lastUpdatedById: text("last_updated_by_id").notNull(), + lastUpdatedByKind: text("last_updated_by_kind", { + enum: ["automation", "user"], + }).notNull(), + startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), + startedById: text("started_by_id").notNull(), + startedByKind: text("started_by_kind", { + enum: ["automation", "user"], + }).notNull(), + task: text("task").notNull(), + }, + (table) => [ + check("agent_task_runs_agent_id_check", agentIdCheck(table.agentId)), + check( + "agent_task_runs_completed_actor_check", + sql`(${table.completedAt} IS NULL AND ${table.completedByKind} IS NULL AND ${table.completedById} IS NULL) OR (${table.completedAt} IS NOT NULL AND ${table.completedByKind} IS NOT NULL AND ${table.completedById} IS NOT NULL AND (${actorCheck(table.completedByKind, table.completedById)}))` + ), + check("agent_task_runs_id_check", uuidV7TextCheck(table.id)), + check( + "agent_task_runs_last_updated_actor_check", + actorCheck(table.lastUpdatedByKind, table.lastUpdatedById) + ), + check( + "agent_task_runs_started_actor_check", + actorCheck(table.startedByKind, table.startedById) + ), + check( + "agent_task_runs_task_check", + boundedControlSafeTextCheck(table.task, agentCurrentTaskMaximumLength) + ), + check( + "agent_task_runs_time_check", + sql`${timestampMillisecondsCheck(table.startedAt)} AND ${timestampMillisecondsCheck(table.lastActivityAt)} AND ${table.lastActivityAt} >= ${table.startedAt} AND (${table.completedAt} IS NULL OR (${timestampMillisecondsCheck(table.completedAt)} AND ${table.completedAt} >= ${table.lastActivityAt}))` + ), + uniqueIndex("agent_task_runs_one_active_agent_idx") + .on(table.agentId) + .where(sql`${table.completedAt} IS NULL`), + index("agent_task_runs_started_id_idx").on(table.startedAt, table.id), + index("agent_task_runs_agent_started_id_idx").on( + table.agentId, + table.startedAt, + table.id + ), + ] +); diff --git a/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts b/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts index 45968dfc3..c7a05dc05 100644 --- a/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts +++ b/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts @@ -18,7 +18,7 @@ export const automationPrincipalCapabilities = sqliteTable( (table) => [ check( "automation_principal_capabilities_capability_check", - sql`${table.capability} IN ('notifications:read', 'reports:read', 'tasks:read', 'tasks:write')` + sql`${table.capability} IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')` ), check( "automation_principal_capabilities_granted_at_check", diff --git a/greenfield/src/server/database/schema/drizzleSchema.ts b/greenfield/src/server/database/schema/drizzleSchema.ts index 452ba668a..afc32329f 100644 --- a/greenfield/src/server/database/schema/drizzleSchema.ts +++ b/greenfield/src/server/database/schema/drizzleSchema.ts @@ -3,6 +3,7 @@ * Domain code imports its own tables directly rather than using this catalog as a barrel. */ export { auditEvents } from "./auditEvents.ts"; +export { agentTaskRuns } from "./agentTaskRuns.ts"; export { authChallenges } from "./authChallenges.ts"; export { authPendingLogins } from "./authPendingLogins.ts"; export { authRateLimitBuckets } from "./authRateLimitBuckets.ts"; diff --git a/greenfield/src/server/database/validation/agentTaskRuns.ts b/greenfield/src/server/database/validation/agentTaskRuns.ts new file mode 100644 index 000000000..cef48c4f2 --- /dev/null +++ b/greenfield/src/server/database/validation/agentTaskRuns.ts @@ -0,0 +1,99 @@ +import { compareAsc } from "date-fns"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-orm/valibot"; +import * as v from "valibot"; + +import { + agentCurrentTaskSchema, + agentIdSchema, + agentTaskRunIdSchema, +} from "../../../contracts/agentModel.ts"; +import { + automationPrincipalIdSchema, + securityRecordIdSchema, +} from "../../../contracts/security.ts"; +import { agentTaskRuns } from "../schema/agentTaskRuns.ts"; +import { nonnegativeDateSchema } from "./scalars.ts"; + +const actorKindSchema = v.picklist(["automation", "user"]); + +function actorIsValid(kind: "automation" | "user", id: string): boolean { + return v.safeParse( + kind === "automation" ? automationPrincipalIdSchema : securityRecordIdSchema, + id + ).success; +} + +function runIsConsistent(run: { + readonly completedAt: Date | null; + readonly completedById: string | null; + readonly completedByKind: "automation" | "user" | null; + readonly lastActivityAt: Date; + readonly lastUpdatedById: string; + readonly lastUpdatedByKind: "automation" | "user"; + readonly startedAt: Date; + readonly startedById: string; + readonly startedByKind: "automation" | "user"; +}): boolean { + const completedActorIsConsistent = + run.completedAt === null + ? run.completedById === null && run.completedByKind === null + : run.completedById !== null && + run.completedByKind !== null && + actorIsValid(run.completedByKind, run.completedById); + return ( + completedActorIsConsistent && + actorIsValid(run.startedByKind, run.startedById) && + actorIsValid(run.lastUpdatedByKind, run.lastUpdatedById) && + compareAsc(run.lastActivityAt, run.startedAt) >= 0 && + (run.completedAt === null || compareAsc(run.completedAt, run.lastActivityAt) >= 0) + ); +} + +const refinements = { + agentId: () => agentIdSchema, + completedAt: nonnegativeDateSchema, + completedById: () => v.nullable(v.string()), + completedByKind: () => v.nullable(actorKindSchema), + id: () => agentTaskRunIdSchema, + lastActivityAt: nonnegativeDateSchema, + lastUpdatedById: () => v.string(), + lastUpdatedByKind: () => actorKindSchema, + startedAt: nonnegativeDateSchema, + startedById: () => v.string(), + startedByKind: () => actorKindSchema, + task: () => agentCurrentTaskSchema, +}; + +const generatedSelectSchema = createSelectSchema(agentTaskRuns, refinements); +const selectObjectSchema = v.strictObject(generatedSelectSchema.entries); + +/** Validates one agent task run read from SQLite. */ +export const agentTaskRunSelectSchema = v.pipe( + selectObjectSchema, + v.check((run) => runIsConsistent(run), "Agent task run is inconsistent") +); + +const generatedInsertSchema = createInsertSchema(agentTaskRuns, refinements); +const insertObjectSchema = v.strictObject(generatedInsertSchema.entries); + +/** Validates one new active agent task run before insertion. */ +export const agentTaskRunInsertSchema = v.pipe( + insertObjectSchema, + v.check((run) => runIsConsistent(run), "Agent task run is inconsistent") +); + +const generatedUpdateSchema = createUpdateSchema(agentTaskRuns, refinements); + +/** Validates the complete mutable projection used to touch or finish a run. */ +export const agentTaskRunUpdateSchema = v.strictObject({ + completedAt: generatedUpdateSchema.entries.completedAt, + completedById: generatedUpdateSchema.entries.completedById, + completedByKind: generatedUpdateSchema.entries.completedByKind, + lastActivityAt: generatedUpdateSchema.entries.lastActivityAt, + lastUpdatedById: generatedUpdateSchema.entries.lastUpdatedById, + lastUpdatedByKind: generatedUpdateSchema.entries.lastUpdatedByKind, +}); diff --git a/greenfield/src/server/domains/agents/directory.ts b/greenfield/src/server/domains/agents/directory.ts new file mode 100644 index 000000000..a776f8c79 --- /dev/null +++ b/greenfield/src/server/domains/agents/directory.ts @@ -0,0 +1,60 @@ +import * as v from "valibot"; + +import { + type AgentConfiguration, + type AgentDefinition, + agentConfigurationSchema, +} from "../../../contracts/agentModel.ts"; + +const configuredAgentsInput = { + agents: [ + { + description: "Owns the operator conversation and coordinates Dashboard work.", + displayName: "Mira", + id: "main", + role: "primary", + }, + { + description: "Implements bounded code, debugging, test, and file tasks.", + displayName: "Coder", + id: "coder", + role: "specialist", + }, + { + description: "Drafts reviewed operator communication without sending it.", + displayName: "Communicator", + id: "communicator", + role: "specialist", + }, + { + description: "Runs bounded system checks and reports operational status.", + displayName: "Monitor", + id: "monitor", + role: "specialist", + }, + { + description: "Researches sources, verifies claims, and compares options.", + displayName: "Researcher", + id: "researcher", + role: "specialist", + }, + ], +} as const; + +/** Reviewed Dashboard-owned directory; it does not claim live Gateway availability. */ +export const dashboardAgentConfiguration: AgentConfiguration = Object.freeze( + v.parse(agentConfigurationSchema, configuredAgentsInput) +); + +const agentsById = new Map( + dashboardAgentConfiguration.agents.map((agent) => [agent.id, agent]) +); + +/** + * Finds one reviewed agent definition without consulting mutable external config. + * @param id Stable Dashboard agent identifier. + * @returns The reviewed definition when configured. + */ +export function findDashboardAgent(id: string): AgentDefinition | undefined { + return agentsById.get(id); +} diff --git a/greenfield/src/server/domains/agents/errors.ts b/greenfield/src/server/domains/agents/errors.ts new file mode 100644 index 000000000..c16c72b76 --- /dev/null +++ b/greenfield/src/server/domains/agents/errors.ts @@ -0,0 +1,13 @@ +import { Data } from "effect"; + +import type { DatabaseRuntimeWriteUnavailableError } from "../../database/runtime/databaseErrors.ts"; + +/** Expected lookup failure for an id outside the reviewed agent directory. */ +export class AgentNotFoundError extends Data.TaggedError("AgentNotFoundError")<{ + readonly agentId: string; + readonly message: string; +}> {} + +export type AgentOperationError = + | AgentNotFoundError + | DatabaseRuntimeWriteUnavailableError; diff --git a/greenfield/src/server/domains/agents/procedures.test.ts b/greenfield/src/server/domains/agents/procedures.test.ts new file mode 100644 index 000000000..502a58b24 --- /dev/null +++ b/greenfield/src/server/domains/agents/procedures.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; + +import { captureFailure } from "../../test/support/promise.ts"; +import { + createTestApplicationRuntime, + createTestAutomationAuthentication, + createTestRequestContext, + createTestSessionAuthentication, +} from "../../test/support/requestContext.ts"; +import { appRouter } from "../../trpc/appRouter.ts"; +import { + agentServiceFor, + openFreshMigratedDatabase, +} from "./testSupport/agentService.ts"; + +describe("agent procedures", () => { + test("enforces exact read and write capabilities", async () => { + for (const testCase of [ + { authentication: undefined, code: "UNAUTHORIZED", operation: "read" }, + { + authentication: createTestSessionAuthentication(["reports:read"]), + code: "FORBIDDEN", + operation: "read", + }, + { + authentication: createTestSessionAuthentication(["agents:read"]), + code: "FORBIDDEN", + operation: "write", + }, + { + authentication: createTestSessionAuthentication(["agents:write"]), + code: "FORBIDDEN", + operation: "write", + }, + ] as const) { + const caller = appRouter.createCaller( + await createTestRequestContext(testCase.authentication) + ).agents; + const failure = await captureFailure(() => + testCase.operation === "read" + ? caller.listStatuses({}) + : caller.updateMetadata({ agentId: "main", currentTask: null }) + ); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe(testCase.code); + } + }); + + test("serves session reads and automation current-task writes", async () => { + const database = await openFreshMigratedDatabase(); + const agentService = agentServiceFor(database); + try { + const automationContext = await createTestRequestContext( + createTestAutomationAuthentication(["agents:read", "agents:write"]), + createTestApplicationRuntime(), + { agentService } + ); + const automationCaller = appRouter.createCaller(automationContext).agents; + expect( + await automationCaller.updateMetadata({ + agentId: "main", + currentTask: "Implement agent procedures", + }) + ).toMatchObject({ state: "working" }); + + const sessionContext = await createTestRequestContext( + createTestSessionAuthentication(["agents:read"]), + createTestApplicationRuntime(), + { agentService } + ); + const sessionCaller = appRouter.createCaller(sessionContext).agents; + const configuration = await sessionCaller.getConfiguration({}); + expect(configuration.agents.map(({ id }) => id)).toEqual([ + "coder", + "communicator", + "main", + "monitor", + "researcher", + ]); + expect(await sessionCaller.getStatus({ id: "main" })).toMatchObject({ + currentTask: "Implement agent procedures", + state: "working", + }); + expect( + await sessionCaller.listTaskHistory({ agentId: "main", limit: 10 }) + ).toMatchObject({ runs: [{ status: "active" }] }); + } finally { + database.sqlite.close(true); + } + }); + + test("maps unknown configured-agent lookups to a stable not-found error", async () => { + const database = await openFreshMigratedDatabase(); + try { + const context = await createTestRequestContext( + createTestAutomationAuthentication(["agents:read"]), + createTestApplicationRuntime(), + { agentService: agentServiceFor(database) } + ); + const failure = await captureFailure(() => + appRouter.createCaller(context).agents.getStatus({ id: "unknown" }) + ); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("NOT_FOUND"); + } finally { + database.sqlite.close(true); + } + }); +}); diff --git a/greenfield/src/server/domains/agents/procedures.ts b/greenfield/src/server/domains/agents/procedures.ts new file mode 100644 index 000000000..df994c655 --- /dev/null +++ b/greenfield/src/server/domains/agents/procedures.ts @@ -0,0 +1,8 @@ +import { router } from "../../trpc/trpc.ts"; +import { agentRoutes } from "./routes.ts"; + +/** Leaf procedure names owned by the agent-domain router. */ +export const agentProcedureNames = Object.freeze(Object.keys(agentRoutes)); + +/** Capability-scoped Dashboard agent configuration and current-task router. */ +export const agentRouter = router(agentRoutes); diff --git a/greenfield/src/server/domains/agents/repository.ts b/greenfield/src/server/domains/agents/repository.ts new file mode 100644 index 000000000..c87bd01b7 --- /dev/null +++ b/greenfield/src/server/domains/agents/repository.ts @@ -0,0 +1,304 @@ +import { toDate } from "date-fns"; +import { and, desc, eq, inArray, isNull, lt, lte, or, type SQL } from "drizzle-orm"; +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; +import * as v from "valibot"; + +import { + agentTaskHistoryPageMaximum, + type ListAgentTaskHistoryInput, +} from "../../../contracts/agents.ts"; +import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWriteAdmission.ts"; +import { agentTaskRuns } from "../../database/schema/agentTaskRuns.ts"; +import { realtimeEvents } from "../../database/schema/realtime.ts"; +import { + agentTaskRunInsertSchema, + agentTaskRunSelectSchema, + agentTaskRunUpdateSchema, +} from "../../database/validation/agentTaskRuns.ts"; +import { + realtimeEventInsertSchema, + realtimeEventSelectSchema, +} from "../../database/validation/realtimeEvents.ts"; + +export type AgentTaskRunRecord = v.InferOutput; +export type AgentTaskRunInsert = v.InferOutput; +export type AgentRealtimeEventInsert = v.InferOutput; + +type TransactionCallback = Parameters[0]; +type AgentTransaction = Parameters[0]; +type AgentPersistenceDatabase = AgentTransaction | SQLiteBunDatabase; +type SynchronousResult = T extends Promise ? never : T; + +export interface AgentRunActor { + readonly id: string; + readonly kind: "automation" | "user"; +} + +export interface AgentRepositoryReader { + findActiveRun(agentId: string): AgentTaskRunRecord | undefined; + findLatestRun(agentId: string): AgentTaskRunRecord | undefined; + listActiveRuns(agentIds: readonly string[]): AgentTaskRunRecord[]; + listTaskRuns(input: ListAgentTaskHistoryInput): AgentTaskRunRecord[]; +} + +export interface AgentRepositoryUnitOfWork extends AgentRepositoryReader { + completeRun( + id: string, + completedAt: Date, + actor: AgentRunActor + ): AgentTaskRunRecord | undefined; + insertRealtimeEvent(input: AgentRealtimeEventInsert): number; + insertRun(input: AgentTaskRunInsert): AgentTaskRunRecord | undefined; + touchRun( + id: string, + lastActivityAt: Date, + actor: AgentRunActor + ): AgentTaskRunRecord | undefined; +} + +export interface AgentRepository extends AgentRepositoryReader { + withImmediateTransaction( + callback: (unit: AgentRepositoryUnitOfWork) => SynchronousResult + ): Promise; + withReadTransaction( + callback: (reader: AgentRepositoryReader) => SynchronousResult + ): T; +} + +function parseRun(row: unknown): AgentTaskRunRecord { + return v.parse(agentTaskRunSelectSchema, row); +} + +function requiredRow(row: T | undefined, operation: string): T { + if (row === undefined) + throw new Error(`Agent repository ${operation} returned no row`); + return row; +} + +function assertPageLimit(limit: number): void { + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > agentTaskHistoryPageMaximum + ) { + throw new RangeError("Agent task history page limit is invalid"); + } +} + +function historyCursorBoundary(input: ListAgentTaskHistoryInput): SQL | undefined { + if (input.cursor === undefined) return undefined; + const startedAt = toDate(input.cursor.startedAtMs); + return or( + lt(agentTaskRuns.startedAt, startedAt), + and(eq(agentTaskRuns.startedAt, startedAt), lt(agentTaskRuns.id, input.cursor.id)) + ); +} + +class DrizzleAgentRepositoryReader implements AgentRepositoryReader { + protected readonly database: AgentPersistenceDatabase; + + public constructor(database: AgentPersistenceDatabase) { + this.database = database; + } + + public findActiveRun(agentId: string): AgentTaskRunRecord | undefined { + const row = this.database + .select() + .from(agentTaskRuns) + .where( + and(eq(agentTaskRuns.agentId, agentId), isNull(agentTaskRuns.completedAt)) + ) + .get(); + return row === undefined ? undefined : parseRun(row); + } + + public findLatestRun(agentId: string): AgentTaskRunRecord | undefined { + const row = this.database + .select() + .from(agentTaskRuns) + .where(eq(agentTaskRuns.agentId, agentId)) + .orderBy(desc(agentTaskRuns.startedAt), desc(agentTaskRuns.id)) + .get(); + return row === undefined ? undefined : parseRun(row); + } + + public listActiveRuns(agentIds: readonly string[]): AgentTaskRunRecord[] { + if (agentIds.length === 0) return []; + return this.database + .select() + .from(agentTaskRuns) + .where( + and( + inArray(agentTaskRuns.agentId, [...agentIds]), + isNull(agentTaskRuns.completedAt) + ) + ) + .orderBy(desc(agentTaskRuns.agentId)) + .limit(agentIds.length + 1) + .all() + .map((row) => parseRun(row)); + } + + public listTaskRuns(input: ListAgentTaskHistoryInput): AgentTaskRunRecord[] { + assertPageLimit(input.limit); + return this.database + .select() + .from(agentTaskRuns) + .where( + and( + input.agentId === undefined + ? undefined + : eq(agentTaskRuns.agentId, input.agentId), + historyCursorBoundary(input) + ) + ) + .orderBy(desc(agentTaskRuns.startedAt), desc(agentTaskRuns.id)) + .limit(input.limit + 1) + .all() + .map((row) => parseRun(row)); + } +} + +class DrizzleAgentRepositoryUnitOfWork + extends DrizzleAgentRepositoryReader + implements AgentRepositoryUnitOfWork +{ + readonly #transaction: AgentTransaction; + + public constructor(transaction: AgentTransaction) { + super(transaction); + this.#transaction = transaction; + } + + public completeRun( + id: string, + completedAt: Date, + actor: AgentRunActor + ): AgentTaskRunRecord | undefined { + const changes = v.parse(agentTaskRunUpdateSchema, { + completedAt, + completedById: actor.id, + completedByKind: actor.kind, + lastActivityAt: completedAt, + lastUpdatedById: actor.id, + lastUpdatedByKind: actor.kind, + }); + const row = this.#transaction + .update(agentTaskRuns) + .set(changes) + .where( + and( + eq(agentTaskRuns.id, id), + isNull(agentTaskRuns.completedAt), + lte(agentTaskRuns.lastActivityAt, completedAt) + ) + ) + .returning() + .get(); + return row === undefined ? undefined : parseRun(row); + } + + public insertRealtimeEvent(input: AgentRealtimeEventInsert): number { + const row = this.#transaction + .insert(realtimeEvents) + .values(v.parse(realtimeEventInsertSchema, input)) + .returning() + .get(); + return v.parse( + realtimeEventSelectSchema, + requiredRow(row, "realtime event insert") + ).id; + } + + public insertRun(input: AgentTaskRunInsert): AgentTaskRunRecord | undefined { + const row = this.#transaction + .insert(agentTaskRuns) + .values(v.parse(agentTaskRunInsertSchema, input)) + .onConflictDoNothing() + .returning() + .get(); + return row === undefined ? undefined : parseRun(row); + } + + public touchRun( + id: string, + lastActivityAt: Date, + actor: AgentRunActor + ): AgentTaskRunRecord | undefined { + const changes = v.parse(agentTaskRunUpdateSchema, { + completedAt: null, + completedById: null, + completedByKind: null, + lastActivityAt, + lastUpdatedById: actor.id, + lastUpdatedByKind: actor.kind, + }); + const row = this.#transaction + .update(agentTaskRuns) + .set(changes) + .where( + and( + eq(agentTaskRuns.id, id), + isNull(agentTaskRuns.completedAt), + lte(agentTaskRuns.lastActivityAt, lastActivityAt) + ) + ) + .returning() + .get(); + return row === undefined ? undefined : parseRun(row); + } +} + +/** + * Creates validated reads and runtime-admitted agent current-task writes. + * @param database Process-owned Drizzle SQLite database. + * @param writeAdmission Process-owned bounded immediate-write admission. + * @returns Agent repository with snapshot reads and admitted writes. + */ +export function createAgentRepository( + database: SQLiteBunDatabase, + writeAdmission: ImmediateDatabaseWriteAdmission +): AgentRepository { + // Drizzle's generic SQLite signature retains its async-driver conditional even + // though Bun's concrete session is synchronous. Adapt it once at this boundary + // while keeping repository callbacks statically unable to return a Promise. + const runTransaction = database.transaction.bind(database) as unknown as ( + callback: (transaction: AgentTransaction) => T, + config: { behavior: "deferred" | "immediate" } + ) => T; + const withReadTransaction = ( + callback: (reader: AgentRepositoryReader) => SynchronousResult + ): T => + runTransaction( + (transaction: AgentTransaction) => + callback(new DrizzleAgentRepositoryReader(transaction)), + { behavior: "deferred" } + ); + + return Object.freeze({ + findActiveRun: (agentId: string) => + withReadTransaction((reader) => reader.findActiveRun(agentId)), + findLatestRun: (agentId: string) => + withReadTransaction((reader) => reader.findLatestRun(agentId)), + listActiveRuns: (agentIds: readonly string[]) => + withReadTransaction((reader) => reader.listActiveRuns(agentIds)), + listTaskRuns: (input: ListAgentTaskHistoryInput) => + withReadTransaction((reader) => reader.listTaskRuns(input)), + withImmediateTransaction( + callback: (unit: AgentRepositoryUnitOfWork) => SynchronousResult + ): Promise { + return writeAdmission.run((markTransactionStarted) => + runTransaction( + (transaction: AgentTransaction) => { + markTransactionStarted(); + return callback( + new DrizzleAgentRepositoryUnitOfWork(transaction) + ); + }, + { behavior: "immediate" } + ) + ); + }, + withReadTransaction, + }); +} diff --git a/greenfield/src/server/domains/agents/routes.ts b/greenfield/src/server/domains/agents/routes.ts new file mode 100644 index 000000000..9b2d7e419 --- /dev/null +++ b/greenfield/src/server/domains/agents/routes.ts @@ -0,0 +1,76 @@ +import { TRPCError } from "@trpc/server"; +import { Effect } from "effect"; + +import { + agentConfigurationSchema, + agentStatusSchema, +} from "../../../contracts/agentModel.ts"; +import { + getAgentStatusInputSchema, + emptyAgentInputSchema, + listAgentStatusesResultSchema, + listAgentTaskHistoryInputSchema, + listAgentTaskHistoryResultSchema, + updateAgentMetadataInputSchema, +} from "../../../contracts/agents.ts"; +import { capabilityProcedure } from "../../trpc/trpc.ts"; +import { AgentNotFoundError } from "./errors.ts"; + +async function runAgentEffect(effect: Effect.Effect): Promise { + try { + return await Effect.runPromise(effect); + } catch (error) { + if (error instanceof AgentNotFoundError) { + throw new TRPCError({ + cause: error, + code: "NOT_FOUND", + message: "Agent resource was not found", + }); + } + throw error; + } +} + +const readProcedure = capabilityProcedure("agents:read"); +const writeProcedure = capabilityProcedure("agents:write").use(({ ctx, next }) => { + if (ctx.principal.kind !== "automation") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "An automation principal is required", + }); + } + return next({ ctx }); +}); + +/** Capability-scoped Dashboard agent status and task-history routes. */ +export const agentRoutes = { + getConfiguration: readProcedure + .input(emptyAgentInputSchema) + .output(agentConfigurationSchema) + .query(async ({ ctx }) => { + const configuration = await runAgentEffect( + ctx.agentService.getConfiguration() + ); + return { agents: configuration.agents.map((agent) => ({ ...agent })) }; + }), + getStatus: readProcedure + .input(getAgentStatusInputSchema) + .output(agentStatusSchema) + .query(({ ctx, input }) => runAgentEffect(ctx.agentService.getStatus(input))), + listStatuses: readProcedure + .input(emptyAgentInputSchema) + .output(listAgentStatusesResultSchema) + .query(({ ctx }) => runAgentEffect(ctx.agentService.listStatuses())), + listTaskHistory: readProcedure + .input(listAgentTaskHistoryInputSchema) + .output(listAgentTaskHistoryResultSchema) + .query(({ ctx, input }) => + runAgentEffect(ctx.agentService.listTaskHistory(input)) + ), + updateMetadata: writeProcedure + .input(updateAgentMetadataInputSchema) + .output(agentStatusSchema) + .mutation(({ ctx, input }) => + runAgentEffect(ctx.agentService.updateMetadata(ctx.principal, input)) + ), +}; diff --git a/greenfield/src/server/domains/agents/service.test.ts b/greenfield/src/server/domains/agents/service.test.ts new file mode 100644 index 000000000..d08f05897 --- /dev/null +++ b/greenfield/src/server/domains/agents/service.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; + +import { count, eq } from "drizzle-orm"; + +import { agentTaskRuns } from "../../database/schema/agentTaskRuns.ts"; +import { realtimeEvents } from "../../database/schema/realtime.ts"; +import { AgentNotFoundError } from "./errors.ts"; +import { + agentServiceFor, + agentTestPrincipal, + openFreshMigratedDatabase, + runAgentEffect, +} from "./testSupport/agentService.ts"; + +describe("agent service", () => { + test("starts, touches, replaces, and clears one attributed current task", async () => { + const database = await openFreshMigratedDatabase(); + let nowMs = 10_000; + let wakeups = 0; + const service = agentServiceFor(database, { + nowMs: () => nowMs, + wakeEventPump: () => { + wakeups += 1; + }, + }); + + try { + const started = await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "main", + currentTask: "Implement agent status", + }) + ); + expect(started).toMatchObject({ + agentId: "main", + currentTask: "Implement agent status", + lastActivityAtMs: 10_000, + startedAtMs: 10_000, + state: "working", + }); + + nowMs = 9000; + const touched = await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "main", + currentTask: "Implement agent status", + }) + ); + expect(touched.lastActivityAtMs).toBe(10_000); + + nowMs = 20_000; + const replaced = await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "main", + currentTask: "Review Phase 3", + }) + ); + expect(replaced).toMatchObject({ + currentTask: "Review Phase 3", + startedAtMs: 20_000, + state: "working", + }); + + nowMs = 30_000; + const cleared = await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "main", + currentTask: null, + }) + ); + expect(cleared).toEqual({ + agentId: "main", + lastActivityAtMs: 30_000, + state: "idle", + }); + + nowMs = 25_000; + const restarted = await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "main", + currentTask: "Start after clock regression", + }) + ); + expect(restarted).toMatchObject({ + currentTask: "Start after clock regression", + lastActivityAtMs: 30_000, + startedAtMs: 30_000, + state: "working", + }); + + const history = await runAgentEffect( + service.listTaskHistory({ agentId: "main", limit: 10 }) + ); + expect(history.runs.map(({ status, task }) => ({ status, task }))).toEqual([ + { status: "active", task: "Start after clock regression" }, + { status: "completed", task: "Review Phase 3" }, + { status: "completed", task: "Implement agent status" }, + ]); + const records = database.orm + .select() + .from(agentTaskRuns) + .orderBy(agentTaskRuns.startedAt) + .all(); + expect(records).toHaveLength(3); + expect(records[0]).toMatchObject({ + completedById: "openclaw-task-tracking", + completedByKind: "automation", + lastUpdatedById: "openclaw-task-tracking", + startedById: "openclaw-task-tracking", + }); + expect(wakeups).toBe(4); + expect( + database.orm.select({ value: count() }).from(realtimeEvents).get()?.value + ).toBe(4); + } finally { + database.sqlite.close(true); + } + }); + + test("returns canonical status pages and stable filtered cursors", async () => { + const database = await openFreshMigratedDatabase(); + let nowMs = 1000; + const service = agentServiceFor(database, { nowMs: () => nowMs }); + + try { + for (const currentTask of ["First", "Second", "Third"]) { + await runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "researcher", + currentTask, + }) + ); + nowMs += 1000; + } + const statuses = await runAgentEffect(service.listStatuses()); + expect(statuses.statuses.map(({ agentId }) => agentId)).toEqual([ + "coder", + "communicator", + "main", + "monitor", + "researcher", + ]); + expect(statuses.statuses.at(-1)).toMatchObject({ + currentTask: "Third", + state: "working", + }); + + const firstPage = await runAgentEffect( + service.listTaskHistory({ agentId: "researcher", limit: 1 }) + ); + expect(firstPage.runs).toHaveLength(1); + expect(firstPage.nextCursor).toBeDefined(); + const secondPage = await runAgentEffect( + service.listTaskHistory({ + agentId: "researcher", + cursor: firstPage.nextCursor, + limit: 1, + }) + ); + expect(secondPage.runs).toHaveLength(1); + expect(secondPage.runs[0]?.id).not.toBe(firstPage.runs[0]?.id); + } finally { + database.sqlite.close(true); + } + }); + + test("fails closed for unknown agents without persistence", async () => { + const database = await openFreshMigratedDatabase(); + const service = agentServiceFor(database); + try { + expect( + runAgentEffect( + service.updateMetadata(agentTestPrincipal, { + agentId: "unknown", + currentTask: "Should not persist", + }) + ) + ).rejects.toBeInstanceOf(AgentNotFoundError); + expect( + database.orm + .select({ value: count() }) + .from(agentTaskRuns) + .where(eq(agentTaskRuns.agentId, "unknown")) + .get()?.value + ).toBe(0); + } finally { + database.sqlite.close(true); + } + }); + + test("fails closed when persisted history references an unconfigured agent", async () => { + const database = await openFreshMigratedDatabase(); + const service = agentServiceFor(database); + try { + database.sqlite.run( + `INSERT INTO agent_task_runs ( + agent_id, id, last_activity_at, last_updated_by_id, + last_updated_by_kind, started_at, started_by_id, + started_by_kind, task + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "unconfigured", + "019fd100-0000-7000-8000-000000000099", + 1000, + "openclaw-task-tracking", + "automation", + 1000, + "openclaw-task-tracking", + "automation", + "Corrupt history row", + ] + ); + + expect( + runAgentEffect(service.listTaskHistory({ limit: 10 })) + ).rejects.toThrow("Persisted agent task run references an unknown agent"); + } finally { + database.sqlite.close(true); + } + }); +}); diff --git a/greenfield/src/server/domains/agents/service.ts b/greenfield/src/server/domains/agents/service.ts new file mode 100644 index 000000000..7cfc6dc51 --- /dev/null +++ b/greenfield/src/server/domains/agents/service.ts @@ -0,0 +1,388 @@ +import { addMilliseconds, getTime, max as maximumDate, toDate } from "date-fns"; +import { Context, Data, Effect, Layer } from "effect"; +import * as v from "valibot"; + +import { + type AgentConfiguration, + type AgentStatus, + type AgentTaskRun, + agentStatusSchema, + agentTaskRunSchema, +} from "../../../contracts/agentModel.ts"; +import { + agentChangePayloadSchema, + agentRealtimeRoutingSchema, + agentRealtimeTopic, +} from "../../../contracts/agentRealtime.ts"; +import { + type GetAgentStatusInput, + type ListAgentStatusesResult, + type ListAgentTaskHistoryInput, + type ListAgentTaskHistoryResult, + type UpdateAgentMetadataInput, + listAgentStatusesResultSchema, + listAgentTaskHistoryResultSchema, +} from "../../../contracts/agents.ts"; +import type { AuthenticatedPrincipal } from "../../../contracts/security.ts"; +import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; +import { + parseSchemaWithRangeError, + positiveSafeIntegerSchema, +} from "../../../shared/validation.ts"; +import { isDatabaseRuntimeWriteUnavailableError } from "../../database/runtime/databaseErrors.ts"; +import { defaultRealtimeRetentionMilliseconds } from "../realtime/retention.ts"; +import { dashboardAgentConfiguration, findDashboardAgent } from "./directory.ts"; +import { AgentNotFoundError, type AgentOperationError } from "./errors.ts"; +import type { + AgentRepository, + AgentRepositoryUnitOfWork, + AgentRunActor, + AgentTaskRunRecord, +} from "./repository.ts"; + +const clockSchema = timestampMillisecondsSchema("Agent clock is invalid"); +const realtimeRetentionSchema = positiveSafeIntegerSchema( + "Agent realtime retention must be a positive integer" +); + +class AgentUnexpectedOperationError extends Data.TaggedError( + "AgentUnexpectedOperationError" +)<{ readonly cause: unknown }> {} + +interface AgentServiceShape { + readonly getConfiguration: () => Effect.Effect; + readonly getStatus: ( + input: GetAgentStatusInput + ) => Effect.Effect; + readonly listStatuses: () => Effect.Effect; + readonly listTaskHistory: ( + input: ListAgentTaskHistoryInput + ) => Effect.Effect; + readonly updateMetadata: ( + principal: AuthenticatedPrincipal, + input: UpdateAgentMetadataInput + ) => Effect.Effect; +} + +/** Effect service for Dashboard-owned agent configuration, status, and history. */ +export class AgentService extends Context.Service()( + "mira-dashboard/server/domains/agents/AgentService" +) {} + +export interface AgentServiceDependencies { + readonly generateId?: () => string; + readonly nowMs?: () => number; + readonly realtimeRetentionMs?: number; + readonly repository: AgentRepository; + readonly wakeEventPump?: () => Promise | void; +} + +function requireConfiguredAgent(agentId: string): void { + if (findDashboardAgent(agentId) === undefined) { + throw new AgentNotFoundError({ + agentId, + message: "Agent was not found", + }); + } +} + +function operationActor(principal: AuthenticatedPrincipal): AgentRunActor { + return principal.kind === "automation" + ? { id: principal.id, kind: "automation" } + : { id: principal.id, kind: "user" }; +} + +function toTaskRun(record: AgentTaskRunRecord): AgentTaskRun { + if (findDashboardAgent(record.agentId) === undefined) { + throw new Error("Persisted agent task run references an unknown agent"); + } + return v.parse(agentTaskRunSchema, { + agentId: record.agentId, + ...(record.completedAt === null + ? { status: "active" } + : { completedAtMs: getTime(record.completedAt), status: "completed" }), + id: record.id, + lastActivityAtMs: getTime(record.lastActivityAt), + startedAtMs: getTime(record.startedAt), + task: record.task, + }); +} + +function statusFromRecord(agentId: string, record?: AgentTaskRunRecord): AgentStatus { + if (record?.completedAt === null) { + return v.parse(agentStatusSchema, { + agentId, + currentTask: record.task, + lastActivityAtMs: getTime(record.lastActivityAt), + startedAtMs: getTime(record.startedAt), + state: "working", + }); + } + return v.parse(agentStatusSchema, { + agentId, + ...(record === undefined + ? {} + : { + lastActivityAtMs: getTime(record.completedAt ?? record.lastActivityAt), + }), + state: "idle", + }); +} + +function listStatuses(repository: AgentRepository): ListAgentStatusesResult { + return repository.withReadTransaction((reader) => { + const agentIds = dashboardAgentConfiguration.agents.map(({ id }) => id); + const activeRuns = reader.listActiveRuns(agentIds); + if (activeRuns.length > agentIds.length) { + throw new Error("Agent active-run count is outside its budget"); + } + const activeByAgent = new Map(activeRuns.map((run) => [run.agentId, run])); + return v.parse(listAgentStatusesResultSchema, { + statuses: agentIds.map((agentId) => + statusFromRecord( + agentId, + activeByAgent.get(agentId) ?? reader.findLatestRun(agentId) + ) + ), + }); + }); +} + +function listTaskHistory( + repository: AgentRepository, + input: ListAgentTaskHistoryInput +): ListAgentTaskHistoryResult { + if (input.agentId !== undefined) requireConfiguredAgent(input.agentId); + const records = repository.listTaskRuns(input); + const hasNextPage = records.length > input.limit; + const page = records.slice(0, input.limit).map((record) => toTaskRun(record)); + const last = page.at(-1); + return v.parse(listAgentTaskHistoryResultSchema, { + ...(hasNextPage && last !== undefined + ? { + nextCursor: { + id: last.id, + startedAtMs: last.startedAtMs, + }, + } + : {}), + runs: page, + }); +} + +function appendRealtimeEvent( + unit: AgentRepositoryUnitOfWork, + agentId: string, + occurredAt: Date, + retentionMs: number +): void { + v.parse(agentRealtimeRoutingSchema, { + entityType: "agent", + operation: "updated", + topic: agentRealtimeTopic, + }); + const payload = v.parse(agentChangePayloadSchema, { id: agentId }); + unit.insertRealtimeEvent({ + entityId: agentId, + entityType: "agent", + expiresAt: addMilliseconds(occurredAt, retentionMs), + occurredAt, + operation: "updated", + payloadJson: JSON.stringify(payload), + topic: agentRealtimeTopic, + }); +} + +function requiredWrite( + record: AgentTaskRunRecord | undefined, + operation: string +): AgentTaskRunRecord { + if (record === undefined) { + throw new Error(`Agent ${operation} changed unexpectedly`); + } + return record; +} + +function insertRun( + unit: AgentRepositoryUnitOfWork, + input: UpdateAgentMetadataInput & { readonly currentTask: string }, + actor: AgentRunActor, + occurredAt: Date, + generateId: () => string +): AgentTaskRunRecord { + return requiredWrite( + unit.insertRun({ + agentId: input.agentId, + completedAt: null, + completedById: null, + completedByKind: null, + id: generateId(), + lastActivityAt: occurredAt, + lastUpdatedById: actor.id, + lastUpdatedByKind: actor.kind, + startedAt: occurredAt, + startedById: actor.id, + startedByKind: actor.kind, + task: input.currentTask, + }), + "task-run insert" + ); +} + +interface MutationResult { + readonly changed: boolean; + readonly status: AgentStatus; +} + +function updateInsideTransaction( + unit: AgentRepositoryUnitOfWork, + input: UpdateAgentMetadataInput, + actor: AgentRunActor, + now: Date, + generateId: () => string, + retentionMs: number +): MutationResult { + const active = unit.findActiveRun(input.agentId); + const latest = active ?? unit.findLatestRun(input.agentId); + const latestActivityAt = latest?.completedAt ?? latest?.lastActivityAt; + const occurredAt = maximumDate([ + now, + ...(latestActivityAt === undefined ? [] : [latestActivityAt]), + ]); + + if (input.currentTask === null) { + if (active === undefined) { + return { + changed: false, + status: statusFromRecord(input.agentId, latest), + }; + } + const completed = requiredWrite( + unit.completeRun(active.id, occurredAt, actor), + "task-run completion" + ); + appendRealtimeEvent(unit, input.agentId, occurredAt, retentionMs); + return { changed: true, status: statusFromRecord(input.agentId, completed) }; + } + + if (active?.task === input.currentTask) { + const touched = requiredWrite( + unit.touchRun(active.id, occurredAt, actor), + "task-run touch" + ); + return { changed: false, status: statusFromRecord(input.agentId, touched) }; + } + + if (active !== undefined) { + requiredWrite( + unit.completeRun(active.id, occurredAt, actor), + "task-run replacement" + ); + } + const inserted = insertRun( + unit, + { ...input, currentTask: input.currentTask }, + actor, + occurredAt, + generateId + ); + appendRealtimeEvent(unit, input.agentId, occurredAt, retentionMs); + return { changed: true, status: statusFromRecord(input.agentId, inserted) }; +} + +function unexpected(error: unknown): AgentNotFoundError | AgentUnexpectedOperationError { + return error instanceof AgentNotFoundError + ? error + : new AgentUnexpectedOperationError({ cause: error }); +} + +function readEffect(operation: () => T): Effect.Effect { + return Effect.try({ catch: unexpected, try: operation }).pipe( + Effect.catchTag("AgentUnexpectedOperationError", (error) => + Effect.die(error.cause) + ) + ); +} + +function mutationEffect( + operation: () => Promise +): Effect.Effect { + return Effect.tryPromise({ + catch: (error) => + error instanceof AgentNotFoundError || + isDatabaseRuntimeWriteUnavailableError(error) + ? error + : new AgentUnexpectedOperationError({ cause: error }), + try: operation, + }).pipe( + Effect.catchTag("AgentUnexpectedOperationError", (error) => + Effect.die(error.cause) + ) + ); +} + +/** + * Creates the agent application service over validated admitted persistence. + * @param dependencies Repository plus replaceable clock, IDs, and realtime wakeup. + * @returns Effect service with typed expected agent-domain failures. + */ +export function createAgentService( + dependencies: AgentServiceDependencies +): AgentService["Service"] { + const generateId = dependencies.generateId ?? (() => Bun.randomUUIDv7()); + const nowMs = dependencies.nowMs ?? Date.now; + const retentionMs = parseSchemaWithRangeError( + realtimeRetentionSchema, + dependencies.realtimeRetentionMs ?? defaultRealtimeRetentionMilliseconds + ); + const now = () => toDate(v.parse(clockSchema, nowMs())); + + return AgentService.of({ + getConfiguration: () => Effect.succeed(dashboardAgentConfiguration), + getStatus: (input) => + readEffect(() => { + requireConfiguredAgent(input.id); + return statusFromRecord( + input.id, + dependencies.repository.findLatestRun(input.id) + ); + }), + listStatuses: () => Effect.sync(() => listStatuses(dependencies.repository)), + listTaskHistory: (input) => + readEffect(() => listTaskHistory(dependencies.repository, input)), + updateMetadata: (principal, input) => + mutationEffect(async () => { + requireConfiguredAgent(input.agentId); + const result = await dependencies.repository.withImmediateTransaction( + (unit) => + updateInsideTransaction( + unit, + input, + operationActor(principal), + now(), + generateId, + retentionMs + ) + ); + if (result.changed && dependencies.wakeEventPump !== undefined) { + try { + await dependencies.wakeEventPump(); + } catch { + // SQLite remains authoritative; adaptive polling recovers the wakeup. + } + } + return result.status; + }), + }); +} + +/** + * Provides the agent service as an Effect layer. + * @param dependencies Repository plus replaceable clock, IDs, and realtime wakeup. + * @returns Layer containing one agent service. + */ +export function agentServiceLayer( + dependencies: AgentServiceDependencies +): Layer.Layer { + return Layer.succeed(AgentService, createAgentService(dependencies)); +} diff --git a/greenfield/src/server/domains/agents/testSupport/agentService.ts b/greenfield/src/server/domains/agents/testSupport/agentService.ts new file mode 100644 index 000000000..3b3f70d26 --- /dev/null +++ b/greenfield/src/server/domains/agents/testSupport/agentService.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; + +import type { AuthenticatedPrincipal } from "../../../../contracts/security.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../../test/support/databaseWriteAdmission.ts"; +import type { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; +import { createAgentRepository } from "../repository.ts"; +import { createAgentService } from "../service.ts"; + +export type TestAgentDatabase = Awaited>; + +/** + * Creates a stable UUIDv7 used by deterministic agent-domain tests. + * @param index Numeric fixture discriminator. + * @returns Stable lowercase UUIDv7. + */ +export function agentTestUuid(index: number): string { + return `019fd100-0000-7000-8000-${index.toString(16).padStart(12, "0")}`; +} + +/** Authenticated automation principal matching the production task-tracking caller shape. */ +export const agentTestPrincipal: AuthenticatedPrincipal = Object.freeze({ + authorizationVersion: 1, + capabilities: Object.freeze(["agents:read", "agents:write"] as const), + authenticatorId: agentTestUuid(50_000), + id: "openclaw-task-tracking", + kind: "automation", +}); + +/** + * Creates a deterministic increasing UUIDv7 generator for agent task runs. + * @param start First numeric discriminator. + * @returns UUIDv7 generator. + */ +export function agentTestIdGenerator(start = 1): () => string { + let next = start; + return () => agentTestUuid(next++); +} + +/** + * Creates an agent service over one isolated migrated test database. + * @param database Isolated migrated test database. + * @param overrides Deterministic service boundaries. + * @returns Agent service bound to the supplied database. + */ +export function agentServiceFor( + database: TestAgentDatabase, + overrides: { + readonly generateId?: () => string; + readonly nowMs?: () => number; + readonly wakeEventPump?: () => Promise | void; + } = {} +) { + return createAgentService({ + generateId: overrides.generateId ?? agentTestIdGenerator(), + nowMs: overrides.nowMs ?? (() => 10_000), + repository: createAgentRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ), + wakeEventPump: overrides.wakeEventPump, + }); +} + +/** + * Runs one agent service Effect through the default test runtime. + * @param effect Agent service operation. + * @returns Promise for its successful value. + */ +export function runAgentEffect(effect: Effect.Effect): Promise { + return Effect.runPromise(effect); +} + +export { openFreshMigratedDatabase } from "../../../test/support/freshDatabase.ts"; diff --git a/greenfield/src/server/domains/agents/testSupport/service.ts b/greenfield/src/server/domains/agents/testSupport/service.ts new file mode 100644 index 000000000..4f3e28a3f --- /dev/null +++ b/greenfield/src/server/domains/agents/testSupport/service.ts @@ -0,0 +1,50 @@ +import { Effect } from "effect"; + +import type { AgentStatus } from "../../../../contracts/agentModel.ts"; +import { dashboardAgentConfiguration, findDashboardAgent } from "../directory.ts"; +import { AgentNotFoundError } from "../errors.ts"; +import { AgentService } from "../service.ts"; + +function missingAgent(agentId: string): AgentNotFoundError { + return new AgentNotFoundError({ agentId, message: "Agent was not found" }); +} + +function testStatus(agentId: string, currentTask: string | null): AgentStatus { + if (currentTask === null) return { agentId, state: "idle" }; + return { + agentId, + currentTask, + lastActivityAtMs: 0, + startedAtMs: 0, + state: "working", + }; +} + +/** + * Creates a deterministic non-persistent service for unrelated context/router tests. + * @returns Inert agent service with reviewed configuration and idle statuses. + */ +export function createTestAgentService(): AgentService["Service"] { + return AgentService.of({ + getConfiguration: () => Effect.succeed(dashboardAgentConfiguration), + getStatus: ({ id }) => + findDashboardAgent(id) === undefined + ? Effect.fail(missingAgent(id)) + : Effect.succeed({ agentId: id, state: "idle" }), + listStatuses: () => + Effect.succeed({ + statuses: dashboardAgentConfiguration.agents.map(({ id }) => ({ + agentId: id, + state: "idle" as const, + })), + }), + listTaskHistory: ({ agentId }) => + agentId !== undefined && findDashboardAgent(agentId) === undefined + ? Effect.fail(missingAgent(agentId)) + : Effect.succeed({ runs: [] }), + updateMetadata: (_principal, input) => + findDashboardAgent(input.agentId) === undefined + ? Effect.fail(missingAgent(input.agentId)) + : Effect.succeed(testStatus(input.agentId, input.currentTask)), + }); +} diff --git a/greenfield/src/server/domains/security/requestAuthenticationSession.test.ts b/greenfield/src/server/domains/security/requestAuthenticationSession.test.ts index 02733329f..f2e2b81c5 100644 --- a/greenfield/src/server/domains/security/requestAuthenticationSession.test.ts +++ b/greenfield/src/server/domains/security/requestAuthenticationSession.test.ts @@ -8,6 +8,7 @@ import { secondsToMilliseconds, } from "date-fns"; +import { applicationCapabilities } from "../../../contracts/security.ts"; import type { RawAuthenticationCredential } from "../../rawHttp/authenticationCredentials.ts"; import { parseOpaqueToken } from "../../shared/opaqueToken.ts"; import { parseAuthenticationResolution } from "./authenticationResolution.ts"; @@ -85,12 +86,7 @@ describe("session request authentication", () => { kind: "authenticated", principal: { authorizationVersion: 1, - capabilities: [ - "notifications:read", - "reports:read", - "tasks:read", - "tasks:write", - ], + capabilities: applicationCapabilities, authenticatorId: fixture.session.prefix, id: authenticationTestUserId, kind: "session", diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index 8d3da837b..909af6bf2 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -5,6 +5,8 @@ import type { ApplicationCapability, RequestAuthentication, } from "../../../contracts/security.ts"; +import type { AgentService } from "../../domains/agents/service.ts"; +import { createTestAgentService } from "../../domains/agents/testSupport/service.ts"; import type { AuthenticationLifecycleService } from "../../domains/security/authenticationLifecycle.ts"; import type { AuthenticationResolution } from "../../domains/security/authenticationResolution.ts"; import type { @@ -380,6 +382,7 @@ export function createTestMfaLoginLifecycleService( } export interface TestServerSecurityServices { + readonly agentService: AgentService["Service"]; readonly authenticateCredential: AuthenticateCredential; readonly authenticationLifecycle: AuthenticationLifecycleService; readonly automationSecurityLifecycle: AutomationSecurityLifecycleService; @@ -398,6 +401,7 @@ export function createTestServerSecurityServices( overrides: Partial = {} ): TestServerSecurityServices { return { + agentService: overrides.agentService ?? createTestAgentService(), authenticateCredential: overrides.authenticateCredential ?? (() => ({ authentication: { kind: "anonymous" as const } })), @@ -490,6 +494,7 @@ export function createTestRequestContext( authentication: RequestAuthentication = anonymousAuthentication, applicationRuntime = createTestApplicationRuntime(), options: { + readonly agentService?: AgentService["Service"]; readonly authenticationClientSourceId?: string; readonly authenticationLifecycle?: AuthenticationLifecycleService; readonly automationSecurityLifecycle?: AutomationSecurityLifecycleService; @@ -505,6 +510,7 @@ export function createTestRequestContext( const request = options.request ?? new Request("http://localhost/trpc/test"); const credentials = readAuthenticationHttpCredentials(request); return createRequestContext({ + agentService: options.agentService ?? createTestAgentService(), applicationRuntime, authenticationCredential: credentials.authentication, authenticationClientSourceId: diff --git a/greenfield/src/server/trpc/appRouter.ts b/greenfield/src/server/trpc/appRouter.ts index d02299bb0..863bd0c8c 100644 --- a/greenfield/src/server/trpc/appRouter.ts +++ b/greenfield/src/server/trpc/appRouter.ts @@ -1,3 +1,4 @@ +import { agentProcedureNames, agentRouter } from "../domains/agents/procedures.ts"; import { eventsProcedureNames, eventsRouter } from "../domains/realtime/procedures.ts"; import { automationSecurityProcedureNames, @@ -25,6 +26,7 @@ function namespacedProcedureNames( /** Root tRPC router for the application. */ export const appRouter = router({ + agents: agentRouter, accountSecurity: accountSecurityRouter, auth: authRouter, automationSecurity: automationSecurityRouter, @@ -36,6 +38,7 @@ export const appRouter = router({ /** First-party procedure inventory produced by the same route records as the root router. */ export const appRouterProcedureNames = Object.freeze([ + ...namespacedProcedureNames("agents", agentProcedureNames), ...namespacedProcedureNames("accountSecurity", accountSecurityProcedureNames), ...namespacedProcedureNames("auth", authProcedureNames), ...namespacedProcedureNames("automationSecurity", automationSecurityProcedureNames), diff --git a/greenfield/src/server/trpc/context.test.ts b/greenfield/src/server/trpc/context.test.ts index ac9e895fc..0339c31ef 100644 --- a/greenfield/src/server/trpc/context.test.ts +++ b/greenfield/src/server/trpc/context.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { createTestAgentService } from "../domains/agents/testSupport/service.ts"; import { createTestTaskService } from "../domains/tasks/testSupport/service.ts"; import { readAuthenticationHttpCredentials } from "../rawHttp/authenticationCredentials.ts"; import { generateOpaqueToken } from "../shared/opaqueToken.ts"; @@ -31,6 +32,7 @@ describe("tRPC request context", () => { const responseHeaders = new Headers(); const context = await createRequestContext({ + agentService: createTestAgentService(), applicationRuntime, authenticationCredential: credentials.authentication, authenticationClientSourceId: "client-source-1", @@ -106,6 +108,7 @@ describe("tRPC request context", () => { const request = new Request("http://localhost/trpc/auth.status"); const credentials = readAuthenticationHttpCredentials(request); const context = await createRequestContext({ + agentService: createTestAgentService(), applicationRuntime: createTestApplicationRuntime(), authenticationCredential: credentials.authentication, authenticationClientSourceId: "client-source-without-user-agent", @@ -132,6 +135,7 @@ describe("tRPC request context", () => { const request = new Request("http://localhost/trpc/events.stream"); const credentials = readAuthenticationHttpCredentials(request); await createRequestContext({ + agentService: createTestAgentService(), applicationRuntime: createTestApplicationRuntime(), authenticationCredential: credentials.authentication, authenticationClientSourceId: "client-source-2", diff --git a/greenfield/src/server/trpc/context.ts b/greenfield/src/server/trpc/context.ts index cfecc251a..ff007d3b8 100644 --- a/greenfield/src/server/trpc/context.ts +++ b/greenfield/src/server/trpc/context.ts @@ -1,4 +1,5 @@ import type { RequestAuthentication } from "../../contracts/security.ts"; +import type { AgentService } from "../domains/agents/service.ts"; import type { AuthenticationLifecycleService } from "../domains/security/authenticationLifecycle.ts"; import { type AuthenticationLease, @@ -23,6 +24,7 @@ export type AuthenticateCredential = (credential: RawAuthenticationCredential) = /** Dependencies supplied while constructing one application request context. */ export interface RequestContextOptions { + readonly agentService: AgentService["Service"]; readonly applicationRuntime: ApplicationRuntime; readonly authenticationCredential: RawAuthenticationCredential; readonly authenticationClientSourceId: string; @@ -41,6 +43,7 @@ export interface RequestContextOptions { /** Dependencies supplied to every application tRPC procedure. */ export interface RequestContext { + readonly agentService: AgentService["Service"]; readonly authentication: RequestAuthentication; readonly authenticationClientSourceId: string; readonly authenticationLifecycle: AuthenticationLifecycleService; @@ -70,6 +73,7 @@ export async function createRequestContext( ); const userAgent = options.request.headers.get("user-agent"); return Object.freeze({ + agentService: options.agentService, authentication: resolution.authentication, authenticationClientSourceId: options.authenticationClientSourceId, authenticationLifecycle: options.authenticationLifecycle, diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index 3179952e5..f65af88f1 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -25,6 +25,16 @@ function freezeProcedureExpectedErrorPolicy< * independent enforcement boundary, and the startup assertion detects drift between them. */ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ + "agents.getConfiguration": ["FORBIDDEN", "UNAUTHORIZED"], + "agents.getStatus": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + "agents.listStatuses": ["FORBIDDEN", "UNAUTHORIZED"], + "agents.listTaskHistory": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + "agents.updateMetadata": [ + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], "accountSecurity.beginTotpEnrollment": [ "CONFLICT", "FORBIDDEN", diff --git a/greenfield/src/shared/databaseMigrationManifest.ts b/greenfield/src/shared/databaseMigrationManifest.ts index a362252a6..570d253b4 100644 --- a/greenfield/src/shared/databaseMigrationManifest.ts +++ b/greenfield/src/shared/databaseMigrationManifest.ts @@ -13,8 +13,8 @@ export const migrationManifest = Object.freeze { frontend.routes .filter((route) => route.target.delivery === "implemented") .map(({ path }) => path) - ).toEqual(["/login", "/tasks"]); + ).toEqual(["/agents", "/login", "/tasks"]); expect(countByPhase(frontend.routes)).toEqual({ "phase-2": 1, "phase-3": 5, From 31bc23e3d125260a217e73f2a7075f3df7c16034 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Fri, 7 Aug 2026 18:32:42 +0200 Subject: [PATCH 2/3] fix(rewrite): close Phase 3 agent review findings --- .../greenfield-rewrite/progress.md | 2 +- .../src/browser/agents/AgentsRoute.test.tsx | 117 +++++++++++++++++- greenfield/src/browser/agents/AgentsRoute.tsx | 10 +- .../src/browser/agents/agentCollections.ts | 6 +- greenfield/src/browser/agents/agentQueries.ts | 2 + .../agents/useAgentCollectionQueryState.ts | 28 +++++ .../api/useRealtimeQueryInvalidation.ts | 9 +- .../AuthenticatedSessionActivity.test.tsx | 32 +++-- .../auth/AuthenticatedSessionActivity.tsx | 6 +- greenfield/src/browser/auth/authQueries.ts | 37 +++--- .../browser/auth/useAuthenticationAction.ts | 6 +- .../src/browser/data/dashboardCollections.ts | 47 +++++-- .../security/SessionManagementSection.tsx | 14 ++- .../useTaskRealtimeInvalidation.test.tsx | 48 +++++++ .../securityIdentitySchema.automation.test.ts | 18 +-- .../src/server/domains/agents/service.test.ts | 58 ++++++++- .../src/server/domains/agents/service.ts | 16 ++- 17 files changed, 381 insertions(+), 75 deletions(-) create mode 100644 greenfield/src/browser/agents/useAgentCollectionQueryState.ts diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 4fbfab5e8..1c5877e66 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -12,7 +12,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | 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 | The task domain and `/tasks` parity slice are implemented with durable history, realtime invalidation, and browser workflows. Agent, report, incident, notification, schedule/job, cache/metrics, overview, and worker-domain parity remain open. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Report, incident, notification, schedule/job, cache/metrics, overview, and worker-domain parity remain open. | | 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | | 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | | 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. | diff --git a/greenfield/src/browser/agents/AgentsRoute.test.tsx b/greenfield/src/browser/agents/AgentsRoute.test.tsx index 745852125..3c3d3f66f 100644 --- a/greenfield/src/browser/agents/AgentsRoute.test.tsx +++ b/greenfield/src/browser/agents/AgentsRoute.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { createMemoryHistory } from "@tanstack/react-router"; import { act } from "react"; @@ -11,6 +11,7 @@ import { type DashboardTrpcTransport, } from "../api/trpcClient.ts"; import { DashboardBrowserApplication } from "../application.tsx"; +import { resetAuthenticatedBrowserCache } from "../auth/authQueries.ts"; import { createDashboardBrowserCollections, type DashboardBrowserCollections, @@ -18,8 +19,9 @@ import { import { createDashboardRouter } from "../router.tsx"; import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClient.ts"; import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; +import { agentStatusesQueryKey } from "./agentQueries.ts"; -const { render, screen } = await import("@testing-library/react"); +const { render, screen, waitFor } = await import("@testing-library/react"); const userEventModule = await import("@testing-library/user-event"); const userEvent = userEventModule.default; @@ -47,6 +49,7 @@ const unexpectedWebAuthnClient: DashboardWebAuthnClient = Object.freeze({ }); class AgentTransport implements DashboardTrpcTransport { + configurationQueryCount = 0; mainStatus: AgentStatus = { agentId: "main", currentTask: "Implement agents route", @@ -54,6 +57,8 @@ class AgentTransport implements DashboardTrpcTransport { startedAtMs: timestampMs, state: "working", }; + statusQueryCount = 0; + statusQueryResponse: Promise | undefined; mutation(path: string): Promise { return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); @@ -65,6 +70,7 @@ class AgentTransport implements DashboardTrpcTransport { return Promise.resolve(authenticatedStatus); } case "agents.getConfiguration": { + this.configurationQueryCount += 1; return Promise.resolve({ agents: [ { @@ -83,6 +89,10 @@ class AgentTransport implements DashboardTrpcTransport { }); } case "agents.listStatuses": { + this.statusQueryCount += 1; + if (this.statusQueryResponse !== undefined) { + return this.statusQueryResponse; + } return Promise.resolve({ statuses: [this.mainStatus], }); @@ -234,4 +244,107 @@ describe("Dashboard agents route", () => { expect(await screen.findByText("Older agent task")).toBeTruthy(); expect(screen.getByText("Newest agent task")).toBeTruthy(); }); + + test("recreates agent collections after the authenticated cache is reset", async () => { + const transport = new AgentTransport(); + const queryClient = createDashboardQueryClient(); + queryClients.push(queryClient); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + const firstView = render( + + ); + mountedViews.push(firstView); + + expect(await screen.findByRole("heading", { name: "Mira" })).toBeTruthy(); + expect(transport.configurationQueryCount).toBe(1); + expect(transport.statusQueryCount).toBe(1); + firstView.unmount(); + mountedViews.splice(mountedViews.indexOf(firstView), 1); + + await act(async () => { + await resetAuthenticatedBrowserCache( + queryClient, + collections, + authenticatedStatus + ); + }); + mountedViews.push( + render( + + ) + ); + + expect(await screen.findByRole("heading", { name: "Mira" })).toBeTruthy(); + await waitFor(() => { + expect(transport.configurationQueryCount).toBe(2); + expect(transport.statusQueryCount).toBe(2); + }); + expect(screen.getAllByText("Implement agents route")).toHaveLength(2); + }); + + test("renders the final collection refresh failure and clears busy state", async () => { + const transport = new AgentTransport(); + const queryClient = createDashboardQueryClient(); + queryClient.setQueryDefaults(agentStatusesQueryKey, { retry: false }); + queryClients.push(queryClient); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/agents"] }) + ); + const trpcClient = createDashboardTrpcClient(transport); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + collectionRegistries.push(collections); + mountedViews.push( + render( + + ) + ); + const user = userEvent.setup(); + const statusRefresh = Promise.withResolvers(); + + expect(await screen.findByRole("heading", { name: "Mira" })).toBeTruthy(); + transport.statusQueryResponse = statusRefresh.promise; + await user.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByText("Refreshing…")).toBeTruthy(); + + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => { + statusRefresh.reject(new TypeError("redacted status failure")); + await statusRefresh.promise.catch(() => {}); + }); + expect(consoleError).toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + expect(await screen.findByRole("alert")).toBeTruthy(); + await waitFor(() => expect(screen.queryByText("Refreshing…")).toBeNull()); + expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy(); + }); }); diff --git a/greenfield/src/browser/agents/AgentsRoute.tsx b/greenfield/src/browser/agents/AgentsRoute.tsx index 327788a89..1edcb4d33 100644 --- a/greenfield/src/browser/agents/AgentsRoute.tsx +++ b/greenfield/src/browser/agents/AgentsRoute.tsx @@ -18,6 +18,7 @@ import { PageHeader } from "../ui/PageHeader.tsx"; import { AgentHistoryTable } from "./AgentHistoryTable.tsx"; import { agentHistoryQueryOptions } from "./agentQueries.ts"; import { AgentStatusGrid } from "./AgentStatusGrid.tsx"; +import { useAgentCollectionQueryState } from "./useAgentCollectionQueryState.ts"; import { useAgentRealtimeInvalidation } from "./useAgentRealtimeInvalidation.ts"; const emptyAgents: readonly AgentDefinition[] = Object.freeze([]); @@ -31,13 +32,14 @@ export function AgentsRoute() { const collections = useDashboardBrowserCollections().agents; const configuration = useLiveQuery(collections.definitions); const statuses = useLiveQuery(collections.statuses); + const collectionQueries = useAgentCollectionQueryState(); const history = useInfiniteQuery(agentHistoryQueryOptions(client)); const agents = configuration.data ?? emptyAgents; const agentStatuses = statuses.data ?? emptyStatuses; const runs = history.data?.pages.flatMap((page) => page.runs) ?? emptyRuns; const error = - collections.definitions.utils.lastError ?? - collections.statuses.utils.lastError ?? + collectionQueries.configuration?.error ?? + collectionQueries.statuses?.error ?? history.error; const pending = configuration.isLoading || statuses.isLoading || history.isPending; const hasCompleteData = @@ -57,8 +59,8 @@ export function AgentsRoute() { actions={