From 85ed8b426cb4941f135c51df44f2b3cdedafb9c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:22:55 +0000 Subject: [PATCH 001/517] docs(agents): rework plan around dedicated packages/ + drop v1 validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runtime is three new packages under packages/ (agent-core, agent-ingress, agent-runner), no shared code with nodejs/ — cherry-pick by copy. - Treat cyclotron-v2 as a concept to reimplement in agent-core, not as a runtime dependency. Queue gets its own dedicated DB. - Drop the Celery async validator from v1. Models keep the full state machine; complete_upload transitions straight to ready. Future validator ships as a fourth node package (agent-validator). --- docs/internal/agent-platform.md | 343 ++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/internal/agent-platform.md diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md new file mode 100644 index 000000000000..ccef5b1f846b --- /dev/null +++ b/docs/internal/agent-platform.md @@ -0,0 +1,343 @@ +# Plan: Agent platform — posthog implementation + +## Context + +Companion to [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/agent-stack/blob/main/docs/agent-platform.md). That doc covers the full system; this one is the posthog-side build plan. Where the two conflict, this plan wins for posthog-side concerns. + +Two things we own here: + +1. **Management plane** — a new flag-gated product under `products/agents/` (Django app + viewsets + frontend), modelled on the existing [`products/deployments/`](../../products/deployments) scaffold from #58421. +2. **Runtime** — three new TypeScript packages under `packages/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `packages/agent-core/`. + +The runtime split (ingress + runner) from the agent-stack doc still holds. This plan refines what each half looks like inside the posthog monorepo and which existing primitives we lean on conceptually (not by import). + +--- + +## Runtime packages + +Three packages under `packages/`, each its own process / deployment: + +``` +packages/ + agent-core/ # shared types, db client, queue primitives, manifest reader + agent-ingress/ # process: HTTP ingress, *.agents.posthog.com terminator + agent-runner/ # process: session executor (Claude Agent SDK + tools + sandbox) +``` + +A fourth package will land later for async bundle validation (see §C below). v1 does not ship it. + +**Hard rule: no imports from `nodejs/`.** When we need a primitive that exists in `nodejs/` (cyclotron queue ops, structured logger, Prom metrics middleware, Postgres connection pool wrapper, Redis client, etc.) we copy the relevant code into `packages/agent-core/` and adapt it. We pay a duplication cost upfront in exchange for: + +- Independent dependency graph — no plugin-server transitive cruft. +- Independent deploy cadence and release process. +- Free hand to delete/restructure without coordinating with CDP. +- Clean ownership boundary for codeowners / on-call. + +Cherry-pick what we want, leave the rest. The legacy concepts the agent-stack plan calls out (plugin VMs, worker thread topology, event-pipeline-shaped hooks) don't come with us. + +### `packages/agent-core/` + +Shared library, no process of its own. Lives here: + +- TypeScript types for the session model, manifest, tool protocol, secrets. +- Postgres client(s) — one for the main posthog DB (read app/revision/secret rows; write `AgentSession`/`SandboxInstance` rows), one for the agent-runtime queue DB (jobs). Each package depends on whichever it needs. +- **Queue primitives** — the cyclotron-v2-shaped session queue (see next section). Single `cyclotron_jobs`-style table with `available | running | completed | failed | canceled`, `FOR UPDATE SKIP LOCKED` dequeue, `lock_id` + `last_heartbeat`, `reschedule({ scheduledAt, state })`, janitor loop. The schema and ops are a clean reimplementation in this package — we own it end-to-end, no shared migrations with `cyclotron_node`. +- Internal-API HTTP client (talks to Django for resolve/decrypt). +- Structured logger, Prom registry, OTel setup. +- Manifest reader / built-ins registry (also imported by the future validator package, so the same code rejects unknown ids in both places). + +### `packages/agent-ingress/` + +The public-facing process. Responsibilities: + +- All `*.agents.posthog.com` traffic terminates here. +- Domain → `(application, revision)` resolution via the Django internal `/internal/agents/applications/resolve` endpoint. In-process LRU keyed by revision id, invalidated on promotion (we expose a small admin endpoint for Django to ping after promote — or just rely on TTL, decide at impl time). +- Per-app auth derived from the resolved revision's config (public / webhook signature / shared secret). +- Implements `/run`, `/listen/:id`, `/send/:id`, `/webhooks/:provider`, `/health`, `/status`. Same contract as the SDK's local dev server. +- `/run` writes an `AgentSession` row + enqueues a session job in the agent-core queue, returns `{ session_id }` immediately. +- `/listen` subscribes to the Redis pub-sub channel `agent_session:{id}` for SSE streaming. +- `/send` publishes a message into `agent_session:{id}:input` — runner picks it up at the next yield. + +**Hard rule (matches agent-stack plan):** ingress imports zero Anthropic / Claude Agent SDK / Modal code, and never decrypts a secret. Enforced by an `eslint-plugin-no-restricted-imports` rule in the package. The blast-radius win is the whole point of splitting from the runner. + +### `packages/agent-runner/` + +The session executor. Responsibilities: + +1. Dequeues a session job from the agent-core queue (lock + heartbeat handled by the queue layer). +2. Loads `parsed_manifest` from cached internal-API resolve. +3. Restores Claude Agent SDK state from the job's `state` payload. +4. Runs one "turn" — until the next tool boundary or completion. +5. Two cases: + - **Completion** → ack the job, write final `output` to `AgentSession`, publish completion to pub-sub. + - **Suspension** (long-running tool, sandbox call, waiting on `/send`) → `reschedule({ scheduledAt, state: serialized_sdk_state })`. Heartbeats keep ticking while inside a turn so we don't get reaped mid-execution. +6. Streams events to the pub-sub bus throughout. + +Tool execution split: + +- **Meta tools** — in-process. Trivial. +- **Referenced (built-in) tools** — in-process. Built-ins registry is a hardcoded map in `agent-core` (e.g. `packages/agent-core/src/builtins/index.ts`). The future validator package imports the same map so unknown ids fail before deploy. +- **Local tools** — proxied to a Modal sandbox via the sandbox manager. Per-invocation secrets passed in the call, never persisted in the sandbox. + +Sandbox manager: + +- Looks up the live `SandboxInstance` row for `(application, revision)`. JIT-provisions on first request. +- Updates `last_used_at` on each call. +- Periodic reaper job (cooperative Postgres advisory lock) destroys sandboxes idle > TTL. + +Reaper: + +- Runs in the runner process. Two passes per tick: + 1. **Sessions** — the queue janitor resets stalled jobs; we additionally write `AgentSession.state = 'failed'` for any session whose job hit the poison-pill threshold. + 2. **Sandboxes** — described above. + +--- + +## Why cyclotron-v2 — as a concept, not a dependency + +A Claude Agent SDK run looks structurally identical to the CDP hog-flow execution model: long-running, stateful, crosses many tool / model-call boundaries, each boundary a natural suspend/resume point, no ordering between concurrent runs, needs lock-based concurrency with heartbeats, needs a janitor for stalled or poisoned jobs. + +cyclotron-v2 has solved exactly these problems in production for CDP. We **reimplement the concepts** in `agent-core`, copying the relevant code where it's cheaper than rebuilding, with no runtime dependency on `nodejs/src/cdp/services/cyclotron-v2/` or the `cyclotron_node` schema. + +| cyclotron-v2 concept | Agent-core mirror | Reference (for copying) | +| --- | --- | --- | +| `JobState: available \| running \| completed \| failed \| canceled` | Same enum, drop-in for `AgentSession.state`. | [`rust/cyclotron-core/src/types.rs:10`](../../rust/cyclotron-core/src/types.rs) | +| `lock_id` + `last_heartbeat` + `FOR UPDATE SKIP LOCKED` dequeue | Same pattern. Runner owns a session via lock; heartbeats every N seconds while inside an SDK turn. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:88`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | +| `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | +| `parent_run_id` for batch grouping | Use for "trigger fanout" — one cron firing creates N sessions sharing a parent run id. | | +| `queue_name` + `priority` | Per-app or per-tier queue isolation. v1 = single queue; schema is open for v2 fairness work. | | +| `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | + +What we add on top: + +- **Heartbeat-from-inside-the-SDK.** SDK tool callbacks and Anthropic streaming chunks tick the queue heartbeat. +- **Session event bus.** The queue stores final state, not intermediate frames. SSE streaming lives in a Redis pub-sub keyed by `session_id`. Queue row + final-state blob is the durable record; the bus is best-effort. +- **`AgentSession` mirror in main posthog Postgres.** Queue rows live in the agent-runtime queue DB; the team-scoped mirror row in main posthog Postgres gives us FKs to `Team` / `AgentApplication` / `Revision`, activity log integration, and clean UI queries. + +### Queue database + +A separate Postgres DB owned by the agent-runtime — `agent_runtime_queue` (name TBD). Schema lives in `packages/agent-core/migrations/`, applied by a small bin script in the same package (mirrors how Rust migrations are managed for `cyclotron_node`, but in TypeScript since we have no Rust here). Not the main posthog Postgres. Not shared with `cyclotron_node`. + +--- + +## Part A — `products/agents/` Django app + +Mirror the [`products/deployments/`](../../products/deployments) scaffold from #58421: + +``` +products/agents/ + __init__.py + product.yaml + manifest.tsx + package.json + backend/ + __init__.py + apps.py + access.py + models.py + api/ + services/ + migrations/ + management/ + test/ + frontend/ + mcp/ # later +``` + +Bootstrap with `bin/hogli product:bootstrap agents` per the [Products README](../../products/README.md), then customize. + +### Models + +All inherit `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)) — uuid7 PKs. All tenant-data models have `team_id` (FK to `posthog.Team`) per the CLAUDE.md rule; consider `ProductTeamModel` if the product ends up isolated. + +**`AgentApplication`** (team-scoped) + +- `team: FK(Team)`, `name`, `slug` (unique — see open Q1), `description` +- `live_revision: FK(Revision, null=True)` — pointer-swap on promotion +- Soft delete (`deleted: bool`) +- Activity-logged via `log_activity_from_viewset` ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) + +**`Revision`** (immutable per deploy) + +- `application: FK(AgentApplication)` +- `state: enum(pending_upload | uploaded | validating | ready | failed)` — **full state machine in the schema from day one**, even though v1 skips straight from `uploaded` to `ready` (see §C). +- `bundle_s3_key`, `bundle_size`, `bundle_sha256` — content-hash binding for the presigned PUT. +- `top_level_config: JSONField` — validated synchronously at deploy start by Django. +- `parsed_manifest: JSONField(null=True)` — populated by the future validator package. v1 leaves this null and runner falls back to reading the bundle's `.ass.yaml` manifest section directly via `top_level_config`. +- `validation_report: JSONField(null=True)` — structured errors when the future validator marks `failed`. +- `created_by: FK(User)`, `created_at` +- Index: `(application_id, state, created_at desc)` for "list ready revisions". + +**`PreviewBinding`** + +- `application: FK(AgentApplication)`, `revision: FK(Revision)`, `subdomain_suffix: str` + +**`AgentApplicationSecret`** + +- `application: FK(AgentApplication)`, `name: str` (unique per app), `encrypted_value: EncryptedJSONStringField` +- `EncryptedJSONStringField` ([`posthog/helpers/encrypted_fields.py:137`](../../posthog/helpers/encrypted_fields.py)) — same pattern as `Integration.sensitive_config`. +- Plaintext never returned by REST API after creation. Decryption only via internal API, audit-logged per call. + +**`AgentSession`** (mirror of queue job in main DB) + +- `team: FK(Team)`, `application: FK(AgentApplication)`, `revision: FK(Revision)` +- `queue_job_id: UUID` — points at the actual job in the agent-runtime queue DB +- `state: enum` — mirrors the queue's `JobState`. Updated by the runner on transition. +- `trigger_type: str`, `trigger_payload: JSONField` +- `input: JSONField`, `output: JSONField(null=True)`, `error: JSONField(null=True)` +- `parent_run_id: UUID(null=True)` — same id as the queue's `parent_run_id` for trigger fanouts +- `started_at`, `last_heartbeat_at`, `completed_at` +- `runtime_instance: str(null=True)` — for attribution + +**`SandboxInstance`** + +- `application: FK(AgentApplication)`, `revision: FK(Revision)` +- `modal_sandbox_id: str`, `state: enum(provisioning | ready | terminating | terminated)` +- `created_at`, `last_used_at`, `terminated_at` +- v1 = at most one per `(application, revision)`. No unique constraint at the DB level; enforced by runtime. + +### Migrations + +Standard Django migrations under `products/agents/backend/migrations/`. Follow the [`django-migrations`](../../.claude/skills/django-migrations) skill — invoke it before writing the migration files. + +### API (DRF + OAuth) + +Invoke [`improving-drf-endpoints`](../../.claude/skills/improving-drf-endpoints) before writing viewsets/serializers — it covers `@validated_request`, `@extend_schema`, and the schema/typing pipeline that feeds frontend + MCP. + +New scope objects: `agent_application`, `agent_secret`. Add to [`posthog/scopes.py:16`](../../posthog/scopes.py). + +Viewsets follow `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_function.py:469`](../../posthog/api/hog_function.py)). + +Endpoints (project-scoped `/api/projects/{team_id}/...`): + +- `agent_applications/` — CRUD + soft delete + - `POST /:id/start_deploy` → `{ revision_id, presigned_put_url, expires_at, max_size, required_sha256 }` + - `POST /:id/complete_upload` → **v1: synchronously transition the revision to `ready`** (skipping `validating`). Logged so we know which revisions never went through real validation when the validator lands. + - `POST /:id/promote` → swap `live_revision` to a `ready` revision +- `revisions/` — list + retrieve (read-only) +- `preview_bindings/` — CRUD +- `agent_application_secrets/` — create/list/delete (no plaintext read) +- `agent_sessions/` — list + retrieve. Filters: `application_id`, `state`, `parent_run_id`, time range. + +**Internal-only endpoints** (called by `agent-ingress` and `agent-runner`): + +- `GET /internal/agents/applications/resolve` — given a domain or app id, returns the live revision + manifest. Cacheable ~5s. +- `POST /internal/agents/secrets/{app_id}/decrypt` — returns plaintext for a named set of secrets. Audit-logged. Separate internal scope, not exposed in OAuth UI. + +Add to `INTERNAL_API_SCOPE_OBJECTS` ([`posthog/scopes.py:121`](../../posthog/scopes.py)) so they don't appear in PAT creation flows. + +### Frontend + +Mirror [`products/deployments/manifest.tsx`](../../products/deployments/manifest.tsx). Gated by `FEATURE_FLAGS.AGENTS`, `tags: ['alpha']`. + +v1 scenes: + +- `AgentApplications` (list) +- `AgentApplication` (detail: revisions, secrets, sessions, sandbox state tabs) +- `AgentSession` (single-session inspection) + +Use the [`scene-menu-bar`](../../.claude/skills/scene-menu-bar) and [`making-scenes-tab-aware`](../../.claude/skills/making-scenes-tab-aware) conventions for tabs. + +Routes: + +- `/agents` → list +- `/agents/:slug` → detail (default: revisions) +- `/agents/:slug/sessions/:session_id` → session detail + +CLI is the primary deploy surface in v1; this UI is management + observability. + +--- + +## Part B — Deploy flow (v1, no async validator) + +1. CLI bundles the project locally. +2. CLI calls Django `start_deploy` with the parsed top-level config. Django validates synchronously (schema-level checks on `.ass.yaml` and triggers) and creates a `Revision` row in `pending_upload`. +3. Django returns a presigned S3 PUT URL bound to size + content hash. +4. CLI uploads the bundle to S3. +5. CLI calls `complete_upload`. +6. **v1 shortcut**: Django transitions the revision `uploaded → ready` immediately, with no manifest parsing. The bundle is trusted as-is. +7. CLI (or web UI) `promote`s the revision to live. +8. Runtime resolves traffic for the app to the live revision (cache invalidation keyed by revision id). + +The full state machine (`pending_upload → uploaded → validating → ready | failed`) is present in the schema and the `complete_upload` endpoint; the `validating → ready` transition is just immediate in v1. When the validator package lands, `complete_upload` stops auto-promoting and instead enqueues a validation job in a separate queue. + +--- + +## Part C — Async bundle validator (deferred, not v1) + +When we ship it, the validator will be **a fourth node package**, not a Celery task. Lives at `packages/agent-validator/`. Same shape as `agent-runner`: + +- Polls its own work queue (`available` revisions whose state is `uploaded` / `validating`). +- Picks one up, marks `validating`, streams the bundle from S3, unpacks with size/file-count caps, walks manifests, resolves referenced ids against the shared built-ins registry in `agent-core`, runs static checks (secrets exist, allow-listed actions exist on referenced tools, triggers valid), transitions to `ready` (+ `parsed_manifest`) or `failed` (+ structured `validation_report`). +- Same heartbeat/lock/janitor pattern from `agent-core`'s queue primitives — the validator is just another consumer of the same primitive, against a different table. +- Built-ins registry shared via `agent-core` means the validator and the runner have identical opinions on which tool ids exist. + +v1 ships without it. Models support the state transitions today; the runner reads `top_level_config` directly until `parsed_manifest` is being populated. When the validator lands: + +- `complete_upload` stops auto-promoting. +- Existing revisions stay `ready` (they were trusted). +- Validator starts running for new revisions. +- Runner switches to preferring `parsed_manifest` when present. + +Pure-function validators (`(bytes) -> (parsed, errors)`) inside the validator package will also be importable by the CLI for `ass build` local checks. + +--- + +## Part D — Security & infra + +- **DBs**: + - Agent-runtime queue gets its own Postgres DB (`agent_runtime_queue`). Not shared with `cyclotron_node`. Owned by `agent-core` migrations. + - `AgentSession` and `SandboxInstance` mirrors live in main posthog Postgres (team-scoped, FKs, activity log eligible). + - Runner writes to both — queue row is the work item, `AgentSession` is the user-visible record. +- **S3 bucket**: new `posthog-agent-bundles-{env}`, KMS-encrypted, lifecycle expires non-`ready` bundles after 7 days. Use [`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py) helpers from Django. +- **Secrets**: `EncryptedJSONStringField` (same key schedule as `Integration`). Decrypt only in `agent-runner` via the internal API. +- **Per-team quotas**: enforced on Django writes (apps, secrets, revisions/day) and at `agent-ingress` (concurrent sessions per app, `/run` rate limit). Surface limits in the UI. +- **Observability**: structured logs with `app_id` / `revision_id` / `session_id` / `queue_job_id`; OTel traces per session and per tool call; Prometheus metrics; Sentry tagged separately for `agent-ingress` and `agent-runner`. +- **Feature flag**: `FEATURE_FLAGS.AGENTS` gates the product (frontend + API + ingress). Per-team rollout. + +--- + +## Open questions + +Resolutions to the agent-stack open questions + posthog-specific ones: + +1. **Slug uniqueness** — global. Subdomain-driven; per-team would need a tenant prefix we don't want. +2. **API ↔ worker transport** — the agent-core queue (cyclotron-v2-shaped, in its own DB). Closed. +3. **Built-ins registry visibility** — shared from `agent-core` so the future validator and the runner agree from day one. +4. **Cluster placement** — `agent-ingress` and `agent-runner` are their own k8s deployments with their own HPAs. Sized independently. +5. **Queue DB sharing** — `agent_runtime_queue` is its own DB. No sharing with `cyclotron_node` or main posthog. +6. **Queue state size cap** — Claude Agent SDK conversation state can grow large. Need a soft cap (e.g. 1 MiB) and a fallback that offloads the conversation log to S3, keeping only a pointer in the job state. Validate against a real workload before promising a number. +7. **Internal-API auth between runtime and Django** — mTLS via existing service mesh, or a shared signing key checked in middleware? Pick at impl time. +8. **Activity log for sessions** — log only management-plane changes (apps, revisions, secrets). Sessions are too high-volume; surface them in the sessions UI instead. +9. **Code duplication strategy** — when copying from `nodejs/`, do we vendor whole files with attribution, or rewrite from scratch with the original as reference? Recommend: rewrite small primitives, vendor + adapt larger ones (the queue ops are the only obvious "vendor" candidate). + +--- + +## Milestones (posthog-side) + +Each shippable behind `FEATURE_FLAGS.AGENTS`. + +1. **Scaffold + models.** `products/agents/` skeleton (mirror `products/deployments/`), Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. *(unblocks parallel work)* +2. **Management API.** CRUD viewsets for apps/revisions/secrets/preview-bindings. Activity logging wired. `complete_upload` shortcut transitions straight to `ready`. +3. **Deploy flow.** `start_deploy` → presigned PUT → `complete_upload` (auto-ready) → `promote`. End-to-end via CLI. No async work. +4. **Internal API.** `resolve` + `decrypt` endpoints with internal scopes. mTLS / signed-key auth. +5. **`packages/agent-core/`.** Types, DB clients, queue primitives (schema + ops), pub-sub helper, internal-API client, logger/metrics. No process; tested in isolation. +6. **`packages/agent-ingress/`.** Domain resolution, `/run` writes `AgentSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. +7. **`packages/agent-runner/` — meta + built-in tools.** Queue consumer. Real Claude Agent SDK invocation. State serialized into queue `state`, reschedule loop on tool boundaries. Built-ins registry shared with `agent-core`. +8. **Sandboxes.** Modal integration, custom-tool execution, sandbox lifecycle + reaper. `SandboxInstance` writes from the runner. +9. **Triggers.** Webhooks, cron, slack event ingestion. +10. **Frontend.** App list, app detail (revisions/secrets/sessions/sandbox tabs), session detail. +11. **Preview deploys, observability polish, quotas.** +12. **`packages/agent-validator/`.** Async bundle validator. Pure-function checks reusable from the CLI. Flip `complete_upload` to enqueue validation instead of auto-ready. +13. **Skills + registry v2** (publish flow, third-party tool publishing). Reuses the same Revision-style immutable artifacts. + +--- + +## Cross-references + +- agent-stack plan: [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/agent-stack/blob/main/docs/agent-platform.md) +- Reference scaffold: [`products/deployments/`](../../products/deployments) (#58421) +- cyclotron-v2 (reference only, not a dependency): [`rust/cyclotron-core/src/`](../../rust/cyclotron-core/src/), [`nodejs/src/cdp/services/cyclotron-v2/`](../../nodejs/src/cdp/services/cyclotron-v2/) +- Patterns to mirror in Django: `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)), `EncryptedJSONStringField` ([`posthog/helpers/encrypted_fields.py:137`](../../posthog/helpers/encrypted_fields.py)), `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_function.py:469`](../../posthog/api/hog_function.py)), `object_storage` presigned helpers ([`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py)), activity logging ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) From 82feab4edbf12a09eeb6819766fb3fd790b39519 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 13 May 2026 20:30:01 -0400 Subject: [PATCH 002/517] init agent stack product --- frontend/package.json | 1 + posthog/settings/web.py | 1 + products/agent_stack/__init__.py | 0 products/agent_stack/backend/__init__.py | 0 products/agent_stack/backend/apps.py | 7 ++++ .../agent_stack/backend/facade/__init__.py | 0 products/agent_stack/backend/facade/api.py | 32 +++++++++++++++++ .../agent_stack/backend/facade/contracts.py | 26 ++++++++++++++ products/agent_stack/backend/facade/enums.py | 33 +++++++++++++++++ .../agent_stack/backend/logic/__init__.py | 14 ++++++++ products/agent_stack/backend/models.py | 30 ++++++++++++++++ .../backend/presentation/__init__.py | 0 .../backend/presentation/serializers.py | 15 ++++++++ .../agent_stack/backend/presentation/urls.py | 9 +++++ .../agent_stack/backend/presentation/views.py | 35 +++++++++++++++++++ .../agent_stack/backend/tasks/__init__.py | 0 .../agent_stack/backend/tasks/schedules.py | 2 ++ products/agent_stack/backend/tasks/tasks.py | 8 +++++ .../agent_stack/backend/tests/__init__.py | 0 .../agent_stack/backend/tests/conftest.py | 13 +++++++ .../agent_stack/backend/tests/test_api.py | 21 +++++++++++ .../agent_stack/backend/tests/test_logic.py | 0 .../agent_stack/backend/tests/test_models.py | 0 .../backend/tests/test_presentation.py | 0 .../agent_stack/backend/tests/test_tasks.py | 0 products/agent_stack/manifest.tsx | 23 ++++++++++++ products/agent_stack/package.json | 7 ++++ products/agent_stack/product.yaml | 3 ++ products/agent_stack/tsconfig.json | 6 ++++ products/agent_stack/turbo.json | 10 ++++++ products/db_routing.yaml | 2 ++ tach.toml | 14 ++++++++ 32 files changed, 312 insertions(+) create mode 100644 products/agent_stack/__init__.py create mode 100644 products/agent_stack/backend/__init__.py create mode 100644 products/agent_stack/backend/apps.py create mode 100644 products/agent_stack/backend/facade/__init__.py create mode 100644 products/agent_stack/backend/facade/api.py create mode 100644 products/agent_stack/backend/facade/contracts.py create mode 100644 products/agent_stack/backend/facade/enums.py create mode 100644 products/agent_stack/backend/logic/__init__.py create mode 100644 products/agent_stack/backend/models.py create mode 100644 products/agent_stack/backend/presentation/__init__.py create mode 100644 products/agent_stack/backend/presentation/serializers.py create mode 100644 products/agent_stack/backend/presentation/urls.py create mode 100644 products/agent_stack/backend/presentation/views.py create mode 100644 products/agent_stack/backend/tasks/__init__.py create mode 100644 products/agent_stack/backend/tasks/schedules.py create mode 100644 products/agent_stack/backend/tasks/tasks.py create mode 100644 products/agent_stack/backend/tests/__init__.py create mode 100644 products/agent_stack/backend/tests/conftest.py create mode 100644 products/agent_stack/backend/tests/test_api.py create mode 100644 products/agent_stack/backend/tests/test_logic.py create mode 100644 products/agent_stack/backend/tests/test_models.py create mode 100644 products/agent_stack/backend/tests/test_presentation.py create mode 100644 products/agent_stack/backend/tests/test_tasks.py create mode 100644 products/agent_stack/manifest.tsx create mode 100644 products/agent_stack/package.json create mode 100644 products/agent_stack/product.yaml create mode 100644 products/agent_stack/tsconfig.json create mode 100644 products/agent_stack/turbo.json diff --git a/frontend/package.json b/frontend/package.json index ca51d5ec4ced..a9c69b1dc85a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -71,6 +71,7 @@ "@posthog/icons": "^0.36.6", "@posthog/products-access-control": "workspace:*", "@posthog/products-actions": "workspace:*", + "@posthog/products-agent-stack": "workspace:*", "@posthog/products-business-knowledge": "workspace:*", "@posthog/products-cohorts": "workspace:*", "@posthog/products-conversations": "workspace:*", diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 3c0f1e8862ee..13b3b3547765 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -74,6 +74,7 @@ "products.access_control.backend.apps.AccessControlConfig", "products.warehouse_sources_queue.backend.apps.WarehouseSourcesQueueConfig", "products.business_knowledge.backend.apps.BusinessKnowledgeConfig", + "products.agent_stack.backend.apps.AgentStackConfig", ] INSTALLED_APPS = [ diff --git a/products/agent_stack/__init__.py b/products/agent_stack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/__init__.py b/products/agent_stack/backend/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/apps.py b/products/agent_stack/backend/apps.py new file mode 100644 index 000000000000..6dd953e3d9d8 --- /dev/null +++ b/products/agent_stack/backend/apps.py @@ -0,0 +1,7 @@ +"""Django app configuration for agent_stack.""" +from django.apps import AppConfig + + +class AgentStackConfig(AppConfig): + name = "products.agent_stack.backend" + label = "agent_stack" diff --git a/products/agent_stack/backend/facade/__init__.py b/products/agent_stack/backend/facade/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/facade/api.py b/products/agent_stack/backend/facade/api.py new file mode 100644 index 000000000000..b94797a37c3e --- /dev/null +++ b/products/agent_stack/backend/facade/api.py @@ -0,0 +1,32 @@ +""" +Facade for agent_stack. + +The ONLY module other products are allowed to import. +Accept frozen dataclasses, call logic/, return frozen +dataclasses. Never return ORM instances or import DRF. +""" + +from __future__ import annotations + +from .. import logic +from ..models import SplineReticulator +from . import contracts +from .enums import SplineStatus + + +def _to_dto(obj: SplineReticulator) -> contracts.SplineReticulatorDTO: + return contracts.SplineReticulatorDTO( + id=obj.id, + name=obj.name, + status=SplineStatus(obj.status), + created_at=obj.created_at, + ) + + +def create(input: contracts.CreateSplineReticulatorInput) -> contracts.SplineReticulatorDTO: + obj = logic.create_spline_reticulator(team_id=input.team_id, name=input.name) + return _to_dto(obj) + + +def list_all() -> list[contracts.SplineReticulatorDTO]: + return [_to_dto(obj) for obj in logic.list_spline_reticulators()] diff --git a/products/agent_stack/backend/facade/contracts.py b/products/agent_stack/backend/facade/contracts.py new file mode 100644 index 000000000000..c815a4ba6041 --- /dev/null +++ b/products/agent_stack/backend/facade/contracts.py @@ -0,0 +1,26 @@ +""" +Contract types for agent_stack. + +Frozen dataclasses that define what this product exposes. +No Django imports. Used by facade as inputs/outputs. +""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from .enums import SplineStatus + + +@dataclass(frozen=True) +class SplineReticulatorDTO: + id: UUID + name: str + status: SplineStatus + created_at: datetime + + +@dataclass(frozen=True) +class CreateSplineReticulatorInput: + team_id: int + name: str diff --git a/products/agent_stack/backend/facade/enums.py b/products/agent_stack/backend/facade/enums.py new file mode 100644 index 000000000000..39015939729d --- /dev/null +++ b/products/agent_stack/backend/facade/enums.py @@ -0,0 +1,33 @@ +""" +Exported enums for agent_stack. + +If an enum appears in a contract dataclass field, it belongs here. +Internal-only constants (DB magic values, feature flags) stay in +the implementation (logic.py, models.py). +""" + +from enum import StrEnum + + +class RevisionState(StrEnum): + PENDING_UPLOAD = "pending_upload" + UPLOADED = "uploaded" + VALIDATING = "validating" + READY = "ready" + FAILED = "failed" + + +class SessionState(StrEnum): + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + + +class SandboxState(StrEnum): + PROVISIONING = "provisioning" + READY = "ready" + DESTROYING = "destroying" + DESTROYED = "destroyed" + FAILED = "failed" diff --git a/products/agent_stack/backend/logic/__init__.py b/products/agent_stack/backend/logic/__init__.py new file mode 100644 index 000000000000..f458c3799292 --- /dev/null +++ b/products/agent_stack/backend/logic/__init__.py @@ -0,0 +1,14 @@ +"""Business logic for agent_stack.""" + +from __future__ import annotations + +from ..facade.enums import SplineStatus +from ..models import SplineReticulator + + +def create_spline_reticulator(*, team_id: int, name: str) -> SplineReticulator: + return SplineReticulator.objects.create(team_id=team_id, name=name, status=SplineStatus.PENDING) + + +def list_spline_reticulators() -> list[SplineReticulator]: + return list(SplineReticulator.objects.all()) diff --git a/products/agent_stack/backend/models.py b/products/agent_stack/backend/models.py new file mode 100644 index 000000000000..0f6ba6f462a5 --- /dev/null +++ b/products/agent_stack/backend/models.py @@ -0,0 +1,30 @@ +""" +Django models for agent_stack. + +Keep models thin — business logic belongs in logic/. +Use types from facade/enums.py where applicable. +Avoid ForeignKeys to models outside this app; if needed, +disallow reverse relations with related_name='+'. +""" + +import uuid + +from django.db import models + +from posthog.models.scoping.product_mixin import ProductTeamModel + +from .facade.enums import SplineStatus + + +class SplineReticulator(ProductTeamModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + status = models.CharField( + max_length=32, + choices=[(s.value, s.value) for s in SplineStatus], + default=SplineStatus.PENDING, + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.name diff --git a/products/agent_stack/backend/presentation/__init__.py b/products/agent_stack/backend/presentation/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/presentation/serializers.py b/products/agent_stack/backend/presentation/serializers.py new file mode 100644 index 000000000000..f2c800f672fd --- /dev/null +++ b/products/agent_stack/backend/presentation/serializers.py @@ -0,0 +1,15 @@ +"""DRF serializers for agent_stack.""" + +from rest_framework import serializers +from rest_framework_dataclasses.serializers import DataclassSerializer + +from ..facade.contracts import SplineReticulatorDTO + + +class SplineReticulatorSerializer(DataclassSerializer): + class Meta: + dataclass = SplineReticulatorDTO + + +class CreateSplineReticulatorSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, help_text="Name of the spline to reticulate.") diff --git a/products/agent_stack/backend/presentation/urls.py b/products/agent_stack/backend/presentation/urls.py new file mode 100644 index 000000000000..e2773e2608fa --- /dev/null +++ b/products/agent_stack/backend/presentation/urls.py @@ -0,0 +1,9 @@ +"""URL routes for agent_stack.""" + +from rest_framework.routers import DefaultRouter + +from .views import SplineReticulatorViewSet + +router = DefaultRouter() +router.register(r"spline_reticulators", SplineReticulatorViewSet, basename="spline_reticulators") +urlpatterns = router.urls diff --git a/products/agent_stack/backend/presentation/views.py b/products/agent_stack/backend/presentation/views.py new file mode 100644 index 000000000000..958c6e563ed3 --- /dev/null +++ b/products/agent_stack/backend/presentation/views.py @@ -0,0 +1,35 @@ +""" +DRF views for agent_stack. + +Validate JSON via serializers, call facade methods, +return serialized responses. No business logic here. +""" + +from drf_spectacular.utils import extend_schema +from rest_framework import status, viewsets +from rest_framework.request import Request +from rest_framework.response import Response + +from posthog.api.routing import TeamAndOrgViewSetMixin + +from ..facade import api, contracts +from .serializers import CreateSplineReticulatorSerializer, SplineReticulatorSerializer + + +class SplineReticulatorViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): + scope_object = "INTERNAL" + + @extend_schema(responses={200: SplineReticulatorSerializer(many=True)}) + def list(self, request: Request, **kwargs) -> Response: + items = api.list_all() + return Response(SplineReticulatorSerializer(items, many=True).data) + + @extend_schema(request=CreateSplineReticulatorSerializer, responses={201: SplineReticulatorSerializer}) + def create(self, request: Request, **kwargs) -> Response: + serializer = CreateSplineReticulatorSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + dto = api.create(contracts.CreateSplineReticulatorInput( + team_id=self.team_id, + **serializer.validated_data, + )) + return Response(SplineReticulatorSerializer(dto).data, status=status.HTTP_201_CREATED) diff --git a/products/agent_stack/backend/tasks/__init__.py b/products/agent_stack/backend/tasks/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/tasks/schedules.py b/products/agent_stack/backend/tasks/schedules.py new file mode 100644 index 000000000000..605b5446828b --- /dev/null +++ b/products/agent_stack/backend/tasks/schedules.py @@ -0,0 +1,2 @@ +"""Celery beat schedules for agent_stack.""" +# Define periodic task schedules here diff --git a/products/agent_stack/backend/tasks/tasks.py b/products/agent_stack/backend/tasks/tasks.py new file mode 100644 index 000000000000..5043774cc7e5 --- /dev/null +++ b/products/agent_stack/backend/tasks/tasks.py @@ -0,0 +1,8 @@ +""" +Celery tasks for agent_stack. + +Async entrypoints that call the facade (facade/api.py). +Keep task functions thin - only call facade methods. +""" +# from celery import shared_task +# from ..facade import api diff --git a/products/agent_stack/backend/tests/__init__.py b/products/agent_stack/backend/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/tests/conftest.py b/products/agent_stack/backend/tests/conftest.py new file mode 100644 index 000000000000..ae3f00200968 --- /dev/null +++ b/products/agent_stack/backend/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from posthog.models.scoping import team_scope + + +@pytest.fixture(autouse=True) +def _set_team_scope(request): + if request.node.get_closest_marker("django_db") is None: + yield + return + team = request.getfixturevalue("team") + with team_scope(team.id): + yield diff --git a/products/agent_stack/backend/tests/test_api.py b/products/agent_stack/backend/tests/test_api.py new file mode 100644 index 000000000000..0ab2a7213791 --- /dev/null +++ b/products/agent_stack/backend/tests/test_api.py @@ -0,0 +1,21 @@ +from uuid import UUID + +import pytest + +from products.agent_stack.backend.facade import api +from products.agent_stack.backend.facade.contracts import CreateSplineReticulatorInput +from products.agent_stack.backend.facade.enums import SplineStatus + + +@pytest.mark.django_db +class TestSplineReticulatorAPI: + def test_create_and_list(self, team): + dto = api.create(CreateSplineReticulatorInput(team_id=team.id, name="test-spline")) + + assert isinstance(dto.id, UUID) + assert dto.name == "test-spline" + assert dto.status == SplineStatus.PENDING + + all_items = api.list_all() + assert len(all_items) == 1 + assert all_items[0].id == dto.id diff --git a/products/agent_stack/backend/tests/test_logic.py b/products/agent_stack/backend/tests/test_logic.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/tests/test_models.py b/products/agent_stack/backend/tests/test_models.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/tests/test_presentation.py b/products/agent_stack/backend/tests/test_presentation.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/tests/test_tasks.py b/products/agent_stack/backend/tests/test_tasks.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/manifest.tsx b/products/agent_stack/manifest.tsx new file mode 100644 index 000000000000..40a5022db8f6 --- /dev/null +++ b/products/agent_stack/manifest.tsx @@ -0,0 +1,23 @@ +/** + * Product manifest for agent_stack. + * + * Defines scenes, routes, URLs, and navigation for this product. + */ +import { ProductManifest } from '../../frontend/src/types' + +export const manifest: ProductManifest = { + name: 'AgentStack', + scenes: { + // Define scenes here + }, + routes: { + // Define routes here + }, + redirects: {}, + urls: { + // Define URL helpers here + }, + fileSystemTypes: {}, + treeItemsNew: [], + treeItemsProducts: [], +} diff --git a/products/agent_stack/package.json b/products/agent_stack/package.json new file mode 100644 index 000000000000..a3edbf0a0b50 --- /dev/null +++ b/products/agent_stack/package.json @@ -0,0 +1,7 @@ +{ + "name": "@posthog/products-agent_stack", + "scripts": { + "backend:test": "pytest -c ../../pytest.ini --rootdir ../.. backend/tests -v --tb=short", + "backend:contract-check": "echo 'Contract files unchanged'" + } +} diff --git a/products/agent_stack/product.yaml b/products/agent_stack/product.yaml new file mode 100644 index 000000000000..45be6fc5a288 --- /dev/null +++ b/products/agent_stack/product.yaml @@ -0,0 +1,3 @@ +name: Agent stack +owners: + - team-devex diff --git a/products/agent_stack/tsconfig.json b/products/agent_stack/tsconfig.json new file mode 100644 index 000000000000..fe425d6fcba8 --- /dev/null +++ b/products/agent_stack/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true + } +} diff --git a/products/agent_stack/turbo.json b/products/agent_stack/turbo.json new file mode 100644 index 000000000000..34a16b3d30ce --- /dev/null +++ b/products/agent_stack/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "backend:contract-check": { + "inputs": ["backend/facade/**", "backend/presentation/**"], + "outputs": [], + "cache": true + } + } +} diff --git a/products/db_routing.yaml b/products/db_routing.yaml index baac59dbb2c3..64819c76353c 100644 --- a/products/db_routing.yaml +++ b/products/db_routing.yaml @@ -3,3 +3,5 @@ routes: database: visual_review - app_label: warehouse_sources_queue database: warehouse_sources_queue + - app_label: agent_stack + database: agent_stack diff --git a/tach.toml b/tach.toml index 6c1a9c52ac91..8fccf58b8f9f 100644 --- a/tach.toml +++ b/tach.toml @@ -589,3 +589,17 @@ expose = [ from = [ "products.business_knowledge", ] + +[[modules]] +path = "products.agent_stack" +depends_on = ["posthog"] +layer = "modules" + +[[interfaces]] +expose = [ + "backend\\.facade.*", + "backend\\.presentation\\.views.*", +] +from = [ + "products.agent_stack", +] From 1050d1f1d450cdfe10b75248d54b8f1faa34fa04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:04:03 +0000 Subject: [PATCH 003/517] feat(agents): scaffold agent runtime packages (core + ingress + runner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new packages under packages/, each its own process, with no imports from nodejs/ — cyclotron-v2 concepts reimplemented inside agent-core rather than vendored. @posthog/agent-core - agent_sessions queue (manager + worker + janitor) with own Postgres DB and migration runner. Reimplemented from the cyclotron-v2 reference; dropped parent_run_id and priority (the doc carried them from cyclotron-v2, but v1 has no triggers and one queue — additive migrations when those land). - pino logger, Prom registry, in-memory + Redis session bus, internal-API client (resolve + decrypt) typed via zod. - Built-in tool registry (single source of truth, shared with future validator). Manifest parser hooked to the same registry. @posthog/agent-ingress - ultimate-express server with /run, /listen (SSE), /send, /webhooks, /health, /status, /metrics. - Domain resolver with TTL LRU on top of the internal-API client. - Per-app auth (public / shared_secret / webhook_signature) driven entirely by the resolved revision payload, so ingress never holds secrets itself. - eslint restricted-imports config blocks Anthropic / Modal / nodejs/ imports. @posthog/agent-runner - Queue consumer that runs one SDK turn per dequeue and reschedules at every tool boundary, with a turn-level heartbeat keeping the lock alive. - Native-only tool execution in v1: meta tools (complete, wait_for_input) + built-ins from agent-core. No Modal sandboxing. - SessionExecutor interface as the seam for the real Claude Agent SDK; a NotImplementedExecutor lets the process boot end-to-end before wiring. Tests: 12 agent-core + 11 agent-ingress + 13 agent-runner pass; queue integration suite is DB-gated and skips without AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL. https://claude.ai/code/session_01Bkx1f6m35QnFZ2RbxTsrZt --- docs/internal/agent-platform.md | 9 +- packages/agent-core/.gitignore | 3 + packages/agent-core/README.md | 29 + packages/agent-core/bin/migrate.ts | 64 ++ packages/agent-core/jest.config.js | 10 + .../migrations/0001_initial_schema.sql | 54 ++ packages/agent-core/package.json | 44 ++ packages/agent-core/src/builtins/index.ts | 66 ++ packages/agent-core/src/index.ts | 8 + .../agent-core/src/internal-api/client.ts | 78 +++ packages/agent-core/src/internal-api/index.ts | 4 + packages/agent-core/src/internal-api/types.ts | 27 + packages/agent-core/src/logger.ts | 14 + packages/agent-core/src/manifest/index.ts | 77 +++ packages/agent-core/src/metrics.ts | 28 + packages/agent-core/src/pubsub/in-memory.ts | 53 ++ packages/agent-core/src/pubsub/index.ts | 4 + packages/agent-core/src/pubsub/redis.ts | 93 +++ packages/agent-core/src/pubsub/types.ts | 40 ++ packages/agent-core/src/queue/index.ts | 18 + packages/agent-core/src/queue/janitor.ts | 191 ++++++ packages/agent-core/src/queue/manager.ts | 112 ++++ packages/agent-core/src/queue/types.ts | 82 +++ packages/agent-core/src/queue/worker.ts | 225 +++++++ packages/agent-core/tests/builtins.test.ts | 25 + packages/agent-core/tests/manifest.test.ts | 55 ++ packages/agent-core/tests/pubsub.test.ts | 69 ++ packages/agent-core/tests/queue.test.ts | 142 ++++ packages/agent-core/tsconfig.json | 26 + packages/agent-core/tsconfig.test.json | 9 + packages/agent-ingress/.eslintrc.json | 20 + packages/agent-ingress/.gitignore | 3 + packages/agent-ingress/README.md | 17 + packages/agent-ingress/jest.config.js | 10 + packages/agent-ingress/package.json | 37 ++ packages/agent-ingress/src/auth.ts | 67 ++ packages/agent-ingress/src/config.ts | 30 + packages/agent-ingress/src/index.ts | 56 ++ packages/agent-ingress/src/resolver.ts | 64 ++ packages/agent-ingress/src/routes/health.ts | 7 + packages/agent-ingress/src/routes/host.ts | 18 + packages/agent-ingress/src/routes/listen.ts | 50 ++ packages/agent-ingress/src/routes/run.ts | 67 ++ packages/agent-ingress/src/routes/send.ts | 38 ++ packages/agent-ingress/src/routes/status.ts | 11 + packages/agent-ingress/src/routes/webhooks.ts | 56 ++ packages/agent-ingress/src/server.ts | 47 ++ packages/agent-ingress/src/types.ts | 10 + packages/agent-ingress/tests/server.test.ts | 195 ++++++ packages/agent-ingress/tsconfig.json | 26 + packages/agent-ingress/tsconfig.test.json | 9 + packages/agent-runner/.gitignore | 3 + packages/agent-runner/README.md | 25 + packages/agent-runner/jest.config.js | 10 + packages/agent-runner/package.json | 35 + packages/agent-runner/src/config.ts | 25 + packages/agent-runner/src/executor-stub.ts | 15 + packages/agent-runner/src/executor.ts | 48 ++ packages/agent-runner/src/index.ts | 63 ++ packages/agent-runner/src/state.ts | 50 ++ packages/agent-runner/src/tools/builtins.ts | 85 +++ packages/agent-runner/src/tools/meta.ts | 45 ++ packages/agent-runner/src/tools/registry.ts | 24 + packages/agent-runner/src/tools/types.ts | 27 + packages/agent-runner/src/worker.ts | 188 ++++++ packages/agent-runner/tests/state.test.ts | 30 + packages/agent-runner/tests/tools.test.ts | 45 ++ packages/agent-runner/tests/worker.test.ts | 231 +++++++ packages/agent-runner/tsconfig.json | 26 + packages/agent-runner/tsconfig.test.json | 9 + pnpm-lock.yaml | 609 ++++++++---------- pnpm-workspace.yaml | 3 + 72 files changed, 3755 insertions(+), 338 deletions(-) create mode 100644 packages/agent-core/.gitignore create mode 100644 packages/agent-core/README.md create mode 100644 packages/agent-core/bin/migrate.ts create mode 100644 packages/agent-core/jest.config.js create mode 100644 packages/agent-core/migrations/0001_initial_schema.sql create mode 100644 packages/agent-core/package.json create mode 100644 packages/agent-core/src/builtins/index.ts create mode 100644 packages/agent-core/src/index.ts create mode 100644 packages/agent-core/src/internal-api/client.ts create mode 100644 packages/agent-core/src/internal-api/index.ts create mode 100644 packages/agent-core/src/internal-api/types.ts create mode 100644 packages/agent-core/src/logger.ts create mode 100644 packages/agent-core/src/manifest/index.ts create mode 100644 packages/agent-core/src/metrics.ts create mode 100644 packages/agent-core/src/pubsub/in-memory.ts create mode 100644 packages/agent-core/src/pubsub/index.ts create mode 100644 packages/agent-core/src/pubsub/redis.ts create mode 100644 packages/agent-core/src/pubsub/types.ts create mode 100644 packages/agent-core/src/queue/index.ts create mode 100644 packages/agent-core/src/queue/janitor.ts create mode 100644 packages/agent-core/src/queue/manager.ts create mode 100644 packages/agent-core/src/queue/types.ts create mode 100644 packages/agent-core/src/queue/worker.ts create mode 100644 packages/agent-core/tests/builtins.test.ts create mode 100644 packages/agent-core/tests/manifest.test.ts create mode 100644 packages/agent-core/tests/pubsub.test.ts create mode 100644 packages/agent-core/tests/queue.test.ts create mode 100644 packages/agent-core/tsconfig.json create mode 100644 packages/agent-core/tsconfig.test.json create mode 100644 packages/agent-ingress/.eslintrc.json create mode 100644 packages/agent-ingress/.gitignore create mode 100644 packages/agent-ingress/README.md create mode 100644 packages/agent-ingress/jest.config.js create mode 100644 packages/agent-ingress/package.json create mode 100644 packages/agent-ingress/src/auth.ts create mode 100644 packages/agent-ingress/src/config.ts create mode 100644 packages/agent-ingress/src/index.ts create mode 100644 packages/agent-ingress/src/resolver.ts create mode 100644 packages/agent-ingress/src/routes/health.ts create mode 100644 packages/agent-ingress/src/routes/host.ts create mode 100644 packages/agent-ingress/src/routes/listen.ts create mode 100644 packages/agent-ingress/src/routes/run.ts create mode 100644 packages/agent-ingress/src/routes/send.ts create mode 100644 packages/agent-ingress/src/routes/status.ts create mode 100644 packages/agent-ingress/src/routes/webhooks.ts create mode 100644 packages/agent-ingress/src/server.ts create mode 100644 packages/agent-ingress/src/types.ts create mode 100644 packages/agent-ingress/tests/server.test.ts create mode 100644 packages/agent-ingress/tsconfig.json create mode 100644 packages/agent-ingress/tsconfig.test.json create mode 100644 packages/agent-runner/.gitignore create mode 100644 packages/agent-runner/README.md create mode 100644 packages/agent-runner/jest.config.js create mode 100644 packages/agent-runner/package.json create mode 100644 packages/agent-runner/src/config.ts create mode 100644 packages/agent-runner/src/executor-stub.ts create mode 100644 packages/agent-runner/src/executor.ts create mode 100644 packages/agent-runner/src/index.ts create mode 100644 packages/agent-runner/src/state.ts create mode 100644 packages/agent-runner/src/tools/builtins.ts create mode 100644 packages/agent-runner/src/tools/meta.ts create mode 100644 packages/agent-runner/src/tools/registry.ts create mode 100644 packages/agent-runner/src/tools/types.ts create mode 100644 packages/agent-runner/src/worker.ts create mode 100644 packages/agent-runner/tests/state.test.ts create mode 100644 packages/agent-runner/tests/tools.test.ts create mode 100644 packages/agent-runner/tests/worker.test.ts create mode 100644 packages/agent-runner/tsconfig.json create mode 100644 packages/agent-runner/tsconfig.test.json diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index ccef5b1f846b..7ae19a7147af 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -106,10 +106,14 @@ cyclotron-v2 has solved exactly these problems in production for CDP. We **reimp | `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | | `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | | Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | -| `parent_run_id` for batch grouping | Use for "trigger fanout" — one cron firing creates N sessions sharing a parent run id. | | -| `queue_name` + `priority` | Per-app or per-tier queue isolation. v1 = single queue; schema is open for v2 fairness work. | | +| `queue_name` | Per-app or per-tier queue isolation. v1 = single queue. | | | `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | +Deliberately **not** carried over in v1: + +- `priority` — only useful with multiple queue tiers; v1 has one queue. Easy additive migration when v2 fairness work happens. +- `parent_run_id` — only useful for trigger fanout (one cron firing → N sessions); v1 has no triggers. Add when triggers ship. + What we add on top: - **Heartbeat-from-inside-the-SDK.** SDK tool callbacks and Anthropic streaming chunks tick the queue heartbeat. @@ -187,7 +191,6 @@ All inherit `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/ut - `state: enum` — mirrors the queue's `JobState`. Updated by the runner on transition. - `trigger_type: str`, `trigger_payload: JSONField` - `input: JSONField`, `output: JSONField(null=True)`, `error: JSONField(null=True)` -- `parent_run_id: UUID(null=True)` — same id as the queue's `parent_run_id` for trigger fanouts - `started_at`, `last_heartbeat_at`, `completed_at` - `runtime_instance: str(null=True)` — for attribution diff --git a/packages/agent-core/.gitignore b/packages/agent-core/.gitignore new file mode 100644 index 000000000000..83631f817f87 --- /dev/null +++ b/packages/agent-core/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md new file mode 100644 index 000000000000..ec829e814d8d --- /dev/null +++ b/packages/agent-core/README.md @@ -0,0 +1,29 @@ +# @posthog/agent-core + +Shared library for the PostHog agent platform runtime. Imported by `@posthog/agent-ingress` and `@posthog/agent-runner` — never imports from them, and never imports from `nodejs/`. + +See [`docs/internal/agent-platform.md`](../../docs/internal/agent-platform.md) for the full architecture. + +## What lives here + +- **Queue primitives** (`src/queue/`) — cyclotron-v2-shaped session queue, reimplemented from scratch. Single `agent_sessions` table backed by its own Postgres DB. Provides `SessionQueueManager` (enqueue), `SessionQueueWorker` (dequeue + lock + heartbeat), `SessionQueueJanitor` (stall recovery + poison-pill detection). +- **Types** (`src/types/`) — the session model, manifest, tool protocol, secrets. +- **Logger** (`src/logger.ts`) — pino-based structured logger. +- **Metrics** (`src/metrics.ts`) — Prom registry helpers. +- **Pub-sub** (`src/pubsub/`) — Redis pub-sub helper for session streaming; in-memory adapter for tests. +- **Internal-API client** (`src/internal-api/`) — calls Django for resolve + decrypt. +- **Built-ins registry** (`src/builtins/`) — hardcoded map of agent-stack built-in tool ids. Imported by both runner and future validator so unknown ids fail in both places. +- **Manifest reader** (`src/manifest/`) — parse + validate top-level config. + +## Database + +The queue owns a dedicated Postgres DB (`agent_runtime_queue`). Migrations live in `migrations/` and are applied via `bin/migrate.ts`. + +```bash +AGENT_RUNTIME_QUEUE_DATABASE_URL=postgres://... pnpm migrate +``` + +## Hard rules + +- **No imports from `nodejs/`.** Cherry-pick by copy. +- Process-less. Importing this package never starts a server, opens a pool, or schedules a timer. diff --git a/packages/agent-core/bin/migrate.ts b/packages/agent-core/bin/migrate.ts new file mode 100644 index 000000000000..eaf4371865f2 --- /dev/null +++ b/packages/agent-core/bin/migrate.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env tsx +/** + * Apply pending migrations to the agent-runtime queue DB. + * + * Reads SQL files from packages/agent-core/migrations/, applies them in lexicographic + * order, records each applied id in agent_runtime_migrations. + * + * Usage: + * AGENT_RUNTIME_QUEUE_DATABASE_URL=postgres://... pnpm migrate + */ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +import { Pool } from 'pg' + +async function main(): Promise { + const url = process.env.AGENT_RUNTIME_QUEUE_DATABASE_URL + if (!url) { + console.error('AGENT_RUNTIME_QUEUE_DATABASE_URL is required') + process.exit(1) + } + + const migrationsDir = join(__dirname, '..', 'migrations') + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql')) + .sort() + + const pool = new Pool({ connectionString: url }) + try { + await pool.query( + `CREATE TABLE IF NOT EXISTS agent_runtime_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )` + ) + + for (const file of files) { + const id = file.replace(/\.sql$/, '') + const { rowCount } = await pool.query('SELECT 1 FROM agent_runtime_migrations WHERE id = $1', [id]) + if (rowCount && rowCount > 0) { + console.info(`[migrate] skip ${id}`) + continue + } + const sql = readFileSync(join(migrationsDir, file), 'utf8') + console.info(`[migrate] apply ${id}`) + await pool.query('BEGIN') + try { + await pool.query(sql) + await pool.query('INSERT INTO agent_runtime_migrations (id) VALUES ($1)', [id]) + await pool.query('COMMIT') + } catch (err) { + await pool.query('ROLLBACK') + throw err + } + } + } finally { + await pool.end() + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/agent-core/jest.config.js b/packages/agent-core/jest.config.js new file mode 100644 index 000000000000..cb031c2723fd --- /dev/null +++ b/packages/agent-core/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.ts'], + testTimeout: 15_000, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, +} diff --git a/packages/agent-core/migrations/0001_initial_schema.sql b/packages/agent-core/migrations/0001_initial_schema.sql new file mode 100644 index 000000000000..76544abc66f4 --- /dev/null +++ b/packages/agent-core/migrations/0001_initial_schema.sql @@ -0,0 +1,54 @@ +-- agent_sessions: durable record of a single session execution. +-- Lives in a dedicated Postgres DB (agent_runtime_queue). The team-scoped mirror row +-- in main posthog Postgres (AgentSession) carries FKs to Team/AgentApplication/Revision. + +CREATE TYPE AgentSessionStatus AS ENUM( + 'available', + 'running', + 'completed', + 'failed', + 'canceled' +); + +CREATE TABLE IF NOT EXISTS agent_sessions ( + id UUID PRIMARY KEY, + team_id INT NOT NULL, + application_id UUID, + revision_id UUID, + queue_name TEXT NOT NULL, + status AgentSessionStatus NOT NULL, + scheduled TIMESTAMPTZ NOT NULL, + created TIMESTAMPTZ NOT NULL, + lock_id UUID, + last_heartbeat TIMESTAMPTZ, + janitor_touch_count SMALLINT NOT NULL DEFAULT 0, + transition_count SMALLINT NOT NULL DEFAULT 0, + last_transition TIMESTAMPTZ NOT NULL, + state BYTEA, + state_byte_size INT +); + +-- Dequeue path +CREATE INDEX idx_agent_sessions_dequeue + ON agent_sessions (queue_name, scheduled) + WHERE status = 'available'; + +-- Janitor: stalled running jobs +CREATE INDEX idx_agent_sessions_stalled + ON agent_sessions (last_heartbeat) + WHERE status = 'running'; + +-- Janitor: terminal jobs awaiting cleanup +CREATE INDEX idx_agent_sessions_terminal + ON agent_sessions (last_transition) + WHERE status IN ('completed', 'failed', 'canceled'); + +CREATE INDEX idx_agent_sessions_team_id ON agent_sessions(team_id); +CREATE INDEX idx_agent_sessions_revision_id ON agent_sessions(revision_id); +CREATE INDEX idx_agent_sessions_application_id ON agent_sessions(application_id); + +-- Bookkeeping so we can apply migrations idempotently. +CREATE TABLE IF NOT EXISTS agent_runtime_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json new file mode 100644 index 000000000000..2458de3ef4b8 --- /dev/null +++ b/packages/agent-core/package.json @@ -0,0 +1,44 @@ +{ + "name": "@posthog/agent-core", + "version": "0.1.0", + "description": "Shared library for the PostHog agent platform runtime (queue primitives, types, internal-API client).", + "license": "MIT", + "author": "PostHog ", + "repository": "https://github.com/PostHog/posthog", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "pnpm clean && tsc -b", + "clean": "rm -rf dist", + "typescript:check": "tsc --noEmit -p .", + "test": "jest --runInBand --forceExit", + "migrate": "tsx bin/migrate.ts" + }, + "dependencies": { + "luxon": "^3.4.4", + "node-fetch": "^2.6.1", + "pg": "^8.6.0", + "pino": "^8.6.0", + "prom-client": "^14.2.0", + "ioredis": "^4.27.6", + "uuid": "^10.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/ioredis": "^4.26.4", + "@types/jest": "catalog:", + "@types/luxon": "^3.4.2", + "@types/node": "catalog:", + "@types/node-fetch": "^2.5.10", + "@types/pg": "^8.6.0", + "@types/uuid": "^10.0.0", + "jest": "catalog:", + "ts-jest": "^29.1.0", + "tsx": "^4.7.0", + "typescript": "catalog:" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/packages/agent-core/src/builtins/index.ts b/packages/agent-core/src/builtins/index.ts new file mode 100644 index 000000000000..daac5758f0a1 --- /dev/null +++ b/packages/agent-core/src/builtins/index.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' + +/** + * Registry of built-in tool ids that agent-stack manifests are allowed to reference. + * + * Authoritative source for both the runner (which executes them) and the future + * validator (which rejects unknown ids at deploy time). Keep entries minimal — only + * the contract; the runner registers the actual implementations against these ids. + */ + +export interface BuiltinSpec { + /** Public id used in agent-stack manifests. Stable. */ + id: string + /** Short human description for UIs and the validator's reports. */ + description: string + /** JSON schema for the tool's arguments, expressed as a zod schema. */ + args: z.ZodTypeAny + /** Allowed action names for this tool, when the manifest uses fine-grained allow-listing. */ + actions?: readonly string[] +} + +const BUILTIN_SPECS: readonly BuiltinSpec[] = [ + { + id: 'posthog.events.capture', + description: 'Capture an event into PostHog product analytics.', + args: z.object({ + event: z.string().min(1), + distinctId: z.string().min(1), + properties: z.record(z.string(), z.unknown()).optional(), + }), + }, + { + id: 'posthog.feature_flags.evaluate', + description: 'Evaluate a PostHog feature flag for a given distinct id.', + args: z.object({ + flag: z.string().min(1), + distinctId: z.string().min(1), + groups: z.record(z.string(), z.string()).optional(), + }), + }, + { + id: 'http.fetch', + description: 'Make an outbound HTTP request from the agent runtime.', + args: z.object({ + url: z.string().url(), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), + timeoutMs: z.number().int().min(1).max(60_000).default(10_000), + }), + }, +] as const + +const BUILTIN_INDEX: ReadonlyMap = new Map(BUILTIN_SPECS.map((spec) => [spec.id, spec])) + +export function listBuiltins(): readonly BuiltinSpec[] { + return BUILTIN_SPECS +} + +export function getBuiltin(id: string): BuiltinSpec | null { + return BUILTIN_INDEX.get(id) ?? null +} + +export function isBuiltinId(id: string): boolean { + return BUILTIN_INDEX.has(id) +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts new file mode 100644 index 000000000000..17ab4715d4c3 --- /dev/null +++ b/packages/agent-core/src/index.ts @@ -0,0 +1,8 @@ +export * from './queue' +export * from './pubsub' +export * from './internal-api' +export * from './builtins' +export * from './manifest' +export { logger } from './logger' +export type { Logger } from './logger' +export { registry, collectDefaults, metricsText, metricsContentType } from './metrics' diff --git a/packages/agent-core/src/internal-api/client.ts b/packages/agent-core/src/internal-api/client.ts new file mode 100644 index 000000000000..18486e17d2af --- /dev/null +++ b/packages/agent-core/src/internal-api/client.ts @@ -0,0 +1,78 @@ +import fetch, { Headers, RequestInit, Response } from 'node-fetch' + +import { logger } from '../logger' +import { ResolvedRevision, ResolvedRevisionSchema, SecretsResponse, SecretsResponseSchema } from './types' + +export interface InternalApiClientConfig { + baseUrl: string + /** Shared signing key checked by Django middleware (or set to undefined for mTLS deployments). */ + sharedKey?: string + /** AbortController-style timeout. */ + timeoutMs?: number +} + +/** + * Talks to Django for the two endpoints declared in the agent-platform plan: + * - GET /internal/agents/applications/resolve + * - POST /internal/agents/secrets/{app_id}/decrypt + * + * Both live behind internal-only scopes and are not exposed in the public API. + */ +export class InternalApiClient { + constructor(private readonly config: InternalApiClientConfig) {} + + /** Resolve a domain or application id to the live revision + manifest. */ + async resolve(input: { domain?: string; applicationId?: string }): Promise { + const params = new URLSearchParams() + if (input.domain) { + params.set('domain', input.domain) + } + if (input.applicationId) { + params.set('application_id', input.applicationId) + } + const url = `${this.config.baseUrl}/internal/agents/applications/resolve?${params.toString()}` + + const response = await this.fetchWithAuth(url, { method: 'GET' }) + if (response.status === 404) { + return null + } + if (!response.ok) { + throw new Error(`internal-api resolve failed: ${response.status} ${await response.text()}`) + } + const body = (await response.json()) as unknown + return ResolvedRevisionSchema.parse(body) + } + + /** Decrypt a set of named secrets for an application. Audit-logged on the Django side. */ + async decryptSecrets(applicationId: string, names: string[]): Promise { + const url = `${this.config.baseUrl}/internal/agents/secrets/${encodeURIComponent(applicationId)}/decrypt` + const response = await this.fetchWithAuth(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ names }), + }) + if (!response.ok) { + throw new Error(`internal-api decryptSecrets failed: ${response.status} ${await response.text()}`) + } + const body = (await response.json()) as unknown + return SecretsResponseSchema.parse(body) + } + + private async fetchWithAuth(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers ?? {}) + if (this.config.sharedKey) { + headers.set('x-internal-key', this.config.sharedKey) + } + const controller = new AbortController() + const timeout = this.config.timeoutMs ?? 5_000 + const timer = setTimeout(() => controller.abort(), timeout) + try { + return await fetch(url, { ...init, headers, signal: controller.signal }) + } catch (err) { + logger.error('internal-api request failed', { url, error: String(err) }) + throw err + } finally { + clearTimeout(timer) + } + } +} diff --git a/packages/agent-core/src/internal-api/index.ts b/packages/agent-core/src/internal-api/index.ts new file mode 100644 index 000000000000..ddb8a439f177 --- /dev/null +++ b/packages/agent-core/src/internal-api/index.ts @@ -0,0 +1,4 @@ +export { InternalApiClient } from './client' +export type { InternalApiClientConfig } from './client' +export { ResolvedRevisionSchema, SecretsResponseSchema } from './types' +export type { ResolvedRevision, SecretsResponse } from './types' diff --git a/packages/agent-core/src/internal-api/types.ts b/packages/agent-core/src/internal-api/types.ts new file mode 100644 index 000000000000..c72fff096a25 --- /dev/null +++ b/packages/agent-core/src/internal-api/types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +/** Resolved application + live revision payload returned by Django. */ +export const ResolvedRevisionSchema = z.object({ + applicationId: z.string().uuid(), + applicationSlug: z.string(), + teamId: z.number().int(), + revisionId: z.string().uuid(), + revisionState: z.enum(['pending_upload', 'uploaded', 'validating', 'ready', 'failed']), + bundleS3Key: z.string(), + bundleSha256: z.string(), + topLevelConfig: z.record(z.string(), z.unknown()), + parsedManifest: z.record(z.string(), z.unknown()).nullable(), + auth: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('public') }), + z.object({ mode: z.literal('shared_secret'), token: z.string().min(1) }), + z.object({ mode: z.literal('webhook_signature'), provider: z.string(), secret: z.string().min(1) }), + ]), +}) + +export type ResolvedRevision = z.infer + +export const SecretsResponseSchema = z.object({ + secrets: z.record(z.string(), z.string()), +}) + +export type SecretsResponse = z.infer diff --git a/packages/agent-core/src/logger.ts b/packages/agent-core/src/logger.ts new file mode 100644 index 000000000000..1d1c707a7a5d --- /dev/null +++ b/packages/agent-core/src/logger.ts @@ -0,0 +1,14 @@ +import pino, { Logger } from 'pino' + +const level = process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'test' ? 'silent' : 'info') + +export const logger: Logger = pino({ + level, + base: { pkg: '@posthog/agent-core' }, + timestamp: pino.stdTimeFunctions.isoTime, + formatters: { + level: (label) => ({ level: label }), + }, +}) + +export type { Logger } diff --git a/packages/agent-core/src/manifest/index.ts b/packages/agent-core/src/manifest/index.ts new file mode 100644 index 000000000000..b4f5f2574f1e --- /dev/null +++ b/packages/agent-core/src/manifest/index.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' + +import { isBuiltinId } from '../builtins' + +/** + * Minimal manifest shape used by the v1 runner. The full validator (packages/agent-validator, + * deferred) will parse bundle contents, walk the YAML tree, and produce a richer parsed_manifest. + * In v1, the Django side stores top_level_config from the CLI's parse step and the runner reads + * it directly. Both code paths share this schema so they agree on what's valid. + */ + +const ToolReferenceSchema = z.object({ + id: z.string().min(1), + /** Optional action allow-list when the manifest opts into fine-grained scoping. */ + actions: z.array(z.string()).optional(), +}) + +const TriggerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('http'), path: z.string().startsWith('/') }), + z.object({ kind: z.literal('cron'), schedule: z.string().min(1) }), + z.object({ kind: z.literal('webhook'), provider: z.string().min(1) }), +]) + +export const ManifestSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + entrypoint: z.string().min(1), + tools: z.array(ToolReferenceSchema).default([]), + triggers: z.array(TriggerSchema).default([]), +}) + +export type Manifest = z.infer +export type ToolReference = z.infer +export type Trigger = z.infer + +export interface ManifestValidationError { + path: string + message: string +} + +export interface ManifestValidationResult { + manifest: Manifest | null + errors: ManifestValidationError[] +} + +/** + * Parse + validate raw manifest data. Returns a structured result rather than throwing + * so callers (Django start_deploy, the future validator, the CLI) can surface all errors + * at once. + */ +export function parseManifest(raw: unknown): ManifestValidationResult { + const parsed = ManifestSchema.safeParse(raw) + if (!parsed.success) { + return { + manifest: null, + errors: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.') || '', + message: issue.message, + })), + } + } + + const errors: ManifestValidationError[] = [] + parsed.data.tools.forEach((tool, index) => { + if (!isBuiltinId(tool.id)) { + errors.push({ + path: `tools.${index}.id`, + message: `unknown built-in tool id: ${tool.id}`, + }) + } + }) + + return { + manifest: errors.length === 0 ? parsed.data : null, + errors, + } +} diff --git a/packages/agent-core/src/metrics.ts b/packages/agent-core/src/metrics.ts new file mode 100644 index 000000000000..bbf70c3504db --- /dev/null +++ b/packages/agent-core/src/metrics.ts @@ -0,0 +1,28 @@ +import { Registry, collectDefaultMetrics } from 'prom-client' + +/** + * Process-local Prom registry for the agent runtime. Each process (ingress / runner) + * uses this registry and exposes /metrics from it. + * + * We deliberately do not use the default global registry to avoid cross-talk with any + * library that registers default metrics at import time. + */ +export const registry = new Registry() + +let defaultsCollected = false + +export function collectDefaults(): void { + if (defaultsCollected) { + return + } + collectDefaultMetrics({ register: registry }) + defaultsCollected = true +} + +export async function metricsText(): Promise { + return registry.metrics() +} + +export function metricsContentType(): string { + return registry.contentType +} diff --git a/packages/agent-core/src/pubsub/in-memory.ts b/packages/agent-core/src/pubsub/in-memory.ts new file mode 100644 index 000000000000..7452abe9ccbc --- /dev/null +++ b/packages/agent-core/src/pubsub/in-memory.ts @@ -0,0 +1,53 @@ +import { EventEmitter } from 'node:events' + +import { SessionBus, SessionEvent, SessionEventListener, SessionInputListener, SessionInputMessage } from './types' + +/** + * In-process implementation for tests and single-node dev. Not suitable for production + * because /listen subscribers and the runner generally live in different processes. + */ +export class InMemorySessionBus implements SessionBus { + private readonly emitter = new EventEmitter() + + constructor() { + // EventEmitter defaults to 10 listeners. We can plausibly have many concurrent + // /listen subscribers per session on a busy node, so bump it. + this.emitter.setMaxListeners(0) + } + + async publishEvent(sessionId: string, event: SessionEvent): Promise { + this.emitter.emit(this.eventChannel(sessionId), event) + } + + async subscribeEvents(sessionId: string, listener: SessionEventListener): Promise<() => Promise> { + const channel = this.eventChannel(sessionId) + this.emitter.on(channel, listener) + return async () => { + this.emitter.off(channel, listener) + } + } + + async publishInput(sessionId: string, message: SessionInputMessage): Promise { + this.emitter.emit(this.inputChannel(sessionId), message) + } + + async subscribeInput(sessionId: string, listener: SessionInputListener): Promise<() => Promise> { + const channel = this.inputChannel(sessionId) + this.emitter.on(channel, listener) + return async () => { + this.emitter.off(channel, listener) + } + } + + async disconnect(): Promise { + this.emitter.removeAllListeners() + } + + private eventChannel(sessionId: string): string { + return `agent_session:${sessionId}` + } + + private inputChannel(sessionId: string): string { + return `agent_session:${sessionId}:input` + } +} diff --git a/packages/agent-core/src/pubsub/index.ts b/packages/agent-core/src/pubsub/index.ts new file mode 100644 index 000000000000..f6861f0f9d0a --- /dev/null +++ b/packages/agent-core/src/pubsub/index.ts @@ -0,0 +1,4 @@ +export { InMemorySessionBus } from './in-memory' +export { RedisSessionBus } from './redis' +export type { RedisSessionBusConfig } from './redis' +export type { SessionBus, SessionEvent, SessionEventListener, SessionInputListener, SessionInputMessage } from './types' diff --git a/packages/agent-core/src/pubsub/redis.ts b/packages/agent-core/src/pubsub/redis.ts new file mode 100644 index 000000000000..25e785d76cdb --- /dev/null +++ b/packages/agent-core/src/pubsub/redis.ts @@ -0,0 +1,93 @@ +import Redis from 'ioredis' + +import { logger } from '../logger' +import { SessionBus, SessionEvent, SessionEventListener, SessionInputListener, SessionInputMessage } from './types' + +export interface RedisSessionBusConfig { + /** ioredis-compatible URL (e.g. redis://host:6379). */ + url: string +} + +/** + * Redis pub-sub bus, one publish client + one subscribe client. Subscriptions are + * multiplexed onto the single subscribe connection; channels are tracked by ref-count + * so we only unsubscribe when the last listener for a channel goes away. + */ +export class RedisSessionBus implements SessionBus { + private readonly publisher: Redis.Redis + private readonly subscriber: Redis.Redis + private readonly channelListeners = new Map void>>() + + constructor(config: RedisSessionBusConfig) { + this.publisher = new Redis(config.url) + this.subscriber = new Redis(config.url) + this.subscriber.on('message', (channel: string, message: string) => { + const listeners = this.channelListeners.get(channel) + if (!listeners) { + return + } + for (const listener of listeners) { + try { + listener(message) + } catch (err) { + logger.error('RedisSessionBus listener error', { channel, error: String(err) }) + } + } + }) + } + + async publishEvent(sessionId: string, event: SessionEvent): Promise { + await this.publisher.publish(this.eventChannel(sessionId), JSON.stringify(event)) + } + + async subscribeEvents(sessionId: string, listener: SessionEventListener): Promise<() => Promise> { + return this.subscribe(this.eventChannel(sessionId), (raw) => { + listener(JSON.parse(raw) as SessionEvent) + }) + } + + async publishInput(sessionId: string, message: SessionInputMessage): Promise { + await this.publisher.publish(this.inputChannel(sessionId), JSON.stringify(message)) + } + + async subscribeInput(sessionId: string, listener: SessionInputListener): Promise<() => Promise> { + return this.subscribe(this.inputChannel(sessionId), (raw) => { + listener(JSON.parse(raw) as SessionInputMessage) + }) + } + + async disconnect(): Promise { + this.channelListeners.clear() + await this.publisher.quit() + await this.subscriber.quit() + } + + private async subscribe(channel: string, rawListener: (message: string) => void): Promise<() => Promise> { + let listeners = this.channelListeners.get(channel) + if (!listeners) { + listeners = new Set() + this.channelListeners.set(channel, listeners) + await this.subscriber.subscribe(channel) + } + listeners.add(rawListener) + return async () => { + const current = this.channelListeners.get(channel) + if (!current) { + return + } + current.delete(rawListener) + if (current.size === 0) { + this.channelListeners.delete(channel) + await this.subscriber.unsubscribe(channel) + } + } + } + + private eventChannel(sessionId: string): string { + return `agent_session:${sessionId}` + } + + private inputChannel(sessionId: string): string { + return `agent_session:${sessionId}:input` + } +} diff --git a/packages/agent-core/src/pubsub/types.ts b/packages/agent-core/src/pubsub/types.ts new file mode 100644 index 000000000000..bcb52cd89e01 --- /dev/null +++ b/packages/agent-core/src/pubsub/types.ts @@ -0,0 +1,40 @@ +/** + * Per-session event types. Best-effort delivery over the bus; the durable record + * is the queue row and the final state blob. + */ +export type SessionEvent = + | { type: 'turn_started'; at: string } + | { type: 'turn_completed'; at: string } + | { type: 'tool_call'; tool: string; at: string; args?: unknown } + | { type: 'tool_result'; tool: string; at: string; ok: boolean; result?: unknown; error?: string } + | { type: 'message'; at: string; role: 'assistant' | 'system' | 'user'; content: string } + | { type: 'session_completed'; at: string; output: unknown } + | { type: 'session_failed'; at: string; error: string } + +/** + * Messages sent in via /send/:id. Picked up by the runner at the next yield. + */ +export type SessionInputMessage = { + type: 'user_message' + at: string + content: string +} + +export type SessionEventListener = (event: SessionEvent) => void +export type SessionInputListener = (message: SessionInputMessage) => void + +export interface SessionBus { + /** Publish an event for a session. Returns when at least one subscriber has been notified (best-effort). */ + publishEvent(sessionId: string, event: SessionEvent): Promise + + /** Subscribe to the session's event channel. Returns an unsubscribe function. */ + subscribeEvents(sessionId: string, listener: SessionEventListener): Promise<() => Promise> + + /** Publish a user-input message for a session. */ + publishInput(sessionId: string, message: SessionInputMessage): Promise + + /** Subscribe to the session's input channel (runner side). */ + subscribeInput(sessionId: string, listener: SessionInputListener): Promise<() => Promise> + + disconnect(): Promise +} diff --git a/packages/agent-core/src/queue/index.ts b/packages/agent-core/src/queue/index.ts new file mode 100644 index 000000000000..59b136989dad --- /dev/null +++ b/packages/agent-core/src/queue/index.ts @@ -0,0 +1,18 @@ +export { SessionQueueManager } from './manager' +export { SessionQueueWorker } from './worker' +export { SessionQueueJanitor } from './janitor' +export { + SessionJobInitSchema, + RescheduleOptionsSchema, +} from './types' +export type { + SessionStatus, + PoolConfig, + SessionJobInit, + RescheduleOptions, + DequeuedSessionJob, + ManagerConfig, + WorkerConfig, + JanitorConfig, + CleanupResult, +} from './types' diff --git a/packages/agent-core/src/queue/janitor.ts b/packages/agent-core/src/queue/janitor.ts new file mode 100644 index 000000000000..986faccee7e6 --- /dev/null +++ b/packages/agent-core/src/queue/janitor.ts @@ -0,0 +1,191 @@ +import { Pool } from 'pg' +import { Counter, Gauge } from 'prom-client' + +import { logger } from '../logger' +import { CleanupResult, JanitorConfig } from './types' + +const janitorDeletedCounter = new Counter({ + name: 'agent_core_janitor_deleted', + help: 'Number of terminal agent sessions cleaned up by the janitor', + labelNames: ['status'], +}) + +const janitorStalledCounter = new Counter({ + name: 'agent_core_janitor_stalled', + help: 'Number of stalled agent sessions reset by the janitor', +}) + +const janitorPoisonedCounter = new Counter({ + name: 'agent_core_janitor_poisoned', + help: 'Number of poison pill agent sessions failed by the janitor', +}) + +const janitorRunCounter = new Counter({ + name: 'agent_core_janitor_runs', + help: 'Number of agent session janitor runs completed', +}) + +const queueDepthGauge = new Gauge({ + name: 'agent_core_queue_depth', + help: 'Number of available agent sessions per queue', + labelNames: ['queue'], +}) + +export class SessionQueueJanitor { + private pool: Pool + private intervalHandle: ReturnType | null = null + + private readonly cleanupBatchSize: number + private readonly cleanupIntervalMs: number + private readonly stallTimeoutMs: number + private readonly maxTouchCount: number + private readonly cleanupGraceMs: number + + constructor(config: JanitorConfig) { + this.pool = new Pool({ + connectionString: config.pool.dbUrl, + max: config.pool.maxConnections ?? 5, + idleTimeoutMillis: config.pool.idleTimeoutMs ?? 30_000, + }) + this.cleanupBatchSize = config.cleanupBatchSize ?? 10_000 + this.cleanupIntervalMs = config.cleanupIntervalMs ?? 10_000 + this.stallTimeoutMs = config.stallTimeoutMs ?? 30_000 + this.maxTouchCount = config.maxTouchCount ?? 3 + this.cleanupGraceMs = config.cleanupGraceMs ?? 10_000 + } + + async start(): Promise { + const client = await this.pool.connect() + client.release() + + this.intervalHandle = setInterval(() => { + this.runOnce().catch((err) => { + logger.error('SessionQueueJanitor run error', { error: String(err) }) + }) + }, this.cleanupIntervalMs) + + await this.runOnce() + } + + async runOnce(): Promise { + const deleted = await this.cleanupTerminalJobs() + const poisoned = await this.failPoisonPills() + const stalled = await this.resetStalledJobs() + const depths = await this.measureQueueDepths() + + janitorRunCounter.inc() + + return { deleted, stalled, poisoned, depths } + } + + private async cleanupTerminalJobs(): Promise { + const cutoff = new Date(Date.now() - this.cleanupGraceMs) + const result = await this.pool.query<{ status: string }>( + `WITH to_delete AS ( + SELECT id + FROM agent_sessions + WHERE status IN ('completed', 'failed', 'canceled') + AND last_transition < $1 + ORDER BY last_transition ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + DELETE FROM agent_sessions + USING to_delete + WHERE agent_sessions.id = to_delete.id + RETURNING agent_sessions.status::text`, + [cutoff, this.cleanupBatchSize] + ) + + let total = 0 + const counts: Record = {} + for (const row of result.rows) { + counts[row.status] = (counts[row.status] ?? 0) + 1 + total++ + } + for (const [status, count] of Object.entries(counts)) { + janitorDeletedCounter.inc({ status }, count) + } + if (total > 0) { + logger.info('SessionQueueJanitor cleaned up terminal sessions', { counts, total }) + } + return total + } + + private async failPoisonPills(): Promise { + const heartbeatCutoff = new Date(Date.now() - this.stallTimeoutMs) + const result = await this.pool.query( + `UPDATE agent_sessions + SET status = 'failed', lock_id = NULL, last_heartbeat = NULL, + last_transition = NOW(), transition_count = transition_count + 1 + WHERE id IN ( + SELECT id + FROM agent_sessions + WHERE status = 'running' + AND COALESCE(last_heartbeat, $1) <= $1 + AND janitor_touch_count >= $2 + FOR UPDATE SKIP LOCKED + )`, + [heartbeatCutoff, this.maxTouchCount] + ) + const count = result.rowCount ?? 0 + if (count > 0) { + janitorPoisonedCounter.inc(count) + logger.warn('SessionQueueJanitor failed poison-pill sessions', { count }) + } + return count + } + + private async resetStalledJobs(): Promise { + const heartbeatCutoff = new Date(Date.now() - this.stallTimeoutMs) + const result = await this.pool.query( + `WITH stalled AS ( + SELECT id + FROM agent_sessions + WHERE status = 'running' + AND COALESCE(last_heartbeat, $1) <= $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE agent_sessions + SET status = 'available', lock_id = NULL, last_heartbeat = NULL, + janitor_touch_count = janitor_touch_count + 1 + FROM stalled + WHERE agent_sessions.id = stalled.id`, + [heartbeatCutoff] + ) + const count = result.rowCount ?? 0 + if (count > 0) { + janitorStalledCounter.inc(count) + logger.info('SessionQueueJanitor reset stalled sessions', { count }) + } + return count + } + + async measureQueueDepths(): Promise> { + const result = await this.pool.query<{ queue_name: string; count: string }>( + `SELECT queue_name, COUNT(*) as count + FROM agent_sessions + WHERE status = 'available' AND scheduled <= NOW() + GROUP BY queue_name` + ) + const depths = new Map() + for (const row of result.rows) { + const count = parseInt(row.count, 10) + depths.set(row.queue_name, count) + queueDepthGauge.labels({ queue: row.queue_name }).set(count) + } + return depths + } + + isRunning(): boolean { + return this.intervalHandle !== null + } + + async stop(): Promise { + if (this.intervalHandle) { + clearInterval(this.intervalHandle) + this.intervalHandle = null + } + await this.pool.end() + } +} diff --git a/packages/agent-core/src/queue/manager.ts b/packages/agent-core/src/queue/manager.ts new file mode 100644 index 000000000000..4252fb2b062a --- /dev/null +++ b/packages/agent-core/src/queue/manager.ts @@ -0,0 +1,112 @@ +import { Pool } from 'pg' +import { v7 as uuidv7 } from 'uuid' + +import { logger } from '../logger' +import { ManagerConfig, SessionJobInit, SessionJobInitSchema } from './types' + +const DEFAULT_DEPTH_LIMIT = 1_000_000 +const DEFAULT_DEPTH_CHECK_INTERVAL_MS = 10_000 +const DEFAULT_MAX_STATE_BYTES = 1_048_576 // 1 MiB soft cap + +export class SessionQueueManager { + private pool: Pool + private readonly depthLimit: number + private readonly depthCheckIntervalMs: number + private readonly maxStateByteSize: number + private depthCheckPromise: Promise | null = null + private depthCheckExpiresAt = 0 + + constructor(config: ManagerConfig) { + this.pool = new Pool({ + connectionString: config.pool.dbUrl, + max: config.pool.maxConnections ?? 10, + idleTimeoutMillis: config.pool.idleTimeoutMs ?? 30_000, + }) + this.depthLimit = config.depthLimit ?? DEFAULT_DEPTH_LIMIT + this.depthCheckIntervalMs = config.depthCheckIntervalMs ?? DEFAULT_DEPTH_CHECK_INTERVAL_MS + this.maxStateByteSize = config.maxStateByteSize ?? DEFAULT_MAX_STATE_BYTES + } + + async connect(): Promise { + const client = await this.pool.connect() + client.release() + } + + async createJob(input: SessionJobInit): Promise { + const job = SessionJobInitSchema.parse(input) + this.assertStateUnderCap(job.state) + await this.insertGuard() + + const id = job.id ?? uuidv7() + const now = new Date() + const stateByteSize = job.state ? job.state.byteLength : null + + await this.pool.query( + `INSERT INTO agent_sessions + (id, team_id, application_id, revision_id, queue_name, status, scheduled, created, + lock_id, last_heartbeat, janitor_touch_count, transition_count, last_transition, + state, state_byte_size) + VALUES ($1, $2, $3, $4, $5, 'available', $6, $7, + NULL, NULL, 0, 0, $7, + $8, $9)`, + [ + id, + job.teamId, + job.applicationId ?? null, + job.revisionId ?? null, + job.queueName, + job.scheduled ?? now, + now, + job.state ?? null, + stateByteSize, + ] + ) + return id + } + + async disconnect(): Promise { + await this.pool.end() + } + + private assertStateUnderCap(state: Buffer | null | undefined): void { + if (state && state.byteLength > this.maxStateByteSize) { + throw new Error( + `Session state too large (${state.byteLength} bytes, cap ${this.maxStateByteSize}); ` + + 'offload conversation log to S3 and store only the pointer in state.' + ) + } + } + + private async insertGuard(): Promise { + if (await this.isFull()) { + throw new Error(`Agent session queue is full (depth limit: ${this.depthLimit})`) + } + } + + private isFull(): Promise { + if (this.depthCheckPromise && Date.now() < this.depthCheckExpiresAt) { + return this.depthCheckPromise + } + this.depthCheckPromise = this.queryDepth() + this.depthCheckExpiresAt = Date.now() + this.depthCheckIntervalMs + return this.depthCheckPromise + } + + private async queryDepth(): Promise { + try { + const result = await this.pool.query<{ count: string }>( + `SELECT COUNT(*) AS count FROM agent_sessions + WHERE status = 'available' AND scheduled <= NOW()` + ) + const count = parseInt(result.rows[0].count, 10) + const full = count >= this.depthLimit + if (full) { + logger.warn('Agent session queue at capacity', { count, depthLimit: this.depthLimit }) + } + return full + } catch (e) { + logger.error('Agent session queue depth check failed', { error: String(e) }) + return false + } + } +} diff --git a/packages/agent-core/src/queue/types.ts b/packages/agent-core/src/queue/types.ts new file mode 100644 index 000000000000..97c84d0e6860 --- /dev/null +++ b/packages/agent-core/src/queue/types.ts @@ -0,0 +1,82 @@ +import { DateTime } from 'luxon' +import { z } from 'zod' + +export type SessionStatus = 'available' | 'running' | 'completed' | 'failed' | 'canceled' + +export interface PoolConfig { + dbUrl: string + maxConnections?: number + idleTimeoutMs?: number +} + +const uuidSchema = z.string().uuid() + +export const SessionJobInitSchema = z.object({ + id: uuidSchema.optional(), + teamId: z.number().int(), + applicationId: uuidSchema.nullish(), + revisionId: uuidSchema.nullish(), + queueName: z.string().min(1), + scheduled: z.date().optional(), + state: z.instanceof(Buffer).nullish(), +}) + +export type SessionJobInit = z.infer + +export const RescheduleOptionsSchema = z.object({ + scheduledAt: z.date().optional(), + state: z.instanceof(Buffer).nullish(), +}) + +export type RescheduleOptions = z.infer + +export interface DequeuedSessionJob { + readonly id: string + readonly teamId: number + readonly applicationId: string | null + readonly revisionId: string | null + readonly queueName: string + readonly scheduled: DateTime + readonly created: DateTime + readonly transitionCount: number + readonly state: Buffer | null + + ack(): Promise + fail(): Promise + reschedule(options?: RescheduleOptions): Promise + cancel(): Promise + heartbeat(): Promise +} + +export interface ManagerConfig { + pool: PoolConfig + depthLimit?: number + depthCheckIntervalMs?: number + /** Soft cap on serialized SDK state stored inline. Larger payloads should be offloaded. */ + maxStateByteSize?: number +} + +export interface WorkerConfig { + pool: PoolConfig + queueName: string + batchMaxSize?: number + pollDelayMs?: number + heartbeatTimeoutMs?: number + includeEmptyBatches?: boolean +} + +export interface JanitorConfig { + pool: PoolConfig + cleanupBatchSize?: number + cleanupIntervalMs?: number + stallTimeoutMs?: number + maxTouchCount?: number + cleanupGraceMs?: number +} + +export interface CleanupResult { + deleted: number + stalled: number + poisoned: number + depths: Map +} diff --git a/packages/agent-core/src/queue/worker.ts b/packages/agent-core/src/queue/worker.ts new file mode 100644 index 000000000000..1f66b5b28827 --- /dev/null +++ b/packages/agent-core/src/queue/worker.ts @@ -0,0 +1,225 @@ +import { DateTime } from 'luxon' +import { Pool } from 'pg' +import { v7 as uuidv7 } from 'uuid' + +import { logger } from '../logger' +import { DequeuedSessionJob, RescheduleOptions, RescheduleOptionsSchema, WorkerConfig } from './types' + +interface RawSessionRow { + id: string + team_id: number + application_id: string | null + revision_id: string | null + queue_name: string + scheduled: string + created: string + transition_count: number + state: Buffer | null + lock_id: string +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class SessionQueueWorker { + private pool: Pool + private isConsuming = false + private lastPollTime = new Date() + private consumerLoopPromise: Promise | null = null + + private readonly batchMaxSize: number + private readonly pollDelayMs: number + private readonly heartbeatTimeoutMs: number + private readonly includeEmptyBatches: boolean + + constructor(private config: WorkerConfig) { + this.pool = new Pool({ + connectionString: config.pool.dbUrl, + max: config.pool.maxConnections ?? 10, + idleTimeoutMillis: config.pool.idleTimeoutMs ?? 30_000, + }) + this.batchMaxSize = config.batchMaxSize ?? 100 + this.pollDelayMs = config.pollDelayMs ?? 50 + this.heartbeatTimeoutMs = config.heartbeatTimeoutMs ?? 30_000 + this.includeEmptyBatches = config.includeEmptyBatches ?? false + } + + async connect(processBatch: (jobs: DequeuedSessionJob[]) => Promise): Promise { + const client = await this.pool.connect() + client.release() + this.isConsuming = true + this.consumerLoopPromise = this.runConsumerLoop(processBatch) + } + + private async runConsumerLoop(processBatch: (jobs: DequeuedSessionJob[]) => Promise): Promise { + while (this.isConsuming) { + try { + this.lastPollTime = new Date() + const rows = await this.dequeueJobs() + if (rows.length === 0) { + if (this.includeEmptyBatches) { + await processBatch([]) + } + await sleep(this.pollDelayMs) + continue + } + const jobs = rows.map((row) => this.wrapJob(row)) + await processBatch(jobs) + } catch (err) { + logger.error('SessionQueueWorker consumer loop error', { error: String(err) }) + await sleep(this.pollDelayMs) + } + } + } + + private async dequeueJobs(): Promise { + const lockId = uuidv7() + const result = await this.pool.query( + `WITH available AS ( + SELECT id + FROM agent_sessions + WHERE status = 'available' + AND queue_name = $1 + AND scheduled <= NOW() + ORDER BY scheduled ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + UPDATE agent_sessions + SET status = 'running', + lock_id = $3, + last_heartbeat = NOW(), + last_transition = NOW(), + transition_count = transition_count + 1 + FROM available + WHERE agent_sessions.id = available.id + RETURNING + agent_sessions.id, + agent_sessions.team_id, + agent_sessions.application_id, + agent_sessions.revision_id, + agent_sessions.queue_name, + agent_sessions.scheduled, + agent_sessions.created, + agent_sessions.transition_count, + agent_sessions.state, + agent_sessions.lock_id`, + [this.config.queueName, this.batchMaxSize, lockId] + ) + return result.rows.sort((a, b) => new Date(a.scheduled).getTime() - new Date(b.scheduled).getTime()) + } + + private wrapJob(row: RawSessionRow): DequeuedSessionJob { + const pool = this.pool + const lockId = row.lock_id + let released = false + + const releaseGuard = (method: string): void => { + if (released) { + throw new Error(`Session ${row.id} already released, cannot call ${method}`) + } + released = true + } + + return { + id: row.id, + teamId: row.team_id, + applicationId: row.application_id, + revisionId: row.revision_id, + queueName: row.queue_name, + scheduled: DateTime.fromISO(row.scheduled, { zone: 'utc' }), + created: DateTime.fromISO(row.created, { zone: 'utc' }), + transitionCount: row.transition_count, + state: row.state, + + async ack(): Promise { + releaseGuard('ack') + await pool.query( + `UPDATE agent_sessions + SET status = 'completed', lock_id = NULL, last_heartbeat = NULL, + last_transition = NOW(), transition_count = transition_count + 1 + WHERE id = $1 AND lock_id = $2`, + [row.id, lockId] + ) + }, + + async fail(): Promise { + releaseGuard('fail') + await pool.query( + `UPDATE agent_sessions + SET status = 'failed', lock_id = NULL, last_heartbeat = NULL, + last_transition = NOW(), transition_count = transition_count + 1 + WHERE id = $1 AND lock_id = $2`, + [row.id, lockId] + ) + }, + + async reschedule(input?: RescheduleOptions): Promise { + releaseGuard('reschedule') + const options = input ? RescheduleOptionsSchema.parse(input) : undefined + const scheduled = options?.scheduledAt ?? new Date() + const setClauses = [ + `status = 'available'`, + `lock_id = NULL`, + `last_heartbeat = NULL`, + `last_transition = NOW()`, + `transition_count = transition_count + 1`, + `scheduled = $3`, + ] + const params: unknown[] = [row.id, lockId, scheduled] + if (options?.state !== undefined) { + params.push(options.state ?? null) + setClauses.push(`state = $${params.length}`) + params.push(options.state ? options.state.byteLength : null) + setClauses.push(`state_byte_size = $${params.length}`) + } + await pool.query( + `UPDATE agent_sessions SET ${setClauses.join(', ')} + WHERE id = $1 AND lock_id = $2`, + params + ) + }, + + async cancel(): Promise { + releaseGuard('cancel') + await pool.query( + `UPDATE agent_sessions + SET status = 'canceled', lock_id = NULL, last_heartbeat = NULL, + last_transition = NOW(), transition_count = transition_count + 1 + WHERE id = $1 AND lock_id = $2`, + [row.id, lockId] + ) + }, + + async heartbeat(): Promise { + if (released) { + throw new Error(`Session ${row.id} already released, cannot heartbeat`) + } + await pool.query( + `UPDATE agent_sessions + SET last_heartbeat = NOW() + WHERE id = $1 AND lock_id = $2`, + [row.id, lockId] + ) + }, + } + } + + isHealthy(): boolean { + return this.isConsuming && Date.now() - this.lastPollTime.getTime() < this.heartbeatTimeoutMs + } + + async stopConsuming(): Promise { + this.isConsuming = false + if (this.consumerLoopPromise) { + await this.consumerLoopPromise + this.consumerLoopPromise = null + } + } + + async disconnect(): Promise { + await this.stopConsuming() + await this.pool.end() + } +} diff --git a/packages/agent-core/tests/builtins.test.ts b/packages/agent-core/tests/builtins.test.ts new file mode 100644 index 000000000000..6e1ef990d5b4 --- /dev/null +++ b/packages/agent-core/tests/builtins.test.ts @@ -0,0 +1,25 @@ +import { getBuiltin, isBuiltinId, listBuiltins } from '../src' + +describe('builtins registry', () => { + it('lists all registered builtins', () => { + const ids = listBuiltins().map((b) => b.id) + expect(ids).toEqual(expect.arrayContaining(['posthog.events.capture', 'posthog.feature_flags.evaluate', 'http.fetch'])) + }) + + it('looks builtins up by id', () => { + const spec = getBuiltin('posthog.events.capture') + expect(spec).not.toBeNull() + expect(spec?.description).toMatch(/capture/i) + }) + + it('returns null for unknown ids', () => { + expect(getBuiltin('nope.does_not_exist')).toBeNull() + expect(isBuiltinId('nope.does_not_exist')).toBe(false) + }) + + it('validates args via the spec schema', () => { + const spec = getBuiltin('posthog.events.capture')! + expect(spec.args.safeParse({ event: 'signup', distinctId: 'user-1' }).success).toBe(true) + expect(spec.args.safeParse({ event: 'signup' }).success).toBe(false) + }) +}) diff --git a/packages/agent-core/tests/manifest.test.ts b/packages/agent-core/tests/manifest.test.ts new file mode 100644 index 000000000000..76d259c94e45 --- /dev/null +++ b/packages/agent-core/tests/manifest.test.ts @@ -0,0 +1,55 @@ +import { parseManifest } from '../src' + +describe('parseManifest', () => { + it('accepts a minimal valid manifest', () => { + const result = parseManifest({ + name: 'greet', + entrypoint: 'src/main.ts', + }) + expect(result.errors).toEqual([]) + expect(result.manifest).toMatchObject({ + name: 'greet', + entrypoint: 'src/main.ts', + tools: [], + triggers: [], + }) + }) + + it('rejects unknown built-in tool ids', () => { + const result = parseManifest({ + name: 'broken', + entrypoint: 'src/main.ts', + tools: [{ id: 'not.a.real.tool' }], + }) + expect(result.manifest).toBeNull() + expect(result.errors).toEqual([ + expect.objectContaining({ path: 'tools.0.id', message: expect.stringMatching(/unknown built-in tool id/) }), + ]) + }) + + it('reports zod validation errors with paths', () => { + const result = parseManifest({ + entrypoint: 'src/main.ts', + triggers: [{ kind: 'http', path: 'no-leading-slash' }], + }) + expect(result.manifest).toBeNull() + expect(result.errors.length).toBeGreaterThan(0) + const paths = result.errors.map((e) => e.path) + expect(paths).toEqual(expect.arrayContaining(['name', 'triggers.0.path'])) + }) + + it('accepts a manifest with builtins and triggers', () => { + const result = parseManifest({ + name: 'analytics-bot', + entrypoint: 'src/main.ts', + tools: [{ id: 'posthog.events.capture' }], + triggers: [ + { kind: 'http', path: '/hello' }, + { kind: 'cron', schedule: '0 * * * *' }, + ], + }) + expect(result.errors).toEqual([]) + expect(result.manifest?.tools).toHaveLength(1) + expect(result.manifest?.triggers).toHaveLength(2) + }) +}) diff --git a/packages/agent-core/tests/pubsub.test.ts b/packages/agent-core/tests/pubsub.test.ts new file mode 100644 index 000000000000..a96438b296ab --- /dev/null +++ b/packages/agent-core/tests/pubsub.test.ts @@ -0,0 +1,69 @@ +import { InMemorySessionBus, SessionEvent, SessionInputMessage } from '../src' + +describe('InMemorySessionBus', () => { + let bus: InMemorySessionBus + + beforeEach(() => { + bus = new InMemorySessionBus() + }) + + afterEach(async () => { + await bus.disconnect() + }) + + it('delivers events to a subscriber', async () => { + const received: SessionEvent[] = [] + const unsubscribe = await bus.subscribeEvents('s1', (event) => { + received.push(event) + }) + + await bus.publishEvent('s1', { type: 'turn_started', at: '2026-05-14T00:00:00Z' }) + await bus.publishEvent('s1', { type: 'turn_completed', at: '2026-05-14T00:00:01Z' }) + + expect(received).toEqual([ + { type: 'turn_started', at: '2026-05-14T00:00:00Z' }, + { type: 'turn_completed', at: '2026-05-14T00:00:01Z' }, + ]) + + await unsubscribe() + await bus.publishEvent('s1', { type: 'turn_started', at: '2026-05-14T00:00:02Z' }) + expect(received).toHaveLength(2) + }) + + it('separates events for different sessions', async () => { + const oneReceived: SessionEvent[] = [] + const twoReceived: SessionEvent[] = [] + await bus.subscribeEvents('s1', (event) => oneReceived.push(event)) + await bus.subscribeEvents('s2', (event) => twoReceived.push(event)) + + await bus.publishEvent('s1', { type: 'turn_started', at: 't' }) + await bus.publishEvent('s2', { type: 'turn_completed', at: 't' }) + + expect(oneReceived).toEqual([{ type: 'turn_started', at: 't' }]) + expect(twoReceived).toEqual([{ type: 'turn_completed', at: 't' }]) + }) + + it('routes input messages on a separate channel from events', async () => { + const events: SessionEvent[] = [] + const inputs: SessionInputMessage[] = [] + await bus.subscribeEvents('s1', (e) => events.push(e)) + await bus.subscribeInput('s1', (m) => inputs.push(m)) + + await bus.publishInput('s1', { type: 'user_message', at: 't', content: 'hello' }) + await bus.publishEvent('s1', { type: 'message', at: 't', role: 'assistant', content: 'hi' }) + + expect(events).toEqual([{ type: 'message', at: 't', role: 'assistant', content: 'hi' }]) + expect(inputs).toEqual([{ type: 'user_message', at: 't', content: 'hello' }]) + }) + + it('supports multiple subscribers per session', async () => { + const a: SessionEvent[] = [] + const b: SessionEvent[] = [] + await bus.subscribeEvents('s1', (e) => a.push(e)) + await bus.subscribeEvents('s1', (e) => b.push(e)) + + await bus.publishEvent('s1', { type: 'turn_started', at: 't' }) + expect(a).toHaveLength(1) + expect(b).toHaveLength(1) + }) +}) diff --git a/packages/agent-core/tests/queue.test.ts b/packages/agent-core/tests/queue.test.ts new file mode 100644 index 000000000000..a6b40347f6c7 --- /dev/null +++ b/packages/agent-core/tests/queue.test.ts @@ -0,0 +1,142 @@ +/** + * DB-gated queue integration tests. + * + * Skipped automatically if AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL is unset, so this + * suite is safe in environments without a Postgres available. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { Pool } from 'pg' +import { v7 as uuidv7 } from 'uuid' + +import { DequeuedSessionJob, SessionQueueJanitor, SessionQueueManager, SessionQueueWorker } from '../src' + +const DB_URL = process.env.AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL +const describeIfDb = DB_URL ? describe : describe.skip + +describeIfDb('agent-core queue (DB-gated)', () => { + let pool: Pool + let manager: SessionQueueManager + let worker: SessionQueueWorker + + beforeAll(async () => { + pool = new Pool({ connectionString: DB_URL }) + const schema = readFileSync(join(__dirname, '..', 'migrations', '0001_initial_schema.sql'), 'utf8') + await pool.query(`DROP TABLE IF EXISTS agent_sessions`) + await pool.query(`DROP TABLE IF EXISTS agent_runtime_migrations`) + await pool.query(`DROP TYPE IF EXISTS AgentSessionStatus`) + await pool.query(schema) + }) + + afterAll(async () => { + await pool.end() + }) + + beforeEach(async () => { + await pool.query('TRUNCATE agent_sessions') + manager = new SessionQueueManager({ + pool: { dbUrl: DB_URL! }, + depthLimit: 1_000, + depthCheckIntervalMs: 0, + }) + await manager.connect() + worker = new SessionQueueWorker({ + pool: { dbUrl: DB_URL! }, + queueName: 'test-queue', + batchMaxSize: 10, + pollDelayMs: 5, + includeEmptyBatches: false, + }) + }) + + afterEach(async () => { + await worker.disconnect() + await manager.disconnect() + }) + + async function consumeOnce(): Promise { + return new Promise((resolve) => { + worker.connect(async (batch) => { + if (batch.length > 0) { + resolve(batch) + await worker.stopConsuming() + } + }) + }) + } + + it('enqueue → dequeue → ack moves status through running → completed', async () => { + const id = await manager.createJob({ teamId: 42, queueName: 'test-queue' }) + const batch = await consumeOnce() + expect(batch).toHaveLength(1) + expect(batch[0].id).toBe(id) + expect(batch[0].teamId).toBe(42) + await batch[0].ack() + + const { rows } = await pool.query<{ status: string }>( + 'SELECT status FROM agent_sessions WHERE id = $1', + [id] + ) + expect(rows[0].status).toBe('completed') + }) + + it('reschedule round-trips state', async () => { + await manager.createJob({ + teamId: 1, + queueName: 'test-queue', + state: Buffer.from('hello'), + }) + const first = await consumeOnce() + await first[0].reschedule({ scheduledAt: new Date(), state: Buffer.from('world') }) + + worker = new SessionQueueWorker({ + pool: { dbUrl: DB_URL! }, + queueName: 'test-queue', + batchMaxSize: 10, + pollDelayMs: 5, + }) + const second = await consumeOnce() + expect(second[0].state?.toString('utf8')).toBe('world') + }) + + it('janitor resets stalled jobs and fails poison pills', async () => { + const id = await manager.createJob({ teamId: 1, queueName: 'test-queue' }) + // Force the job into 'running' with an ancient heartbeat to simulate a stall. + await pool.query( + `UPDATE agent_sessions + SET status = 'running', lock_id = $2, last_heartbeat = NOW() - INTERVAL '1 hour' + WHERE id = $1`, + [id, uuidv7()] + ) + + const janitor = new SessionQueueJanitor({ + pool: { dbUrl: DB_URL! }, + cleanupGraceMs: 0, + stallTimeoutMs: 1, + maxTouchCount: 1, + }) + try { + const first = await janitor.runOnce() + expect(first.stalled).toBe(1) + + // Stall again to push touch count past the threshold. + await pool.query( + `UPDATE agent_sessions + SET status = 'running', lock_id = $2, last_heartbeat = NOW() - INTERVAL '1 hour' + WHERE id = $1`, + [id, uuidv7()] + ) + const second = await janitor.runOnce() + expect(second.poisoned).toBe(1) + + const { rows } = await pool.query<{ status: string }>( + 'SELECT status FROM agent_sessions WHERE id = $1', + [id] + ) + expect(rows[0].status).toBe('failed') + } finally { + await janitor.stop() + } + }) +}) diff --git a/packages/agent-core/tsconfig.json b/packages/agent-core/tsconfig.json new file mode 100644 index 000000000000..2ec039f5f292 --- /dev/null +++ b/packages/agent-core/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist/", + "rootDir": "src/", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests", "bin"] +} diff --git a/packages/agent-core/tsconfig.test.json b/packages/agent-core/tsconfig.test.json new file mode 100644 index 000000000000..7d1d3ba5e17b --- /dev/null +++ b/packages/agent-core/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-ingress/.eslintrc.json b/packages/agent-ingress/.eslintrc.json new file mode 100644 index 000000000000..c772e7f9c683 --- /dev/null +++ b/packages/agent-ingress/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "root": true, + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["@anthropic-ai/*", "@modal/*", "modal", "claude-agent-sdk"], + "message": "agent-ingress must not import the Claude Agent SDK or Modal — those belong to agent-runner. Blast-radius rule." + }, + { + "group": ["**/nodejs/*", "../../../nodejs/*", "@posthog/nodejs"], + "message": "agent-ingress must not import from nodejs/ — cherry-pick into @posthog/agent-core instead." + } + ] + } + ] + } +} diff --git a/packages/agent-ingress/.gitignore b/packages/agent-ingress/.gitignore new file mode 100644 index 000000000000..83631f817f87 --- /dev/null +++ b/packages/agent-ingress/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/packages/agent-ingress/README.md b/packages/agent-ingress/README.md new file mode 100644 index 000000000000..f1c129ee6519 --- /dev/null +++ b/packages/agent-ingress/README.md @@ -0,0 +1,17 @@ +# @posthog/agent-ingress + +Public-facing HTTP process for the PostHog agent platform. + +- All `*.agents.posthog.com` traffic terminates here. +- Resolves the inbound host to `(application, revision)` via the Django internal API. +- Implements `/run`, `/listen/:id`, `/send/:id`, `/webhooks/:provider`, `/health`, `/status`. +- Writes an `agent_sessions` row + enqueues a session job in `@posthog/agent-core`'s queue. +- Streams session events out via SSE, backed by the session bus (Redis in prod, in-memory in tests). + +## Hard rules + +- **No Anthropic / Claude Agent SDK / Modal imports.** Enforced by `eslint-plugin-no-restricted-imports`. The whole point of splitting from the runner is to keep the blast radius small. +- **Never decrypts a secret.** Secret material only lives in `@posthog/agent-runner`. +- **No imports from `nodejs/`.** Cherry-pick by copy if you ever need something from it. + +See [`docs/internal/agent-platform.md`](../../docs/internal/agent-platform.md) for the full architecture. diff --git a/packages/agent-ingress/jest.config.js b/packages/agent-ingress/jest.config.js new file mode 100644 index 000000000000..cb031c2723fd --- /dev/null +++ b/packages/agent-ingress/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.ts'], + testTimeout: 15_000, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, +} diff --git a/packages/agent-ingress/package.json b/packages/agent-ingress/package.json new file mode 100644 index 000000000000..a89a3b4364df --- /dev/null +++ b/packages/agent-ingress/package.json @@ -0,0 +1,37 @@ +{ + "name": "@posthog/agent-ingress", + "version": "0.1.0", + "description": "HTTP ingress process for the PostHog agent platform: terminates *.agents.posthog.com traffic, enqueues sessions, streams events.", + "license": "MIT", + "author": "PostHog ", + "repository": "https://github.com/PostHog/posthog", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "pnpm clean && tsc -b", + "clean": "rm -rf dist", + "typescript:check": "tsc --noEmit -p .", + "test": "jest --runInBand --forceExit", + "start": "node dist/index.js", + "start:dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@posthog/agent-core": "workspace:*", + "lru-cache": "^11.0.0", + "ultimate-express": "^2.0.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/jest": "catalog:", + "@types/node": "catalog:", + "@types/supertest": "^6.0.2", + "jest": "catalog:", + "supertest": "^7.0.0", + "ts-jest": "^29.1.0", + "tsx": "^4.7.0", + "typescript": "catalog:" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/packages/agent-ingress/src/auth.ts b/packages/agent-ingress/src/auth.ts new file mode 100644 index 000000000000..bc0a9162903d --- /dev/null +++ b/packages/agent-ingress/src/auth.ts @@ -0,0 +1,67 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +import { ResolvedRevision } from '@posthog/agent-core' +import { Request } from 'ultimate-express' + +/** + * Per-app auth derived from the resolved revision. v1 supports three modes: + * + * - `public` — no auth. + * - `shared_secret` — `Authorization: Bearer ` checked against `revision.auth.token`. + * - `webhook_signature` — HMAC-SHA256 over the raw body using `revision.auth.secret`. + * + * Webhook signature checks use raw-body access; the server stores the raw buffer on + * `req.rawBody` via a json verifier in `server.ts`. + */ +export type AuthOutcome = { ok: true } | { ok: false; status: number; message: string } + +export interface AuthRequest extends Request { + rawBody?: Buffer +} + +export function authorize(req: AuthRequest, revision: ResolvedRevision): AuthOutcome { + const auth = revision.auth + switch (auth.mode) { + case 'public': + return { ok: true } + case 'shared_secret': + return authorizeSharedSecret(req, auth.token) + case 'webhook_signature': + return authorizeWebhookSignature(req, auth.provider, auth.secret) + } +} + +function authorizeSharedSecret(req: AuthRequest, token: string): AuthOutcome { + const header = req.header('authorization') ?? '' + const match = header.match(/^Bearer\s+(.+)$/i) + if (!match) { + return { ok: false, status: 401, message: 'missing bearer token' } + } + if (!constantTimeEqual(match[1], token)) { + return { ok: false, status: 401, message: 'invalid bearer token' } + } + return { ok: true } +} + +function authorizeWebhookSignature(req: AuthRequest, provider: string, secret: string): AuthOutcome { + if (!req.rawBody) { + return { ok: false, status: 400, message: 'raw body not captured; webhook signature cannot be checked' } + } + // v1: provider-agnostic HMAC-SHA256 over the raw body, hex digest, supplied via x-signature. + // Real provider-specific schemes (Stripe, Slack, GitHub) will land alongside the trigger work. + const header = req.header('x-signature') ?? '' + const expected = createHmac('sha256', secret).update(req.rawBody).digest('hex') + if (!constantTimeEqual(header, expected)) { + return { ok: false, status: 401, message: `invalid ${provider} webhook signature` } + } + return { ok: true } +} + +function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8') + const bb = Buffer.from(b, 'utf8') + if (ab.length !== bb.length) { + return false + } + return timingSafeEqual(ab, bb) +} diff --git a/packages/agent-ingress/src/config.ts b/packages/agent-ingress/src/config.ts new file mode 100644 index 000000000000..09d17e748ed4 --- /dev/null +++ b/packages/agent-ingress/src/config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +const ConfigSchema = z.object({ + port: z.coerce.number().int().min(1).max(65_535).default(3030), + queueDbUrl: z.string().min(1), + /** Base URL for the Django internal API (e.g. http://app:8000). */ + internalApiBaseUrl: z.string().min(1), + /** Shared signing key for internal-api calls. */ + internalApiSharedKey: z.string().optional(), + /** ioredis URL. When unset, we fall back to the in-memory bus (single-process only). */ + redisUrl: z.string().optional(), + /** Resolver cache TTL for `(domain → revision)` entries. */ + resolverTtlMs: z.coerce.number().int().min(0).default(5_000), + /** Suffix for application subdomains, e.g. ".agents.posthog.com". */ + domainSuffix: z.string().default('.agents.posthog.com'), +}) + +export type IngressConfig = z.infer + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): IngressConfig { + return ConfigSchema.parse({ + port: env.PORT, + queueDbUrl: env.AGENT_RUNTIME_QUEUE_DATABASE_URL, + internalApiBaseUrl: env.INTERNAL_API_BASE_URL, + internalApiSharedKey: env.INTERNAL_API_SHARED_KEY, + redisUrl: env.REDIS_URL, + resolverTtlMs: env.RESOLVER_TTL_MS, + domainSuffix: env.DOMAIN_SUFFIX, + }) +} diff --git a/packages/agent-ingress/src/index.ts b/packages/agent-ingress/src/index.ts new file mode 100644 index 000000000000..3148b276dc4a --- /dev/null +++ b/packages/agent-ingress/src/index.ts @@ -0,0 +1,56 @@ +import { + InMemorySessionBus, + InternalApiClient, + RedisSessionBus, + SessionBus, + SessionQueueManager, + logger, +} from '@posthog/agent-core' + +import { loadConfig } from './config' +import { RevisionResolver } from './resolver' +import { buildServer } from './server' + +async function main(): Promise { + const config = loadConfig() + + const queue = new SessionQueueManager({ pool: { dbUrl: config.queueDbUrl } }) + await queue.connect() + + const apiClient = new InternalApiClient({ + baseUrl: config.internalApiBaseUrl, + sharedKey: config.internalApiSharedKey, + }) + + const resolver = new RevisionResolver({ client: apiClient, ttlMs: config.resolverTtlMs }) + + const bus: SessionBus = config.redisUrl + ? new RedisSessionBus({ url: config.redisUrl }) + : new InMemorySessionBus() + + if (!config.redisUrl) { + logger.warn('REDIS_URL not set; using in-memory bus (single-process only — not safe for production)') + } + + const app = buildServer({ queue, bus, resolver, domainSuffix: config.domainSuffix }) + + const server = app.listen(config.port, () => { + logger.info('agent-ingress listening', { port: config.port }) + }) + + const shutdown = async (signal: string): Promise => { + logger.info('agent-ingress shutting down', { signal }) + server.close() + await bus.disconnect() + await queue.disconnect() + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) +} + +main().catch((err) => { + logger.error('agent-ingress fatal', { error: String(err) }) + process.exit(1) +}) diff --git a/packages/agent-ingress/src/resolver.ts b/packages/agent-ingress/src/resolver.ts new file mode 100644 index 000000000000..e685db2ad441 --- /dev/null +++ b/packages/agent-ingress/src/resolver.ts @@ -0,0 +1,64 @@ +import { InternalApiClient, ResolvedRevision, logger } from '@posthog/agent-core' +import { LRUCache } from 'lru-cache' + +export interface ResolverOptions { + client: InternalApiClient + ttlMs: number + maxEntries?: number +} + +/** + * Resolves an inbound host (or explicit application id) to the live `(application, revision)`. + * + * Backed by an LRU keyed on the resolution input; entries expire after `ttlMs` so promotions + * propagate without needing an explicit invalidation channel. The Django side will eventually + * gain an admin invalidation endpoint, but TTL is enough for v1. + */ +export class RevisionResolver { + private readonly cache: LRUCache + + constructor(private readonly options: ResolverOptions) { + this.cache = new LRUCache({ + max: options.maxEntries ?? 5_000, + ttl: options.ttlMs, + }) + } + + async resolveDomain(domain: string): Promise { + return this.lookup(`domain:${domain}`, () => this.options.client.resolve({ domain })) + } + + async resolveApplication(applicationId: string): Promise { + return this.lookup(`app:${applicationId}`, () => this.options.client.resolve({ applicationId })) + } + + /** Manually evict a cache entry, used on promotion pings from Django. */ + invalidate(key: { domain?: string; applicationId?: string }): void { + if (key.domain) { + this.cache.delete(`domain:${key.domain}`) + } + if (key.applicationId) { + this.cache.delete(`app:${key.applicationId}`) + } + } + + private async lookup( + key: string, + fetcher: () => Promise + ): Promise { + const cached = this.cache.get(key) + if (cached) { + return cached + } + try { + const resolved = await fetcher() + if (resolved) { + this.cache.set(key, resolved) + } + return resolved + } catch (err) { + logger.error('RevisionResolver lookup failed', { key, error: String(err) }) + throw err + } + } +} diff --git a/packages/agent-ingress/src/routes/health.ts b/packages/agent-ingress/src/routes/health.ts new file mode 100644 index 000000000000..bbc0fa750ab5 --- /dev/null +++ b/packages/agent-ingress/src/routes/health.ts @@ -0,0 +1,7 @@ +import { Express } from 'ultimate-express' + +export function registerHealth(app: Express): void { + app.get('/health', (_req, res) => { + res.json({ ok: true }) + }) +} diff --git a/packages/agent-ingress/src/routes/host.ts b/packages/agent-ingress/src/routes/host.ts new file mode 100644 index 000000000000..51d3dc21b84c --- /dev/null +++ b/packages/agent-ingress/src/routes/host.ts @@ -0,0 +1,18 @@ +import { Request } from 'ultimate-express' + +/** + * Pull the application host from the request, validating it against the configured + * `.agents.posthog.com`-shaped suffix. Returns the full hostname (subdomain + suffix), + * since the internal resolve endpoint matches on the full domain. + */ +export function extractHost(req: Request, domainSuffix: string): string | null { + // Honor an explicit override header for clients that proxy us, but fall back to Host. + const raw = (req.header('x-original-host') ?? req.hostname ?? '').toLowerCase().trim() + if (!raw) { + return null + } + if (!raw.endsWith(domainSuffix.toLowerCase())) { + return null + } + return raw +} diff --git a/packages/agent-ingress/src/routes/listen.ts b/packages/agent-ingress/src/routes/listen.ts new file mode 100644 index 000000000000..54e82a10a72a --- /dev/null +++ b/packages/agent-ingress/src/routes/listen.ts @@ -0,0 +1,50 @@ +import { SessionEvent, logger } from '@posthog/agent-core' +import { Express, Request, Response } from 'ultimate-express' + +import { ServerDeps } from '../types' + +/** + * SSE stream of session events. Subscribes to the bus channel for the given session id + * and writes each event as a Server-Sent Events frame. + * + * The bus is best-effort; durable session state lives in the queue row + final state blob. + */ +export function registerListen(app: Express, deps: ServerDeps): void { + app.get('/listen/:id', async (req: Request, res: Response) => { + const sessionId = req.params.id + if (!sessionId) { + return res.status(400).json({ error: 'session id required' }) + } + + res.setHeader('content-type', 'text/event-stream') + res.setHeader('cache-control', 'no-cache') + res.setHeader('connection', 'keep-alive') + res.flushHeaders?.() + res.write('retry: 5000\n\n') + + const sendEvent = (event: SessionEvent): void => { + res.write(`event: ${event.type}\n`) + res.write(`data: ${JSON.stringify(event)}\n\n`) + } + + const unsubscribe = await deps.bus.subscribeEvents(sessionId, sendEvent) + + // Heartbeat so intermediaries (proxies, CDNs) don't time out the connection. + const heartbeat = setInterval(() => { + res.write(': heartbeat\n\n') + }, 15_000) + + const cleanup = async (): Promise => { + clearInterval(heartbeat) + try { + await unsubscribe() + } catch (err) { + logger.error('listen cleanup error', { sessionId, error: String(err) }) + } + } + + req.on('close', () => { + void cleanup() + }) + }) +} diff --git a/packages/agent-ingress/src/routes/run.ts b/packages/agent-ingress/src/routes/run.ts new file mode 100644 index 000000000000..ff5c2b728f96 --- /dev/null +++ b/packages/agent-ingress/src/routes/run.ts @@ -0,0 +1,67 @@ +import { logger } from '@posthog/agent-core' +import { Express, Request, Response } from 'ultimate-express' +import { z } from 'zod' + +import { authorize, AuthRequest } from '../auth' +import { ServerDeps } from '../types' +import { extractHost } from './host' + +const RunBodySchema = z.object({ + /** Optional explicit application id; falls back to host-based resolution. */ + applicationId: z.string().uuid().optional(), + input: z.record(z.string(), z.unknown()).optional(), + triggerType: z.string().default('http'), + triggerPayload: z.record(z.string(), z.unknown()).optional(), +}) + +export function registerRun(app: Express, deps: ServerDeps): void { + app.post('/run', async (req: Request, res: Response) => { + const parsed = RunBodySchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ error: 'invalid body', issues: parsed.error.issues }) + } + const body = parsed.data + + let revision + try { + if (body.applicationId) { + revision = await deps.resolver.resolveApplication(body.applicationId) + } else { + const host = extractHost(req, deps.domainSuffix) + if (!host) { + return res.status(400).json({ error: `host does not match ${deps.domainSuffix}` }) + } + revision = await deps.resolver.resolveDomain(host) + } + } catch (err) { + logger.error('resolve failed in /run', { error: String(err) }) + return res.status(502).json({ error: 'resolve failed' }) + } + + if (!revision) { + return res.status(404).json({ error: 'application not found' }) + } + if (revision.revisionState !== 'ready') { + return res.status(409).json({ error: `revision not ready (state=${revision.revisionState})` }) + } + + const auth = authorize(req as AuthRequest, revision) + if (!auth.ok) { + return res.status(auth.status).json({ error: auth.message }) + } + + try { + const sessionId = await deps.queue.createJob({ + teamId: revision.teamId, + applicationId: revision.applicationId, + revisionId: revision.revisionId, + queueName: 'default', + state: body.input ? Buffer.from(JSON.stringify(body.input)) : null, + }) + return res.status(202).json({ sessionId }) + } catch (err) { + logger.error('enqueue failed in /run', { error: String(err) }) + return res.status(503).json({ error: 'enqueue failed' }) + } + }) +} diff --git a/packages/agent-ingress/src/routes/send.ts b/packages/agent-ingress/src/routes/send.ts new file mode 100644 index 000000000000..f184112395f7 --- /dev/null +++ b/packages/agent-ingress/src/routes/send.ts @@ -0,0 +1,38 @@ +import { logger } from '@posthog/agent-core' +import { Express, Request, Response } from 'ultimate-express' +import { z } from 'zod' + +import { ServerDeps } from '../types' + +const SendBodySchema = z.object({ + content: z.string().min(1), +}) + +/** + * Publish a user-input message for a session. The runner is subscribed to the + * session's input channel and picks it up at the next yield. + */ +export function registerSend(app: Express, deps: ServerDeps): void { + app.post('/send/:id', async (req: Request, res: Response) => { + const sessionId = req.params.id + if (!sessionId) { + return res.status(400).json({ error: 'session id required' }) + } + const parsed = SendBodySchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ error: 'invalid body', issues: parsed.error.issues }) + } + + try { + await deps.bus.publishInput(sessionId, { + type: 'user_message', + at: new Date().toISOString(), + content: parsed.data.content, + }) + return res.status(202).json({ ok: true }) + } catch (err) { + logger.error('send failed', { sessionId, error: String(err) }) + return res.status(503).json({ error: 'send failed' }) + } + }) +} diff --git a/packages/agent-ingress/src/routes/status.ts b/packages/agent-ingress/src/routes/status.ts new file mode 100644 index 000000000000..4ff7176fc32e --- /dev/null +++ b/packages/agent-ingress/src/routes/status.ts @@ -0,0 +1,11 @@ +import { Express } from 'ultimate-express' + +export function registerStatus(app: Express): void { + app.get('/status', (_req, res) => { + res.json({ + service: 'agent-ingress', + version: process.env.npm_package_version ?? 'dev', + uptimeSeconds: Math.round(process.uptime()), + }) + }) +} diff --git a/packages/agent-ingress/src/routes/webhooks.ts b/packages/agent-ingress/src/routes/webhooks.ts new file mode 100644 index 000000000000..f85589233f34 --- /dev/null +++ b/packages/agent-ingress/src/routes/webhooks.ts @@ -0,0 +1,56 @@ +import { logger } from '@posthog/agent-core' +import { Express, Request, Response } from 'ultimate-express' + +import { authorize, AuthRequest } from '../auth' +import { ServerDeps } from '../types' +import { extractHost } from './host' + +/** + * Receive a provider webhook and turn it into a session. Auth is governed by the + * resolved revision's `webhook_signature` mode (HMAC over raw body). + */ +export function registerWebhooks(app: Express, deps: ServerDeps): void { + app.post('/webhooks/:provider', async (req: Request, res: Response) => { + const provider = req.params.provider + if (!provider) { + return res.status(400).json({ error: 'provider required' }) + } + + const host = extractHost(req, deps.domainSuffix) + if (!host) { + return res.status(400).json({ error: `host does not match ${deps.domainSuffix}` }) + } + let revision + try { + revision = await deps.resolver.resolveDomain(host) + } catch (err) { + logger.error('resolve failed in /webhooks', { error: String(err) }) + return res.status(502).json({ error: 'resolve failed' }) + } + if (!revision) { + return res.status(404).json({ error: 'application not found' }) + } + if (revision.revisionState !== 'ready') { + return res.status(409).json({ error: `revision not ready (state=${revision.revisionState})` }) + } + + const auth = authorize(req as AuthRequest, revision) + if (!auth.ok) { + return res.status(auth.status).json({ error: auth.message }) + } + + try { + const sessionId = await deps.queue.createJob({ + teamId: revision.teamId, + applicationId: revision.applicationId, + revisionId: revision.revisionId, + queueName: 'default', + state: Buffer.from(JSON.stringify({ provider, body: req.body })), + }) + return res.status(202).json({ sessionId }) + } catch (err) { + logger.error('enqueue failed in /webhooks', { provider, error: String(err) }) + return res.status(503).json({ error: 'enqueue failed' }) + } + }) +} diff --git a/packages/agent-ingress/src/server.ts b/packages/agent-ingress/src/server.ts new file mode 100644 index 000000000000..7f6056f43a0b --- /dev/null +++ b/packages/agent-ingress/src/server.ts @@ -0,0 +1,47 @@ +import { collectDefaults, logger, metricsContentType, metricsText } from '@posthog/agent-core' +import express, { Express } from 'ultimate-express' + +import { registerHealth } from './routes/health' +import { registerListen } from './routes/listen' +import { registerRun } from './routes/run' +import { registerSend } from './routes/send' +import { registerStatus } from './routes/status' +import { registerWebhooks } from './routes/webhooks' +import { ServerDeps } from './types' + +export type { ServerDeps } from './types' + +export function buildServer(deps: ServerDeps): Express { + collectDefaults() + + const app = express() + + app.use( + express.json({ + limit: '512kb', + // Stash the raw body so webhook signature checks have access to the exact bytes. + verify: (req, _res, buf) => { + ;(req as { rawBody?: Buffer }).rawBody = buf + }, + }) + ) + + app.use((req, _res, next) => { + logger.debug('ingress request', { method: req.method, path: req.path }) + next() + }) + + registerHealth(app) + registerStatus(app) + registerRun(app, deps) + registerListen(app, deps) + registerSend(app, deps) + registerWebhooks(app, deps) + + app.get('/metrics', async (_req, res) => { + res.set('content-type', metricsContentType()) + res.send(await metricsText()) + }) + + return app +} diff --git a/packages/agent-ingress/src/types.ts b/packages/agent-ingress/src/types.ts new file mode 100644 index 000000000000..4be452224c57 --- /dev/null +++ b/packages/agent-ingress/src/types.ts @@ -0,0 +1,10 @@ +import { SessionBus, SessionQueueManager } from '@posthog/agent-core' + +import { RevisionResolver } from './resolver' + +export interface ServerDeps { + queue: SessionQueueManager + bus: SessionBus + resolver: RevisionResolver + domainSuffix: string +} diff --git a/packages/agent-ingress/tests/server.test.ts b/packages/agent-ingress/tests/server.test.ts new file mode 100644 index 000000000000..35672497b9a0 --- /dev/null +++ b/packages/agent-ingress/tests/server.test.ts @@ -0,0 +1,195 @@ +import { InMemorySessionBus, ResolvedRevision, SessionInputMessage } from '@posthog/agent-core' +import supertest from 'supertest' +import type { Express } from 'ultimate-express' + +import { RevisionResolver } from '../src/resolver' +import { buildServer, ServerDeps } from '../src/server' + +class FakeQueue { + public created: Array> = [] + private idCounter = 0 + async createJob(input: Record): Promise { + this.created.push(input) + this.idCounter += 1 + return `session-${this.idCounter}` + } +} + +function makeRevision(overrides: Partial = {}): ResolvedRevision { + return { + applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', + applicationSlug: 'analytics-bot', + teamId: 7, + revisionId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a02', + revisionState: 'ready', + bundleS3Key: 's3://bundles/abc', + bundleSha256: 'abcd', + topLevelConfig: {}, + parsedManifest: null, + auth: { mode: 'public' }, + ...overrides, + } +} + +function makeResolver(revision: ResolvedRevision | null): RevisionResolver { + return { + resolveDomain: async () => revision, + resolveApplication: async () => revision, + invalidate: () => undefined, + } as unknown as RevisionResolver +} + +interface TestHarness { + queue: FakeQueue + bus: InMemorySessionBus + app: Express + teardown: () => Promise +} + +/** + * Mirrors the pattern in nodejs/src/api/router.test.ts: bind the ultimate-express app to + * an ephemeral port first, then hand the app to supertest (which uses `app.address()`). + */ +async function startServer(overrides: Partial = {}): Promise { + const queue = new FakeQueue() + const bus = new InMemorySessionBus() + const resolver = makeResolver(makeRevision()) + const deps: ServerDeps = { + queue: queue as unknown as ServerDeps['queue'], + bus, + resolver, + domainSuffix: '.agents.posthog.com', + ...overrides, + } + const app = buildServer(deps) + await new Promise((resolve, reject) => { + try { + app.listen(0, () => resolve()) + } catch (err) { + reject(err) + } + }) + return { + queue, + bus, + app, + teardown: async () => { + await bus.disconnect() + }, + } +} + +describe('agent-ingress server', () => { + let harness: TestHarness + + afterEach(async () => { + await harness.teardown() + }) + + it('GET /health returns ok', async () => { + harness = await startServer() + const res = await supertest(harness.app).get('/health') + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true }) + }) + + it('GET /status returns service identity', async () => { + harness = await startServer() + const res = await supertest(harness.app).get('/status') + expect(res.status).toBe(200) + expect(res.body.service).toBe('agent-ingress') + expect(typeof res.body.uptimeSeconds).toBe('number') + }) + + it('POST /run enqueues a session when revision is ready', async () => { + harness = await startServer() + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'analytics-bot.agents.posthog.com') + .send({ input: { foo: 'bar' } }) + expect(res.status).toBe(202) + expect(res.body.sessionId).toBe('session-1') + expect(harness.queue.created).toHaveLength(1) + expect(harness.queue.created[0]).toMatchObject({ + teamId: 7, + applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', + revisionId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a02', + queueName: 'default', + }) + }) + + it('POST /run rejects non-ready revisions', async () => { + const resolver = makeResolver(makeRevision({ revisionState: 'uploaded' })) + harness = await startServer({ resolver }) + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'analytics-bot.agents.posthog.com') + .send({}) + expect(res.status).toBe(409) + }) + + it('POST /run 404s when no application matches the host', async () => { + const resolver = makeResolver(null) + harness = await startServer({ resolver }) + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'unknown.agents.posthog.com') + .send({}) + expect(res.status).toBe(404) + }) + + it('POST /run rejects hosts that do not match the suffix', async () => { + harness = await startServer() + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'evil.example.com') + .send({}) + expect(res.status).toBe(400) + }) + + it('POST /run rejects shared_secret requests with no bearer token', async () => { + const resolver = makeResolver(makeRevision({ auth: { mode: 'shared_secret', token: 'sekret' } })) + harness = await startServer({ resolver }) + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'analytics-bot.agents.posthog.com') + .send({}) + expect(res.status).toBe(401) + }) + + it('POST /run accepts shared_secret requests with the right bearer token', async () => { + const resolver = makeResolver(makeRevision({ auth: { mode: 'shared_secret', token: 'sekret' } })) + harness = await startServer({ resolver }) + const res = await supertest(harness.app) + .post('/run') + .set('x-original-host', 'analytics-bot.agents.posthog.com') + .set('authorization', 'Bearer sekret') + .send({}) + expect(res.status).toBe(202) + }) + + it('POST /send/:id publishes to the bus input channel', async () => { + harness = await startServer() + const received: SessionInputMessage[] = [] + await harness.bus.subscribeInput('abc', (m) => received.push(m)) + + const res = await supertest(harness.app).post('/send/abc').send({ content: 'hi' }) + expect(res.status).toBe(202) + expect(received).toEqual([expect.objectContaining({ type: 'user_message', content: 'hi' })]) + }) + + it('POST /send/:id rejects empty content', async () => { + harness = await startServer() + const res = await supertest(harness.app).post('/send/abc').send({ content: '' }) + expect(res.status).toBe(400) + }) + + it('POST /run with explicit applicationId bypasses host', async () => { + harness = await startServer() + const res = await supertest(harness.app) + .post('/run') + .send({ applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01' }) + expect(res.status).toBe(202) + expect(harness.queue.created).toHaveLength(1) + }) +}) diff --git a/packages/agent-ingress/tsconfig.json b/packages/agent-ingress/tsconfig.json new file mode 100644 index 000000000000..0615be011bb6 --- /dev/null +++ b/packages/agent-ingress/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist/", + "rootDir": "src/", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/agent-ingress/tsconfig.test.json b/packages/agent-ingress/tsconfig.test.json new file mode 100644 index 000000000000..7d1d3ba5e17b --- /dev/null +++ b/packages/agent-ingress/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-runner/.gitignore b/packages/agent-runner/.gitignore new file mode 100644 index 000000000000..83631f817f87 --- /dev/null +++ b/packages/agent-runner/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/packages/agent-runner/README.md b/packages/agent-runner/README.md new file mode 100644 index 000000000000..864185a3fc41 --- /dev/null +++ b/packages/agent-runner/README.md @@ -0,0 +1,25 @@ +# @posthog/agent-runner + +Session executor process for the PostHog agent platform. + +- Dequeues session jobs from the `@posthog/agent-core` queue. +- Restores Claude Agent SDK state from the job's `state` payload. +- Runs one "turn" — until the next tool boundary or completion. +- Two outcomes per turn: + - **Completion** — ack the job, publish completion to the bus. + - **Suspension** — `reschedule({ scheduledAt, state })`. Heartbeats keep the lock alive while a turn is in flight. + +## Tool execution (v1, native only) + +v1 executes every tool in-process. There is no Modal sandbox, no remote dispatch. The tool registry combines: + +- **Meta tools** — `complete`, `wait_for_input` (defined in `src/tools/meta.ts`). +- **Built-in tools** — backed by `@posthog/agent-core`'s `builtins` registry. + +Custom tools defined inside an agent bundle are out of scope for v1 and will require the sandbox manager when they land. Until then, only built-in ids declared in agent-core are runnable. + +See [`docs/internal/agent-platform.md`](../../docs/internal/agent-platform.md) for the full architecture. + +## Hard rule + +- **No imports from `nodejs/`.** Cherry-pick by copy if you ever need something from it. diff --git a/packages/agent-runner/jest.config.js b/packages/agent-runner/jest.config.js new file mode 100644 index 000000000000..cb031c2723fd --- /dev/null +++ b/packages/agent-runner/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.ts'], + testTimeout: 15_000, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, +} diff --git a/packages/agent-runner/package.json b/packages/agent-runner/package.json new file mode 100644 index 000000000000..ade543925b33 --- /dev/null +++ b/packages/agent-runner/package.json @@ -0,0 +1,35 @@ +{ + "name": "@posthog/agent-runner", + "version": "0.1.0", + "description": "Session executor process for the PostHog agent platform: consumes the queue, runs Claude Agent SDK turns, executes tools natively.", + "license": "MIT", + "author": "PostHog ", + "repository": "https://github.com/PostHog/posthog", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "pnpm clean && tsc -b", + "clean": "rm -rf dist", + "typescript:check": "tsc --noEmit -p .", + "test": "jest --runInBand --forceExit", + "start": "node dist/index.js", + "start:dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@posthog/agent-core": "workspace:*", + "luxon": "^3.4.4", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/jest": "catalog:", + "@types/luxon": "^3.4.2", + "@types/node": "catalog:", + "jest": "catalog:", + "ts-jest": "^29.1.0", + "tsx": "^4.7.0", + "typescript": "catalog:" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/packages/agent-runner/src/config.ts b/packages/agent-runner/src/config.ts new file mode 100644 index 000000000000..a6b0a22e5a51 --- /dev/null +++ b/packages/agent-runner/src/config.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +const ConfigSchema = z.object({ + queueDbUrl: z.string().min(1), + queueName: z.string().default('default'), + /** Base URL for the Django internal API (e.g. http://app:8000). */ + internalApiBaseUrl: z.string().min(1), + internalApiSharedKey: z.string().optional(), + redisUrl: z.string().optional(), + /** Anthropic API key — only the runner reads secrets. */ + anthropicApiKey: z.string().optional(), +}) + +export type RunnerConfig = z.infer + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): RunnerConfig { + return ConfigSchema.parse({ + queueDbUrl: env.AGENT_RUNTIME_QUEUE_DATABASE_URL, + queueName: env.AGENT_RUNNER_QUEUE_NAME, + internalApiBaseUrl: env.INTERNAL_API_BASE_URL, + internalApiSharedKey: env.INTERNAL_API_SHARED_KEY, + redisUrl: env.REDIS_URL, + anthropicApiKey: env.ANTHROPIC_API_KEY, + }) +} diff --git a/packages/agent-runner/src/executor-stub.ts b/packages/agent-runner/src/executor-stub.ts new file mode 100644 index 000000000000..40caa7493e18 --- /dev/null +++ b/packages/agent-runner/src/executor-stub.ts @@ -0,0 +1,15 @@ +import { ExecutorTurnOutput, SessionExecutor } from './executor' + +/** + * Placeholder until the Claude Agent SDK integration lands. Defined here (not in tests) + * so a stripped-down runner process can still boot end-to-end before the real executor + * is wired in. Treats every turn as "complete with an empty output." + */ +export class NotImplementedExecutor implements SessionExecutor { + async runTurn(): Promise { + return { + kind: 'failed', + error: 'Claude Agent SDK executor is not implemented yet. Wire one up in src/index.ts.', + } + } +} diff --git a/packages/agent-runner/src/executor.ts b/packages/agent-runner/src/executor.ts new file mode 100644 index 000000000000..4d2ebe9a21eb --- /dev/null +++ b/packages/agent-runner/src/executor.ts @@ -0,0 +1,48 @@ +import { ToolCall } from './tools/types' +import { SessionMessage, SessionState } from './state' + +/** + * The contract the worker drives a single turn through. Concrete implementations wrap + * the Claude Agent SDK (real) or a deterministic script (tests). Keeping this as an + * interface means the worker doesn't import the SDK directly — and the runtime can + * swap executors per revision in the future (Anthropic / Bedrock / local). + */ +export interface SessionExecutor { + /** + * Run a single turn against the current state. The returned outcome tells the worker + * whether to ack the job, reschedule it (after a tool call or an explicit suspension), + * or fail it. + */ + runTurn(input: ExecutorTurnInput): Promise +} + +export interface ExecutorTurnInput { + readonly state: SessionState + /** Latest /send/:id messages flushed into state.pendingInputs. */ + readonly newInputs: readonly { content: string; at: string }[] +} + +export type ExecutorTurnOutput = + | { + /** The SDK requested a tool call. The worker executes it, appends the result, and reschedules for another turn. */ + kind: 'tool_call' + message: SessionMessage + call: ToolCall & { id: string } + } + | { + /** Run finished cleanly; the worker acks the job and publishes the output. */ + kind: 'completed' + message: SessionMessage + output: unknown + } + | { + /** SDK voluntarily yielded to await /send/:id input. The worker reschedules and parks the lock. */ + kind: 'awaiting_input' + message: SessionMessage + reason: string | null + } + | { + /** Hard failure inside the turn. The worker fails the job. */ + kind: 'failed' + error: string + } diff --git a/packages/agent-runner/src/index.ts b/packages/agent-runner/src/index.ts new file mode 100644 index 000000000000..8b46c50a37a7 --- /dev/null +++ b/packages/agent-runner/src/index.ts @@ -0,0 +1,63 @@ +import { + InMemorySessionBus, + InternalApiClient, + RedisSessionBus, + SessionBus, + logger, +} from '@posthog/agent-core' + +import { loadConfig } from './config' +import { NotImplementedExecutor } from './executor-stub' +import { RunnerWorker } from './worker' + +async function main(): Promise { + const config = loadConfig() + + const apiClient = new InternalApiClient({ + baseUrl: config.internalApiBaseUrl, + sharedKey: config.internalApiSharedKey, + }) + + const bus: SessionBus = config.redisUrl + ? new RedisSessionBus({ url: config.redisUrl }) + : new InMemorySessionBus() + + if (!config.redisUrl) { + logger.warn('REDIS_URL not set; using in-memory bus (single-process only — not safe for production)') + } + + const worker = new RunnerWorker({ + pool: { dbUrl: config.queueDbUrl }, + queueName: config.queueName, + executor: new NotImplementedExecutor(), + bus, + loadSecrets: async (applicationId) => { + if (!applicationId) { + return {} + } + // Real wiring: ask Django for the secrets declared on the manifest. For now, + // the placeholder executor never reaches the tool dispatch path, so an empty + // map is fine. + const { secrets } = await apiClient.decryptSecrets(applicationId, []) + return secrets + }, + }) + + await worker.start() + logger.info('agent-runner started', { queueName: config.queueName }) + + const shutdown = async (signal: string): Promise => { + logger.info('agent-runner shutting down', { signal }) + await worker.stop() + await bus.disconnect() + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) +} + +main().catch((err) => { + logger.error('agent-runner fatal', { error: String(err) }) + process.exit(1) +}) diff --git a/packages/agent-runner/src/state.ts b/packages/agent-runner/src/state.ts new file mode 100644 index 000000000000..0f1beceb8353 --- /dev/null +++ b/packages/agent-runner/src/state.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' + +/** + * Conversation state serialized between turns. Stored as JSON in the queue's BYTEA column. + * + * Kept deliberately minimal — the Claude Agent SDK owns the actual conversation log; this + * envelope just shuttles the SDK state plus runner-level bookkeeping (input messages + * captured between turns, pending tool calls awaiting a yield). + */ +export const SessionMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), + at: z.string().datetime().optional(), +}) + +export const PendingInputSchema = z.object({ + at: z.string().datetime(), + content: z.string(), +}) + +export const SessionStateSchema = z.object({ + /** Conversation history fed back into the SDK on the next turn. */ + messages: z.array(SessionMessageSchema).default([]), + /** Messages that arrived via /send/:id between turns. */ + pendingInputs: z.array(PendingInputSchema).default([]), + /** Bundled with the initial input payload when /run created the session. */ + initialInput: z.record(z.string(), z.unknown()).nullable().default(null), + /** Number of turns the runner has already executed. */ + turnCount: z.number().int().min(0).default(0), +}) + +export type SessionMessage = z.infer +export type PendingInput = z.infer +export type SessionState = z.infer + +export function emptySessionState(initialInput: Record | null = null): SessionState { + return SessionStateSchema.parse({ initialInput }) +} + +export function serializeState(state: SessionState): Buffer { + return Buffer.from(JSON.stringify(state), 'utf8') +} + +export function deserializeState(buffer: Buffer | null): SessionState { + if (!buffer || buffer.byteLength === 0) { + return emptySessionState() + } + const raw = JSON.parse(buffer.toString('utf8')) as unknown + return SessionStateSchema.parse(raw) +} diff --git a/packages/agent-runner/src/tools/builtins.ts b/packages/agent-runner/src/tools/builtins.ts new file mode 100644 index 000000000000..9685a89b7cd0 --- /dev/null +++ b/packages/agent-runner/src/tools/builtins.ts @@ -0,0 +1,85 @@ +import { getBuiltin, isBuiltinId } from '@posthog/agent-core' + +import { ToolCall, ToolContext, ToolHandler, ToolResult } from './types' + +/** + * Concrete native implementations for the built-in ids declared in agent-core's registry. + * Kept separate from the registry itself so the runner owns "how it runs" while the + * registry owns "what's allowed". Both the runner and the future validator can ask the + * registry whether an id is known; only the runner knows how to execute it. + */ +type BuiltinExecutor = (parsedArgs: unknown, ctx: ToolContext) => Promise + +const EXECUTORS: Record = { + 'posthog.events.capture': async (args, ctx) => { + // v1 stub: log the captured event. The real implementation will go through + // posthog-node once we wire credentials through the secrets path. + return { + captured: true, + teamId: ctx.teamId, + event: args, + } + }, + 'posthog.feature_flags.evaluate': async () => { + return { enabled: false, variant: null } + }, + 'http.fetch': async (args) => { + const { url, method, headers, body, timeoutMs } = args as { + url: string + method: string + headers?: Record + body?: string + timeoutMs: number + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { + method, + headers, + body, + signal: controller.signal, + }) + const text = await response.text() + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: text, + } + } finally { + clearTimeout(timer) + } + }, +} + +class BuiltinHandler implements ToolHandler { + constructor(public readonly id: string) {} + + async invoke(call: ToolCall, ctx: ToolContext): Promise { + const spec = getBuiltin(this.id) + if (!spec) { + return { ok: false, error: `builtin ${this.id} is not declared in the registry` } + } + const parsed = spec.args.safeParse(call.args) + if (!parsed.success) { + return { ok: false, error: `builtin ${this.id} args invalid: ${parsed.error.message}` } + } + const executor = EXECUTORS[this.id] + if (!executor) { + return { ok: false, error: `builtin ${this.id} is declared but not implemented in the runner` } + } + try { + const value = await executor(parsed.data, ctx) + return { ok: true, value } + } catch (err) { + return { ok: false, error: String(err) } + } + } +} + +export function makeBuiltinHandler(id: string): ToolHandler | null { + if (!isBuiltinId(id)) { + return null + } + return new BuiltinHandler(id) +} diff --git a/packages/agent-runner/src/tools/meta.ts b/packages/agent-runner/src/tools/meta.ts new file mode 100644 index 000000000000..1e4d88f504fa --- /dev/null +++ b/packages/agent-runner/src/tools/meta.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' + +import { ToolHandler } from './types' + +const CompleteArgsSchema = z.object({ + output: z.unknown(), +}) + +/** + * `meta.complete` — explicit completion signal from the SDK. The worker treats this as + * a terminal tool call: the session is acked and the output is published. + */ +export const completeMetaTool: ToolHandler = { + id: 'meta.complete', + async invoke(call) { + const parsed = CompleteArgsSchema.safeParse(call.args) + if (!parsed.success) { + return { ok: false, error: 'meta.complete args invalid: expected { output }' } + } + return { ok: true, value: parsed.data.output } + }, +} + +const WaitForInputArgsSchema = z.object({ + reason: z.string().min(1).optional(), +}) + +/** + * `meta.wait_for_input` — suspend the session until a /send/:id message arrives. + * The worker recognizes this id and reschedules the job rather than acking it. + */ +export const waitForInputMetaTool: ToolHandler = { + id: 'meta.wait_for_input', + async invoke(call) { + const parsed = WaitForInputArgsSchema.safeParse(call.args) + if (!parsed.success) { + return { ok: false, error: 'meta.wait_for_input args invalid' } + } + return { ok: true, value: { suspended: true, reason: parsed.data.reason ?? null } } + }, +} + +export const META_TOOL_IDS = new Set([completeMetaTool.id, waitForInputMetaTool.id]) + +export const META_TOOL_HANDLERS: readonly ToolHandler[] = [completeMetaTool, waitForInputMetaTool] diff --git a/packages/agent-runner/src/tools/registry.ts b/packages/agent-runner/src/tools/registry.ts new file mode 100644 index 000000000000..3e90c5b47d70 --- /dev/null +++ b/packages/agent-runner/src/tools/registry.ts @@ -0,0 +1,24 @@ +import { makeBuiltinHandler } from './builtins' +import { META_TOOL_HANDLERS, META_TOOL_IDS } from './meta' +import { ToolCall, ToolContext, ToolHandler, ToolResult } from './types' + +const META_HANDLER_INDEX = new Map(META_TOOL_HANDLERS.map((h) => [h.id, h])) + +/** + * Resolves a tool id to a handler and runs it. v1 is native-only: meta tools and built-ins + * are looked up in-process. Unknown ids return a structured failure rather than throwing. + */ +export async function executeTool(call: ToolCall & { id: string }, ctx: ToolContext): Promise { + const handler = resolveHandler(call.id) + if (!handler) { + return { ok: false, error: `unknown tool id: ${call.id}` } + } + return handler.invoke(call, ctx) +} + +export function resolveHandler(id: string): ToolHandler | null { + if (META_TOOL_IDS.has(id)) { + return META_HANDLER_INDEX.get(id) ?? null + } + return makeBuiltinHandler(id) +} diff --git a/packages/agent-runner/src/tools/types.ts b/packages/agent-runner/src/tools/types.ts new file mode 100644 index 000000000000..ba2af5316194 --- /dev/null +++ b/packages/agent-runner/src/tools/types.ts @@ -0,0 +1,27 @@ +/** + * Tool execution interface. Native-only in v1: every tool runs in-process inside the runner. + * The plan calls for a Modal sandbox manager for custom tools later; that ships behind the + * same interface so callers don't change. + */ +export interface ToolContext { + readonly sessionId: string + readonly teamId: number + readonly applicationId: string | null + readonly revisionId: string | null + /** Decrypted secrets requested by the tool, fetched via the internal-API client. */ + readonly secrets: Record +} + +export interface ToolCall { + readonly id: string + readonly args: unknown +} + +export type ToolResult = + | { ok: true; value: unknown } + | { ok: false; error: string } + +export interface ToolHandler { + readonly id: string + invoke(call: ToolCall, ctx: ToolContext): Promise +} diff --git a/packages/agent-runner/src/worker.ts b/packages/agent-runner/src/worker.ts new file mode 100644 index 000000000000..99fff7af95a0 --- /dev/null +++ b/packages/agent-runner/src/worker.ts @@ -0,0 +1,188 @@ +import { + DequeuedSessionJob, + SessionBus, + SessionEvent, + SessionQueueWorker, + WorkerConfig, + logger, +} from '@posthog/agent-core' + +import { SessionExecutor } from './executor' +import { deserializeState, serializeState, SessionState } from './state' +import { executeTool } from './tools/registry' +import { ToolContext } from './tools/types' + +export interface RunnerWorkerConfig extends WorkerConfig { + executor: SessionExecutor + bus: SessionBus + /** Lookup that returns the per-application secrets dictionary. Hooked to the internal-API client in prod. */ + loadSecrets: (applicationId: string | null) => Promise> + /** Optional turn-level heartbeat interval. Defaults to 5s. */ + heartbeatIntervalMs?: number +} + +/** + * Consumes session jobs, runs one turn per dequeue, and reschedules at every tool + * boundary. A heartbeat ticks while a turn is in flight so the janitor doesn't reap us. + */ +export class RunnerWorker { + private readonly queue: SessionQueueWorker + private readonly executor: SessionExecutor + private readonly bus: SessionBus + private readonly loadSecrets: RunnerWorkerConfig['loadSecrets'] + private readonly heartbeatIntervalMs: number + + constructor(config: RunnerWorkerConfig) { + this.queue = new SessionQueueWorker({ + pool: config.pool, + queueName: config.queueName, + batchMaxSize: config.batchMaxSize, + pollDelayMs: config.pollDelayMs, + heartbeatTimeoutMs: config.heartbeatTimeoutMs, + includeEmptyBatches: config.includeEmptyBatches, + }) + this.executor = config.executor + this.bus = config.bus + this.loadSecrets = config.loadSecrets + this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 5_000 + } + + async start(): Promise { + await this.queue.connect(async (batch) => { + for (const job of batch) { + await this.processJob(job) + } + }) + } + + async stop(): Promise { + await this.queue.disconnect() + } + + isHealthy(): boolean { + return this.queue.isHealthy() + } + + private async processJob(job: DequeuedSessionJob): Promise { + const heartbeat = setInterval(() => { + job.heartbeat().catch((err) => { + logger.error('runner heartbeat failed', { sessionId: job.id, error: String(err) }) + }) + }, this.heartbeatIntervalMs) + + try { + const state = deserializeState(job.state) + const newInputs = state.pendingInputs.slice() + state.pendingInputs = [] + + await this.publish(job.id, { type: 'turn_started', at: new Date().toISOString() }) + + const secrets = await this.loadSecrets(job.applicationId) + const ctx: ToolContext = { + sessionId: job.id, + teamId: job.teamId, + applicationId: job.applicationId, + revisionId: job.revisionId, + secrets, + } + + const outcome = await this.executor.runTurn({ + state, + newInputs: newInputs.map((m) => ({ content: m.content, at: m.at })), + }) + + await this.publish(job.id, { type: 'turn_completed', at: new Date().toISOString() }) + + switch (outcome.kind) { + case 'completed': + state.messages.push(outcome.message) + state.turnCount += 1 + await this.publish(job.id, { + type: 'session_completed', + at: new Date().toISOString(), + output: outcome.output, + }) + await job.ack() + return + case 'failed': + await this.publish(job.id, { + type: 'session_failed', + at: new Date().toISOString(), + error: outcome.error, + }) + await job.fail() + return + case 'tool_call': { + state.messages.push(outcome.message) + state.turnCount += 1 + await this.publish(job.id, { + type: 'tool_call', + tool: outcome.call.id, + at: new Date().toISOString(), + args: outcome.call.args, + }) + const result = await this.runToolCall(outcome.call, ctx) + await this.publish(job.id, { + type: 'tool_result', + tool: outcome.call.id, + at: new Date().toISOString(), + ok: result.ok, + result: result.ok ? result.value : undefined, + error: result.ok ? undefined : result.error, + }) + state.messages.push({ + role: 'system', + content: JSON.stringify({ tool: outcome.call.id, result }), + at: new Date().toISOString(), + }) + await job.reschedule({ + scheduledAt: new Date(), + state: serializeState(state), + }) + return + } + case 'awaiting_input': + state.messages.push(outcome.message) + state.turnCount += 1 + // Park the job in the future; /send/:id arrivals from the bus + // bring it forward via the input listener once that wiring lands. + await job.reschedule({ + scheduledAt: new Date(Date.now() + 60_000), + state: serializeState(state), + }) + return + } + } catch (err) { + logger.error('runner job processing failed', { sessionId: job.id, error: String(err) }) + await this.publish(job.id, { + type: 'session_failed', + at: new Date().toISOString(), + error: String(err), + }) + try { + await job.fail() + } catch (failErr) { + logger.error('runner job fail() failed', { sessionId: job.id, error: String(failErr) }) + } + } finally { + clearInterval(heartbeat) + } + } + + private async runToolCall( + call: { id: string; args: unknown }, + ctx: ToolContext + ): ReturnType { + return executeTool({ id: call.id, args: call.args }, ctx) + } + + private async publish(sessionId: string, event: SessionEvent): Promise { + try { + await this.bus.publishEvent(sessionId, event) + } catch (err) { + logger.error('runner publish failed', { sessionId, error: String(err) }) + } + } +} + +export type { SessionState } diff --git a/packages/agent-runner/tests/state.test.ts b/packages/agent-runner/tests/state.test.ts new file mode 100644 index 000000000000..180a61924f9a --- /dev/null +++ b/packages/agent-runner/tests/state.test.ts @@ -0,0 +1,30 @@ +import { deserializeState, emptySessionState, serializeState, SessionStateSchema } from '../src/state' + +describe('state serializer', () => { + it('returns an empty state when the buffer is null', () => { + const state = deserializeState(null) + expect(state).toEqual({ + messages: [], + pendingInputs: [], + initialInput: null, + turnCount: 0, + }) + }) + + it('round-trips a populated state', () => { + const initial = emptySessionState({ foo: 'bar' }) + initial.messages.push({ role: 'user', content: 'hello' }) + initial.pendingInputs.push({ at: '2026-05-14T00:00:00.000Z', content: 'ping' }) + initial.turnCount = 2 + + const buf = serializeState(initial) + const back = deserializeState(buf) + expect(back).toEqual(initial) + }) + + it('rejects malformed payloads via schema validation', () => { + const bad = Buffer.from(JSON.stringify({ messages: 'not-an-array' }), 'utf8') + expect(() => deserializeState(bad)).toThrow() + expect(SessionStateSchema.safeParse({ messages: 'nope' }).success).toBe(false) + }) +}) diff --git a/packages/agent-runner/tests/tools.test.ts b/packages/agent-runner/tests/tools.test.ts new file mode 100644 index 000000000000..fc3a41150769 --- /dev/null +++ b/packages/agent-runner/tests/tools.test.ts @@ -0,0 +1,45 @@ +import { executeTool, resolveHandler } from '../src/tools/registry' +import { ToolContext } from '../src/tools/types' + +const CTX: ToolContext = { + sessionId: 's1', + teamId: 7, + applicationId: 'app-1', + revisionId: 'rev-1', + secrets: {}, +} + +describe('tool registry', () => { + it('resolves meta.complete and returns the output', async () => { + const result = await executeTool({ id: 'meta.complete', args: { output: { foo: 1 } } }, CTX) + expect(result).toEqual({ ok: true, value: { foo: 1 } }) + }) + + it('resolves meta.wait_for_input', async () => { + const result = await executeTool({ id: 'meta.wait_for_input', args: { reason: 'paused' } }, CTX) + expect(result).toEqual({ ok: true, value: { suspended: true, reason: 'paused' } }) + }) + + it('rejects unknown tool ids', async () => { + const result = await executeTool({ id: 'something.unknown', args: {} }, CTX) + expect(result).toEqual({ ok: false, error: expect.stringContaining('unknown tool id') }) + }) + + it('validates builtin args against the registry schema', async () => { + const handler = resolveHandler('posthog.events.capture') + expect(handler).not.toBeNull() + const bad = await handler!.invoke({ id: 'posthog.events.capture', args: { event: 'signup' } }, CTX) + expect(bad.ok).toBe(false) + }) + + it('runs a valid builtin call', async () => { + const result = await executeTool( + { id: 'posthog.events.capture', args: { event: 'signup', distinctId: 'u-1' } }, + CTX + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toMatchObject({ captured: true, teamId: 7 }) + } + }) +}) diff --git a/packages/agent-runner/tests/worker.test.ts b/packages/agent-runner/tests/worker.test.ts new file mode 100644 index 000000000000..8ced4992ad36 --- /dev/null +++ b/packages/agent-runner/tests/worker.test.ts @@ -0,0 +1,231 @@ +import { DateTime } from 'luxon' + +import { InMemorySessionBus, SessionEvent } from '@posthog/agent-core' + +import { ExecutorTurnOutput, SessionExecutor } from '../src/executor' +import { deserializeState, serializeState } from '../src/state' +import { RunnerWorker } from '../src/worker' + +/** + * Drives processJob via a non-public hook (cast to any). The worker's queue dependency + * is real but never connected; instead, we hand-craft a DequeuedSessionJob and call into + * processJob to verify the orchestration without spinning up Postgres. + */ +interface JobCallRecord { + method: string + args?: unknown +} + +interface FakeDequeuedJob { + id: string + teamId: number + applicationId: string | null + revisionId: string | null + queueName: string + scheduled: DateTime + created: DateTime + transitionCount: number + state: Buffer | null + ack(): Promise + fail(): Promise + reschedule(input?: unknown): Promise + cancel(): Promise + heartbeat(): Promise +} + +function makeJob( + id: string, + opts: { state?: Buffer; applicationId?: string | null } +): { record: JobCallRecord[]; job: FakeDequeuedJob } { + const calls: JobCallRecord[] = [] + return { + record: calls, + job: { + id, + teamId: 1, + applicationId: opts.applicationId ?? 'app-1', + revisionId: 'rev-1', + queueName: 'default', + scheduled: DateTime.now(), + created: DateTime.now(), + transitionCount: 0, + state: opts.state ?? null, + async ack() { + calls.push({ method: 'ack' }) + }, + async fail() { + calls.push({ method: 'fail' }) + }, + async reschedule(input?: unknown) { + calls.push({ method: 'reschedule', args: input }) + }, + async cancel() { + calls.push({ method: 'cancel' }) + }, + async heartbeat() { + calls.push({ method: 'heartbeat' }) + }, + }, + } +} + +function scriptedExecutor(outputs: ExecutorTurnOutput[]): SessionExecutor { + let i = 0 + return { + async runTurn() { + const next = outputs[i] + i += 1 + if (!next) { + throw new Error('scriptedExecutor: no more outputs') + } + return next + }, + } +} + +function captureEvents(bus: InMemorySessionBus, sessionId: string): SessionEvent[] { + const received: SessionEvent[] = [] + void bus.subscribeEvents(sessionId, (e) => received.push(e)) + return received +} + +describe('RunnerWorker.processJob', () => { + it('completion: publishes session_completed and acks the job', async () => { + const bus = new InMemorySessionBus() + const events = captureEvents(bus, 's-complete') + const worker = new RunnerWorker({ + pool: { dbUrl: 'postgres://unused' }, + queueName: 'default', + executor: scriptedExecutor([ + { + kind: 'completed', + message: { role: 'assistant', content: 'done', at: '2026-05-14T00:00:00Z' }, + output: { answer: 42 }, + }, + ]), + bus, + loadSecrets: async () => ({}), + heartbeatIntervalMs: 1_000_000, + }) + + const { record, job } = makeJob('s-complete', {}) + await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) + + expect(record.map((r) => r.method)).toEqual(['ack']) + expect(events.map((e) => e.type)).toEqual(['turn_started', 'turn_completed', 'session_completed']) + + await bus.disconnect() + }) + + it('failed: publishes session_failed and fails the job', async () => { + const bus = new InMemorySessionBus() + const events = captureEvents(bus, 's-fail') + const worker = new RunnerWorker({ + pool: { dbUrl: 'postgres://unused' }, + queueName: 'default', + executor: scriptedExecutor([{ kind: 'failed', error: 'boom' }]), + bus, + loadSecrets: async () => ({}), + heartbeatIntervalMs: 1_000_000, + }) + const { record, job } = makeJob('s-fail', {}) + await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) + + expect(record.map((r) => r.method)).toEqual(['fail']) + const failed = events.find((e): e is Extract => e.type === 'session_failed') + expect(failed?.error).toBe('boom') + + await bus.disconnect() + }) + + it('tool_call: runs the tool natively and reschedules with updated state', async () => { + const bus = new InMemorySessionBus() + captureEvents(bus, 's-tool') + const worker = new RunnerWorker({ + pool: { dbUrl: 'postgres://unused' }, + queueName: 'default', + executor: scriptedExecutor([ + { + kind: 'tool_call', + message: { role: 'assistant', content: 'thinking', at: '2026-05-14T00:00:00.000Z' }, + call: { id: 'meta.complete', args: { output: { ok: true } } }, + }, + ]), + bus, + loadSecrets: async () => ({}), + heartbeatIntervalMs: 1_000_000, + }) + const { record, job } = makeJob('s-tool', {}) + await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) + + const reschedule = record.find((r) => r.method === 'reschedule') + expect(reschedule).not.toBeUndefined() + const args = (reschedule!.args as { state: Buffer }).state + const state = deserializeState(args) + // Two messages: the assistant's "thinking" + the system tool result. + expect(state.messages).toHaveLength(2) + expect(state.messages[1].role).toBe('system') + expect(state.turnCount).toBe(1) + + await bus.disconnect() + }) + + it('awaiting_input: reschedules with the current state and parks the job', async () => { + const bus = new InMemorySessionBus() + const worker = new RunnerWorker({ + pool: { dbUrl: 'postgres://unused' }, + queueName: 'default', + executor: scriptedExecutor([ + { + kind: 'awaiting_input', + message: { role: 'assistant', content: 'awaiting' }, + reason: 'needs more info', + }, + ]), + bus, + loadSecrets: async () => ({}), + heartbeatIntervalMs: 1_000_000, + }) + const { record, job } = makeJob('s-wait', {}) + await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) + + const reschedule = record.find((r) => r.method === 'reschedule') + expect(reschedule).not.toBeUndefined() + await bus.disconnect() + }) + + it('flushes pending inputs into the turn and clears them in the persisted state', async () => { + const bus = new InMemorySessionBus() + const initialState = serializeState({ + messages: [], + pendingInputs: [{ at: '2026-05-14T00:00:00.000Z', content: 'hello' }], + initialInput: null, + turnCount: 0, + }) + + let captured: { content: string }[] = [] + const worker = new RunnerWorker({ + pool: { dbUrl: 'postgres://unused' }, + queueName: 'default', + executor: { + async runTurn(input) { + captured = [...input.newInputs] + return { + kind: 'completed', + message: { role: 'assistant', content: 'ack' }, + output: null, + } + }, + }, + bus, + loadSecrets: async () => ({}), + heartbeatIntervalMs: 1_000_000, + }) + + const { job } = makeJob('s-flush', { state: initialState }) + await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) + + expect(captured).toEqual([{ content: 'hello', at: '2026-05-14T00:00:00.000Z' }]) + await bus.disconnect() + }) +}) diff --git a/packages/agent-runner/tsconfig.json b/packages/agent-runner/tsconfig.json new file mode 100644 index 000000000000..0615be011bb6 --- /dev/null +++ b/packages/agent-runner/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist/", + "rootDir": "src/", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/agent-runner/tsconfig.test.json b/packages/agent-runner/tsconfig.test.json new file mode 100644 index 000000000000..7d1d3ba5e17b --- /dev/null +++ b/packages/agent-runner/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d98e27863a97..42b082016329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,10 +445,10 @@ importers: version: 7.6.24(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@storybook/react-webpack5': specifier: ^7.6.4 - version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/test-runner': specifier: ^0.16.0 - version: 0.16.0(@types/node@22.18.8)(encoding@0.1.13)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + version: 0.16.0(encoding@0.1.13) '@storybook/theming': specifier: ^7.6.4 version: 7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -499,7 +499,7 @@ importers: version: 2.0.0(webpack@5.88.2) webpack: specifier: ^5.88.2 - version: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + version: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: specifier: ^5.1.4 version: 5.1.4(webpack@5.88.2) @@ -1742,6 +1742,141 @@ importers: specifier: ^4.7.0 version: 4.20.5 + packages/agent-core: + dependencies: + ioredis: + specifier: ^4.27.6 + version: 4.28.5 + luxon: + specifier: ^3.4.4 + version: 3.7.2 + node-fetch: + specifier: ^2.6.1 + version: 2.7.0(encoding@0.1.13) + pg: + specifier: ^8.6.0 + version: 8.10.0 + pino: + specifier: ^8.6.0 + version: 8.11.0 + prom-client: + specifier: ^14.2.0 + version: 14.2.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/ioredis': + specifier: ^4.26.4 + version: 4.28.10 + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@types/node-fetch': + specifier: ^2.5.10 + version: 2.6.4 + '@types/pg': + specifier: ^8.6.0 + version: 8.15.4 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/agent-ingress: + dependencies: + '@posthog/agent-core': + specifier: workspace:* + version: link:../agent-core + lru-cache: + specifier: ^11.0.0 + version: 11.2.4 + ultimate-express: + specifier: ^2.0.9 + version: 2.0.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + supertest: + specifier: ^7.0.0 + version: 7.0.0 + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/agent-runner: + dependencies: + '@posthog/agent-core': + specifier: workspace:* + version: link:../agent-core + luxon: + specifier: ^3.4.4 + version: 3.7.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/quill: devDependencies: tailwindcss-scroll-mask: @@ -5593,12 +5728,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -5635,12 +5764,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -5677,12 +5800,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -5719,12 +5836,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -5761,12 +5872,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -5803,12 +5908,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -5845,12 +5944,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -5887,12 +5980,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -5929,12 +6016,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -5971,12 +6052,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -6013,12 +6088,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -6055,12 +6124,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -6097,12 +6160,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -6139,12 +6196,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -6181,12 +6232,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -6223,12 +6268,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -6265,12 +6304,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -6295,12 +6328,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -6337,12 +6364,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -6367,12 +6388,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -6409,12 +6424,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -6439,12 +6448,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -6481,12 +6484,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -6523,12 +6520,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -6565,12 +6556,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -6607,12 +6592,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -14315,10 +14294,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -15360,11 +15335,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -20477,10 +20447,6 @@ packages: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -28464,9 +28430,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true - '@esbuild/aix-ppc64@0.27.2': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true @@ -28485,9 +28448,6 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true - '@esbuild/android-arm64@0.27.2': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true @@ -28506,9 +28466,6 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true - '@esbuild/android-arm@0.27.2': - optional: true - '@esbuild/android-arm@0.27.3': optional: true @@ -28527,9 +28484,6 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true - '@esbuild/android-x64@0.27.2': - optional: true - '@esbuild/android-x64@0.27.3': optional: true @@ -28548,9 +28502,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true - '@esbuild/darwin-arm64@0.27.2': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true @@ -28569,9 +28520,6 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true - '@esbuild/darwin-x64@0.27.2': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true @@ -28590,9 +28538,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true - '@esbuild/freebsd-arm64@0.27.2': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true @@ -28611,9 +28556,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true - '@esbuild/freebsd-x64@0.27.2': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true @@ -28632,9 +28574,6 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true - '@esbuild/linux-arm64@0.27.2': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true @@ -28653,9 +28592,6 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true - '@esbuild/linux-arm@0.27.2': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true @@ -28674,9 +28610,6 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true - '@esbuild/linux-ia32@0.27.2': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true @@ -28695,9 +28628,6 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true - '@esbuild/linux-loong64@0.27.2': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true @@ -28716,9 +28646,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true - '@esbuild/linux-mips64el@0.27.2': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true @@ -28737,9 +28664,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true - '@esbuild/linux-ppc64@0.27.2': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true @@ -28758,9 +28682,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true - '@esbuild/linux-riscv64@0.27.2': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true @@ -28779,9 +28700,6 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true - '@esbuild/linux-s390x@0.27.2': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true @@ -28800,9 +28718,6 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true - '@esbuild/linux-x64@0.27.2': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true @@ -28815,9 +28730,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true - '@esbuild/netbsd-arm64@0.27.2': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true @@ -28836,9 +28748,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true - '@esbuild/netbsd-x64@0.27.2': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true @@ -28851,9 +28760,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true - '@esbuild/openbsd-arm64@0.27.2': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true @@ -28872,9 +28778,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true - '@esbuild/openbsd-x64@0.27.2': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true @@ -28887,9 +28790,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true - '@esbuild/openharmony-arm64@0.27.2': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true @@ -28908,9 +28808,6 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true - '@esbuild/sunos-x64@0.27.2': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true @@ -28929,9 +28826,6 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true - '@esbuild/win32-arm64@0.27.2': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true @@ -28950,9 +28844,6 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true - '@esbuild/win32-ia32@0.27.2': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true @@ -28971,9 +28862,6 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@esbuild/win32-x64@0.27.2': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -32082,7 +31970,7 @@ snapshots: react-refresh: 0.14.0 schema-utils: 3.3.0 source-map: 0.7.6 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: type-fest: 4.41.0 webpack-hot-middleware: 2.25.4 @@ -34117,7 +34005,7 @@ snapshots: - encoding - supports-color - '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4)': + '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4)': dependencies: '@babel/core': 7.29.0 '@storybook/channels': 7.6.4 @@ -34147,12 +34035,12 @@ snapshots: semver: 7.7.4 style-loader: 3.3.3(webpack@5.88.2) swc-loader: 0.2.3(@swc/core@1.15.18)(webpack@5.88.2) - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) ts-dedent: 2.2.0 url: 0.11.1 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-dev-middleware: 6.1.1(webpack@5.88.2) webpack-hot-middleware: 2.25.4 webpack-virtual-modules: 0.5.0 @@ -34223,7 +34111,7 @@ snapshots: get-port: 5.1.1 giget: 1.1.2 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) leven: 3.1.0 ora: 5.4.1 prettier: 2.8.8 @@ -34266,7 +34154,7 @@ snapshots: '@types/cross-spawn': 6.0.2 cross-spawn: 7.0.6 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) lodash: 4.18.1 prettier: 2.8.8 recast: 0.23.11 @@ -34558,7 +34446,7 @@ snapshots: '@storybook/postinstall@7.6.4': {} - '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: '@babel/preset-flow': 7.23.3(@babel/core@7.26.0) '@babel/preset-react': 7.23.3(@babel/core@7.26.0) @@ -34578,7 +34466,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-refresh: 0.14.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: '@babel/core': 7.26.0 typescript: 5.9.3 @@ -34661,7 +34549,7 @@ snapshots: react-docgen-typescript: 2.2.2(typescript@5.9.3) tslib: 2.8.1 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) transitivePeerDependencies: - supports-color @@ -34695,10 +34583,10 @@ snapshots: - typescript - vite-plugin-glimmerx - '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: - '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4) - '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4) + '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/react': 7.6.4(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@types/node': 18.19.130 react: 18.3.1 @@ -34857,6 +34745,46 @@ snapshots: - supports-color - ts-node + '@storybook/test-runner@0.16.0(encoding@0.1.13)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@jest/types': 29.6.3 + '@storybook/core-common': 7.6.4(encoding@0.1.13) + '@storybook/csf': 0.1.13 + '@storybook/csf-tools': 7.6.4 + '@storybook/preview-api': 7.6.20 + '@swc/core': 1.15.18 + '@swc/jest': 0.2.37(@swc/core@1.15.18) + can-bind-to-host: 1.1.2 + commander: 9.4.1 + expect-playwright: 0.8.0 + glob: 10.4.5 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-junit: 16.0.0 + jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0) + jest-runner: 29.7.0 + jest-serializer-html: 7.1.0 + jest-watch-typeahead: 2.2.2(jest@29.7.0) + node-fetch: 2.7.0(encoding@0.1.13) + playwright: 1.45.0 + read-pkg-up: 7.0.1 + tempy: 1.0.1 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@swc/helpers' + - '@types/node' + - babel-plugin-macros + - debug + - encoding + - node-notifier + - supports-color + - ts-node + '@storybook/theming@7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) @@ -37100,17 +37028,17 @@ snapshots: '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@xmldom/xmldom@0.8.11': {} @@ -37717,14 +37645,14 @@ snapshots: loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) babel-loader@9.1.3(@babel/core@7.29.0)(webpack@5.88.2): dependencies: '@babel/core': 7.29.0 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) babel-plugin-add-react-displayname@0.0.5: {} @@ -38697,8 +38625,6 @@ snapshots: cookie@0.7.2: {} - cookie@1.0.2: {} - cookie@1.1.1: {} cookiejar@2.1.4: {} @@ -38830,7 +38756,7 @@ snapshots: cron-parser@4.8.1: dependencies: - luxon: 3.5.0 + luxon: 3.7.2 cron-parser@5.5.0: dependencies: @@ -38921,7 +38847,7 @@ snapshots: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) css-loader@6.8.1(webpack@5.88.2): dependencies: @@ -38933,7 +38859,7 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-value-parser: 4.2.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) css-prefers-color-scheme@10.0.0(postcss@8.5.2): dependencies: @@ -40105,35 +40031,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -40699,7 +40596,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) file-system-cache@2.3.0: dependencies: @@ -40843,7 +40740,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) form-data@4.0.5: dependencies: @@ -41522,7 +41419,7 @@ snapshots: html-webpack-harddisk-plugin@2.0.0(html-webpack-plugin@5.5.3(webpack@5.88.2))(webpack@5.88.2): dependencies: html-webpack-plugin: 5.5.3(webpack@5.88.2) - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) html-webpack-plugin@5.5.3(webpack@5.88.2): dependencies: @@ -41531,7 +41428,7 @@ snapshots: lodash: 4.17.21 pretty-error: 4.0.0 tapable: 2.2.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) htmlnano@2.1.1(cssnano@7.0.6(postcss@8.5.6))(postcss@8.5.6)(relateurl@0.2.7)(svgo@3.3.2)(terser@5.46.0)(typescript@5.9.3): dependencies: @@ -41801,7 +41698,7 @@ snapshots: ioredis@4.28.5: dependencies: cluster-key-slot: 1.1.2 - debug: 4.3.4 + debug: 4.4.3 denque: 1.5.1 lodash.defaults: 4.2.0 lodash.flatten: 4.4.0 @@ -42847,6 +42744,22 @@ snapshots: - debug - supports-color + jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0): + dependencies: + expect-playwright: 0.8.0 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-process-manager: 0.4.0 + jest-runner: 29.7.0 + nyc: 15.1.0 + playwright-core: 1.45.0 + rimraf: 3.0.2 + uuid: 8.3.2 + transitivePeerDependencies: + - debug + - supports-color + jest-pnp-resolver@1.2.3(jest-resolve@27.5.1): optionalDependencies: jest-resolve: 27.5.1 @@ -43265,6 +43178,17 @@ snapshots: string-length: 5.0.1 strip-ansi: 7.2.0 + jest-watch-typeahead@2.2.2(jest@29.7.0): + dependencies: + ansi-escapes: 6.0.0 + chalk: 5.6.2 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + jest-watcher@27.5.1: dependencies: '@jest/test-result': 27.5.1 @@ -43412,7 +43336,7 @@ snapshots: jsbn@1.1.0: {} - jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)): + jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -43435,7 +43359,7 @@ snapshots: temp: 0.8.4 write-file-atomic: 2.4.3 optionalDependencies: - '@babel/preset-env': 7.23.5(@babel/core@7.26.0) + '@babel/preset-env': 7.23.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -43709,7 +43633,7 @@ snapshots: less: 4.2.2 loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) less@3.13.1: dependencies: @@ -45015,7 +44939,7 @@ snapshots: inquirer: 8.2.5 is-node-process: 1.2.0 js-levenshtein: 1.1.6 - node-fetch: 2.6.9(encoding@0.1.13) + node-fetch: 2.7.0(encoding@0.1.13) outvariant: 1.4.0 path-to-regexp: 6.2.1 strict-event-emitter: 0.2.8 @@ -46205,7 +46129,7 @@ snapshots: postcss: 8.5.6 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) postcss-logical@8.0.0(postcss@8.5.2): dependencies: @@ -47132,10 +47056,6 @@ snapshots: dependencies: side-channel: 1.1.0 - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -47195,7 +47115,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -48332,7 +48252,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: sass: 1.56.0 @@ -49040,11 +48960,11 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) style-loader@3.3.3(webpack@5.88.2): dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) style-search@0.1.0: {} @@ -49183,7 +49103,7 @@ snapshots: formidable: 3.5.1 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.1 + qs: 6.15.1 transitivePeerDependencies: - supports-color @@ -49248,7 +49168,7 @@ snapshots: swc-loader@0.2.3(@swc/core@1.15.18)(webpack@5.88.2): dependencies: '@swc/core': 1.15.18 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) symbol-tree@3.2.4: {} @@ -49442,17 +49362,16 @@ snapshots: '@swc/core': 1.15.18 esbuild: 0.27.7 - terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2): + terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(webpack@5.88.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.15.18 - esbuild: 0.18.20 terser@5.19.1: dependencies: @@ -49669,7 +49588,7 @@ snapshots: json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.2 + semver: 7.7.4 type-fest: 4.41.0 typescript: 5.9.3 yargs-parser: 21.1.1 @@ -49681,6 +49600,26 @@ snapshots: esbuild: 0.27.7 jest-util: 30.0.5 + ts-jest@29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.0.5 + '@jest/types': 30.0.5 + babel-jest: 30.0.5(@babel/core@7.29.0) + jest-util: 30.0.5 + ts-json-schema-generator@2.4.0-next.6: dependencies: '@types/json-schema': 7.0.15 @@ -49929,9 +49868,9 @@ snapshots: dependencies: '@types/express': 4.17.21 accepts: 1.3.8 - acorn: 8.15.0 + acorn: 8.16.0 bytes: 3.1.2 - cookie: 1.0.2 + cookie: 1.1.1 cookie-signature: 1.2.2 encodeurl: 2.0.0 etag: 1.8.1 @@ -49941,9 +49880,9 @@ snapshots: mime-types: 2.1.35 ms: 2.1.3 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.15.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 tseep: 1.3.1 type-is: 2.0.1 uWebSockets.js: https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/cfc9a40d8132a34881813cec3d5f8e3a185b3ce3 @@ -50430,7 +50369,7 @@ snapshots: vite@7.3.1(@types/node@22.18.8)(jiti@2.6.1)(less@4.2.2)(lightningcss@1.32.0)(sass-embedded@1.70.0)(terser@5.46.0)(tsx@4.20.5)(yaml@2.8.2): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.6 @@ -50449,7 +50388,7 @@ snapshots: vite@7.3.1(@types/node@22.18.8)(jiti@2.6.1)(less@4.2.2)(lightningcss@1.32.0)(sass-embedded@1.70.0)(terser@5.46.0)(tsx@4.20.5)(yaml@2.8.4): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.6 @@ -50648,7 +50587,7 @@ snapshots: import-local: 3.1.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-merge: 5.8.0 webpack-dev-middleware@6.1.1(webpack@5.88.2): @@ -50659,7 +50598,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.2.0 optionalDependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-hot-middleware@2.25.4: dependencies: @@ -50710,7 +50649,7 @@ snapshots: - esbuild - uglify-js - webpack@5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4): + webpack@5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.4 '@types/estree': 1.0.1 @@ -50733,7 +50672,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) watchpack: 2.4.0 webpack-sources: 3.2.3 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b49344ec536..1dc67b621d0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,9 @@ packages: - packages/quill - packages/quill/apps/* - packages/quill/packages/* + - packages/agent-core + - packages/agent-ingress + - packages/agent-runner - frontend - playwright - nodejs From af34d40fcb0160835798cd1813603dc2d7afebce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:15:42 +0000 Subject: [PATCH 004/517] fix(agent_stack): align npm package name with frontend reference products/agent_stack/package.json was named @posthog/products-agent_stack (underscore), but frontend/package.json depends on @posthog/products-agent-stack (hyphen, matching the rest of products/*). pnpm i fails on the mismatch. https://claude.ai/code/session_01Bkx1f6m35QnFZ2RbxTsrZt --- pnpm-lock.yaml | 155 +++++++++--------------------- products/agent_stack/package.json | 2 +- 2 files changed, 48 insertions(+), 109 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42b082016329..0e2e638f31f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,10 +445,10 @@ importers: version: 7.6.24(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@storybook/react-webpack5': specifier: ^7.6.4 - version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/test-runner': specifier: ^0.16.0 - version: 0.16.0(encoding@0.1.13) + version: 0.16.0(@types/node@22.18.8)(encoding@0.1.13)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) '@storybook/theming': specifier: ^7.6.4 version: 7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -499,7 +499,7 @@ importers: version: 2.0.0(webpack@5.88.2) webpack: specifier: ^5.88.2 - version: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + version: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-cli: specifier: ^5.1.4 version: 5.1.4(webpack@5.88.2) @@ -601,6 +601,9 @@ importers: '@posthog/products-actions': specifier: workspace:* version: link:../products/actions + '@posthog/products-agent-stack': + specifier: workspace:* + version: link:../products/agent_stack '@posthog/products-business-knowledge': specifier: workspace:* version: link:../products/business_knowledge @@ -2197,6 +2200,8 @@ importers: specifier: 'catalog:' version: 0.2.4(kea@4.0.0-pre.5(react@18.3.1)) + products/agent_stack: {} + products/alerts: {} products/analytics_platform: {} @@ -31970,7 +31975,7 @@ snapshots: react-refresh: 0.14.0 schema-utils: 3.3.0 source-map: 0.7.6 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) optionalDependencies: type-fest: 4.41.0 webpack-hot-middleware: 2.25.4 @@ -34005,7 +34010,7 @@ snapshots: - encoding - supports-color - '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4)': + '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4)': dependencies: '@babel/core': 7.29.0 '@storybook/channels': 7.6.4 @@ -34035,12 +34040,12 @@ snapshots: semver: 7.7.4 style-loader: 3.3.3(webpack@5.88.2) swc-loader: 0.2.3(@swc/core@1.15.18)(webpack@5.88.2) - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) ts-dedent: 2.2.0 url: 0.11.1 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-dev-middleware: 6.1.1(webpack@5.88.2) webpack-hot-middleware: 2.25.4 webpack-virtual-modules: 0.5.0 @@ -34111,7 +34116,7 @@ snapshots: get-port: 5.1.1 giget: 1.1.2 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) leven: 3.1.0 ora: 5.4.1 prettier: 2.8.8 @@ -34154,7 +34159,7 @@ snapshots: '@types/cross-spawn': 6.0.2 cross-spawn: 7.0.6 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) lodash: 4.18.1 prettier: 2.8.8 recast: 0.23.11 @@ -34446,7 +34451,7 @@ snapshots: '@storybook/postinstall@7.6.4': {} - '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: '@babel/preset-flow': 7.23.3(@babel/core@7.26.0) '@babel/preset-react': 7.23.3(@babel/core@7.26.0) @@ -34466,7 +34471,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-refresh: 0.14.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) optionalDependencies: '@babel/core': 7.26.0 typescript: 5.9.3 @@ -34549,7 +34554,7 @@ snapshots: react-docgen-typescript: 2.2.2(typescript@5.9.3) tslib: 2.8.1 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) transitivePeerDependencies: - supports-color @@ -34583,10 +34588,10 @@ snapshots: - typescript - vite-plugin-glimmerx - '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: - '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4) - '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4) + '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/react': 7.6.4(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@types/node': 18.19.130 react: 18.3.1 @@ -34745,46 +34750,6 @@ snapshots: - supports-color - ts-node - '@storybook/test-runner@0.16.0(encoding@0.1.13)': - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@jest/types': 29.6.3 - '@storybook/core-common': 7.6.4(encoding@0.1.13) - '@storybook/csf': 0.1.13 - '@storybook/csf-tools': 7.6.4 - '@storybook/preview-api': 7.6.20 - '@swc/core': 1.15.18 - '@swc/jest': 0.2.37(@swc/core@1.15.18) - can-bind-to-host: 1.1.2 - commander: 9.4.1 - expect-playwright: 0.8.0 - glob: 10.4.5 - jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-junit: 16.0.0 - jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0) - jest-runner: 29.7.0 - jest-serializer-html: 7.1.0 - jest-watch-typeahead: 2.2.2(jest@29.7.0) - node-fetch: 2.7.0(encoding@0.1.13) - playwright: 1.45.0 - read-pkg-up: 7.0.1 - tempy: 1.0.1 - ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@swc/helpers' - - '@types/node' - - babel-plugin-macros - - debug - - encoding - - node-notifier - - supports-color - - ts-node - '@storybook/theming@7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) @@ -37028,17 +36993,17 @@ snapshots: '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@xmldom/xmldom@0.8.11': {} @@ -37645,14 +37610,14 @@ snapshots: loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) babel-loader@9.1.3(@babel/core@7.29.0)(webpack@5.88.2): dependencies: '@babel/core': 7.29.0 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) babel-plugin-add-react-displayname@0.0.5: {} @@ -38847,7 +38812,7 @@ snapshots: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.1 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) css-loader@6.8.1(webpack@5.88.2): dependencies: @@ -38859,7 +38824,7 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-value-parser: 4.2.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) css-prefers-color-scheme@10.0.0(postcss@8.5.2): dependencies: @@ -40596,7 +40561,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) file-system-cache@2.3.0: dependencies: @@ -40740,7 +40705,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) form-data@4.0.5: dependencies: @@ -41419,7 +41384,7 @@ snapshots: html-webpack-harddisk-plugin@2.0.0(html-webpack-plugin@5.5.3(webpack@5.88.2))(webpack@5.88.2): dependencies: html-webpack-plugin: 5.5.3(webpack@5.88.2) - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) html-webpack-plugin@5.5.3(webpack@5.88.2): dependencies: @@ -41428,7 +41393,7 @@ snapshots: lodash: 4.17.21 pretty-error: 4.0.0 tapable: 2.2.1 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) htmlnano@2.1.1(cssnano@7.0.6(postcss@8.5.6))(postcss@8.5.6)(relateurl@0.2.7)(svgo@3.3.2)(terser@5.46.0)(typescript@5.9.3): dependencies: @@ -42744,22 +42709,6 @@ snapshots: - debug - supports-color - jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0): - dependencies: - expect-playwright: 0.8.0 - jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-process-manager: 0.4.0 - jest-runner: 29.7.0 - nyc: 15.1.0 - playwright-core: 1.45.0 - rimraf: 3.0.2 - uuid: 8.3.2 - transitivePeerDependencies: - - debug - - supports-color - jest-pnp-resolver@1.2.3(jest-resolve@27.5.1): optionalDependencies: jest-resolve: 27.5.1 @@ -43178,17 +43127,6 @@ snapshots: string-length: 5.0.1 strip-ansi: 7.2.0 - jest-watch-typeahead@2.2.2(jest@29.7.0): - dependencies: - ansi-escapes: 6.0.0 - chalk: 5.6.2 - jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) - jest-regex-util: 29.6.3 - jest-watcher: 29.7.0 - slash: 5.1.0 - string-length: 5.0.1 - strip-ansi: 7.2.0 - jest-watcher@27.5.1: dependencies: '@jest/test-result': 27.5.1 @@ -43336,7 +43274,7 @@ snapshots: jsbn@1.1.0: {} - jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)): + jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -43359,7 +43297,7 @@ snapshots: temp: 0.8.4 write-file-atomic: 2.4.3 optionalDependencies: - '@babel/preset-env': 7.23.5(@babel/core@7.29.0) + '@babel/preset-env': 7.23.5(@babel/core@7.26.0) transitivePeerDependencies: - supports-color @@ -43633,7 +43571,7 @@ snapshots: less: 4.2.2 loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) less@3.13.1: dependencies: @@ -46129,7 +46067,7 @@ snapshots: postcss: 8.5.6 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) postcss-logical@8.0.0(postcss@8.5.2): dependencies: @@ -47115,7 +47053,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -48252,7 +48190,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) optionalDependencies: sass: 1.56.0 @@ -48960,11 +48898,11 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) style-loader@3.3.3(webpack@5.88.2): dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) style-search@0.1.0: {} @@ -49168,7 +49106,7 @@ snapshots: swc-loader@0.2.3(@swc/core@1.15.18)(webpack@5.88.2): dependencies: '@swc/core': 1.15.18 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) symbol-tree@3.2.4: {} @@ -49362,16 +49300,17 @@ snapshots: '@swc/core': 1.15.18 esbuild: 0.27.7 - terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(webpack@5.88.2): + terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.1 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.15.18 + esbuild: 0.18.20 terser@5.19.1: dependencies: @@ -50587,7 +50526,7 @@ snapshots: import-local: 3.1.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-merge: 5.8.0 webpack-dev-middleware@6.1.1(webpack@5.88.2): @@ -50598,7 +50537,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.2.0 optionalDependencies: - webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) webpack-hot-middleware@2.25.4: dependencies: @@ -50649,7 +50588,7 @@ snapshots: - esbuild - uglify-js - webpack@5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4): + webpack@5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.4 '@types/estree': 1.0.1 @@ -50672,7 +50611,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) watchpack: 2.4.0 webpack-sources: 3.2.3 optionalDependencies: diff --git a/products/agent_stack/package.json b/products/agent_stack/package.json index a3edbf0a0b50..3d9d774a17db 100644 --- a/products/agent_stack/package.json +++ b/products/agent_stack/package.json @@ -1,5 +1,5 @@ { - "name": "@posthog/products-agent_stack", + "name": "@posthog/products-agent-stack", "scripts": { "backend:test": "pytest -c ../../pytest.ini --rootdir ../.. backend/tests -v --tb=short", "backend:contract-check": "echo 'Contract files unchanged'" From 56f2e2fe343612773e13ccc721f8452fcbcbcdf8 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 13 May 2026 21:30:35 -0400 Subject: [PATCH 005/517] docs(agents): add nodejs status TODO to area plan Cross-references the three runtime packages against the plan and lists remaining work (executor wiring, builtin implementations, observability, end-to-end integration test). Lives near the top of the plan so it can be referenced and updated as work lands. Also labels two pre-existing fenced code blocks to satisfy MD040. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/internal/agent-platform.md | 104 ++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index 7ae19a7147af..e4184d2f89ba 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -13,11 +13,91 @@ The runtime split (ingress + runner) from the agent-stack doc still holds. This --- +## Status & nodejs TODO + +Working tracker for the runtime packages only — the Django side is being built in parallel. Update inline as work lands; the rest of this doc is the spec. + +**Last audited:** 2026-05-13. + +### packages/agent-core/ (milestone 5 — substantially done) + +- [x] Queue schema + migration ([migrations/0001_initial_schema.sql](../../packages/agent-core/migrations/0001_initial_schema.sql)): state enum, `lock_id`, `last_heartbeat`, `BYTEA` state, `transition_count`, `janitor_touch_count`, indexes for dequeue/stall/cleanup +- [x] Manager / enqueue with depth limit + 1 MiB state cap ([src/queue/manager.ts](../../packages/agent-core/src/queue/manager.ts)) +- [x] Worker / dequeue + `FOR UPDATE SKIP LOCKED` + heartbeat + ack/fail/reschedule/cancel ([src/queue/worker.ts](../../packages/agent-core/src/queue/worker.ts)) +- [x] Janitor / stall recovery + poison-pill + terminal cleanup + Prom metrics ([src/queue/janitor.ts](../../packages/agent-core/src/queue/janitor.ts)) +- [x] Migrations runner ([bin/migrate.ts](../../packages/agent-core/bin/migrate.ts)) +- [x] Pub-sub interface + Redis adapter + in-memory adapter ([src/pubsub/](../../packages/agent-core/src/pubsub)) +- [x] Internal-API client (`resolve`, `decrypt`) with optional shared-key header ([src/internal-api/client.ts](../../packages/agent-core/src/internal-api/client.ts)) +- [x] Built-ins registry — `posthog.events.capture`, `posthog.feature_flags.evaluate`, `http.fetch` ([src/builtins/index.ts](../../packages/agent-core/src/builtins/index.ts)) +- [x] Manifest reader + Zod schema + built-in id validation ([src/manifest/index.ts](../../packages/agent-core/src/manifest/index.ts)) +- [x] Logger (pino) + Prom metrics ([src/logger.ts](../../packages/agent-core/src/logger.ts), [src/metrics.ts](../../packages/agent-core/src/metrics.ts)) +- [x] Tests: queue (DB-gated), pubsub in-memory, manifest, builtins +- [ ] Tests: Redis pubsub integration (needs Redis in CI) +- [ ] Tests: internal-API client smoke (mock server — 404, timeout, shared-key header) +- [ ] Decide internal-API transport auth (mTLS vs shared key) — both supported in code, pick at infra time + +### packages/agent-ingress/ (milestone 6 — wired end-to-end against fakes) + +- [x] Bootstrap, Zod-validated env, SIGTERM/SIGINT shutdown ([src/index.ts](../../packages/agent-ingress/src/index.ts), [src/config.ts](../../packages/agent-ingress/src/config.ts)) +- [x] Host resolver with LRU + TTL + `invalidate()` hook ([src/resolver.ts](../../packages/agent-ingress/src/resolver.ts)) +- [x] Auth modes: `public`, `shared_secret`, `webhook_signature` (generic HMAC-SHA256) ([src/auth.ts](../../packages/agent-ingress/src/auth.ts)) +- [x] `/run` — resolves, authorizes, writes job via agent-core queue, returns 202 `{ sessionId }` ([src/routes/run.ts](../../packages/agent-ingress/src/routes/run.ts)) +- [x] `/listen/:id` — SSE wired to `bus.subscribeEvents` + 15s heartbeat ([src/routes/listen.ts](../../packages/agent-ingress/src/routes/listen.ts)) +- [x] `/send/:id` — publishes `user_message` to `bus.publishInput` ([src/routes/send.ts](../../packages/agent-ingress/src/routes/send.ts)) +- [x] `/webhooks/:provider` — host check, generic signature verify, enqueue ([src/routes/webhooks.ts](../../packages/agent-ingress/src/routes/webhooks.ts)) +- [x] `/health`, `/status` +- [x] ESLint hard rule blocking Anthropic / Modal / nodejs imports ([.eslintrc.json](../../packages/agent-ingress/.eslintrc.json)) +- [x] Tests: `/health`, `/status`, `/run`, `/send` happy/sad paths with FakeQueue + InMemoryBus ([tests/server.test.ts](../../packages/agent-ingress/tests/server.test.ts)) +- [ ] Tests: webhook signature flow end-to-end +- [ ] Tests: `/listen` SSE flow (subscribe → publish → frame received) +- [ ] Tests: resolver LRU + TTL + invalidate +- [ ] Provider-specific webhook strategies (Stripe, Slack-style HMAC-with-timestamp) under the generic webhook_signature mode +- [ ] Per-team concurrent-session quota enforcement on `/run` +- [ ] `/run` rate limiter +- [ ] Promotion invalidation: settle on push-from-Django call to `resolver.invalidate(...)` vs TTL-only + +### packages/agent-runner/ (milestone 7 — orchestration solid, executor stubbed) + +- [x] Worker — dequeue, lock, heartbeat, reschedule on suspend, ack/fail on terminal ([src/worker.ts](../../packages/agent-runner/src/worker.ts)) +- [x] `SessionExecutor` interface + `ExecutorTurnInput/Output` shape ([src/executor.ts](../../packages/agent-runner/src/executor.ts)) +- [ ] **Real executor backed by Claude Agent SDK.** Currently `NotImplementedExecutor` ([src/executor-stub.ts](../../packages/agent-runner/src/executor-stub.ts)) returns a "not implemented" error. The real one must invoke the SDK, stream chunks, tick heartbeats, and return `tool_call | completed | failed | awaiting_input` per turn. +- [ ] State ↔ Claude Agent SDK `Message[]` / `ContentBlock` mapping. Today [src/state.ts](../../packages/agent-runner/src/state.ts) round-trips a generic `{role, content, at}` envelope. +- [x] Meta tools `complete`, `wait_for_input` ([src/tools/meta.ts](../../packages/agent-runner/src/tools/meta.ts)) +- [x] `http.fetch` builtin — real fetch with timeout ([src/tools/builtins.ts](../../packages/agent-runner/src/tools/builtins.ts)) +- [ ] `posthog.events.capture` builtin — currently logs to console; wire `posthog-node` + per-app credentials from secrets +- [ ] `posthog.feature_flags.evaluate` builtin — currently hardcoded false; wire to PostHog API +- [x] Tool registry + dispatch ([src/tools/registry.ts](../../packages/agent-runner/src/tools/registry.ts)) +- [x] Config (Anthropic key, queue DB, internal API, Redis) ([src/config.ts](../../packages/agent-runner/src/config.ts)) +- [x] Tests: state round-trip, tool dispatch, worker outcomes (`completed` / `failed` / `tool_call` / `awaiting_input` / pendingInputs flush) +- [ ] Tests: real Claude Agent SDK turn (gated on key + recorded fixtures) +- [ ] Secrets loader — [src/index.ts](../../packages/agent-runner/src/index.ts) `loadSecrets` returns `{}`; wire to `apiClient.decryptSecrets` once a tool actually needs them +- [ ] Runner-side reaper: queue janitor already resets stalled jobs; need a matching write to set `AgentSession.state = 'failed'` for the mirror row +- [ ] `AgentSession` mirror writes — direct DB vs internal API — coordinate with Django owner + +### Cross-package / system level + +- [ ] End-to-end integration test: ingress `/run` → queue → runner picks up → real Claude Agent SDK turn → tool call → completion → SSE frame delivered via `/listen` +- [ ] Observability + - [ ] OTel traces per session + per tool invocation + - [ ] Sentry tagging (`service: agent-ingress`, `service: agent-runner`) + - [ ] Structured-log fields (`app_id`, `revision_id`, `session_id`, `queue_job_id`) everywhere a request or job is logged +- [ ] `FEATURE_FLAGS.AGENTS` gating — decide whether ingress checks the flag or Django blocks at `resolve`. Pick one and document. +- [ ] k8s deploy manifests + HPA configs (ingress and runner as separate deployments) + +### Deferred (later milestones, intentionally not in this list) + +- Triggers (M9): cron, slack event ingestion — webhook endpoint exists; orchestrator still TBD +- Sandboxes (M8): Modal integration, custom-tool execution, sandbox lifecycle + reaper +- Bundle validator (M12): the fourth package `packages/agent-validator/` +- Skills + registry v2 (M13) + +--- + ## Runtime packages Three packages under `packages/`, each its own process / deployment: -``` +```text packages/ agent-core/ # shared types, db client, queue primitives, manifest reader agent-ingress/ # process: HTTP ingress, *.agents.posthog.com terminator @@ -99,15 +179,15 @@ A Claude Agent SDK run looks structurally identical to the CDP hog-flow executio cyclotron-v2 has solved exactly these problems in production for CDP. We **reimplement the concepts** in `agent-core`, copying the relevant code where it's cheaper than rebuilding, with no runtime dependency on `nodejs/src/cdp/services/cyclotron-v2/` or the `cyclotron_node` schema. -| cyclotron-v2 concept | Agent-core mirror | Reference (for copying) | -| --- | --- | --- | -| `JobState: available \| running \| completed \| failed \| canceled` | Same enum, drop-in for `AgentSession.state`. | [`rust/cyclotron-core/src/types.rs:10`](../../rust/cyclotron-core/src/types.rs) | -| `lock_id` + `last_heartbeat` + `FOR UPDATE SKIP LOCKED` dequeue | Same pattern. Runner owns a session via lock; heartbeats every N seconds while inside an SDK turn. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:88`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | -| `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | -| `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | -| Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | -| `queue_name` | Per-app or per-tier queue isolation. v1 = single queue. | | -| `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | +| cyclotron-v2 concept | Agent-core mirror | Reference (for copying) | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `JobState: available \| running \| completed \| failed \| canceled` | Same enum, drop-in for `AgentSession.state`. | [`rust/cyclotron-core/src/types.rs:10`](../../rust/cyclotron-core/src/types.rs) | +| `lock_id` + `last_heartbeat` + `FOR UPDATE SKIP LOCKED` dequeue | Same pattern. Runner owns a session via lock; heartbeats every N seconds while inside an SDK turn. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:88`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | +| `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | +| `queue_name` | Per-app or per-tier queue isolation. v1 = single queue. | | +| `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | Deliberately **not** carried over in v1: @@ -130,7 +210,7 @@ A separate Postgres DB owned by the agent-runtime — `agent_runtime_queue` (nam Mirror the [`products/deployments/`](../../products/deployments) scaffold from #58421: -``` +```text products/agents/ __init__.py product.yaml @@ -322,7 +402,7 @@ Resolutions to the agent-stack open questions + posthog-specific ones: Each shippable behind `FEATURE_FLAGS.AGENTS`. -1. **Scaffold + models.** `products/agents/` skeleton (mirror `products/deployments/`), Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. *(unblocks parallel work)* +1. **Scaffold + models.** `products/agents/` skeleton (mirror `products/deployments/`), Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. _(unblocks parallel work)_ 2. **Management API.** CRUD viewsets for apps/revisions/secrets/preview-bindings. Activity logging wired. `complete_upload` shortcut transitions straight to `ready`. 3. **Deploy flow.** `start_deploy` → presigned PUT → `complete_upload` (auto-ready) → `promote`. End-to-end via CLI. No async work. 4. **Internal API.** `resolve` + `decrypt` endpoints with internal scopes. mTLS / signed-key auth. From 2f31ae291446c8beefeccf99fa987e15420cc902 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 13 May 2026 21:34:50 -0400 Subject: [PATCH 006/517] init models --- docs/internal/agent-platform.md | 145 ++++---- products/agent_stack/backend/facade/api.py | 23 -- .../agent_stack/backend/facade/contracts.py | 20 -- products/agent_stack/backend/facade/enums.py | 17 +- .../agent_stack/backend/logic/__init__.py | 11 - .../backend/migrations/0001_initial.py | 312 ++++++++++++++++++ .../backend/migrations/__init__.py | 0 .../backend/migrations/max_migration.txt | 1 + products/agent_stack/backend/models.py | 190 ++++++++++- .../backend/presentation/serializers.py | 14 - .../agent_stack/backend/presentation/urls.py | 8 +- .../agent_stack/backend/presentation/views.py | 36 +- .../agent_stack/backend/tests/test_api.py | 21 -- products/agent_stack/package.json | 2 +- products/db_routing.yaml | 2 - 15 files changed, 573 insertions(+), 229 deletions(-) create mode 100644 products/agent_stack/backend/migrations/0001_initial.py create mode 100644 products/agent_stack/backend/migrations/__init__.py create mode 100644 products/agent_stack/backend/migrations/max_migration.txt diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index ccef5b1f846b..7e2d9ca32a53 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -6,7 +6,7 @@ Companion to [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/a Two things we own here: -1. **Management plane** — a new flag-gated product under `products/agents/` (Django app + viewsets + frontend), modelled on the existing [`products/deployments/`](../../products/deployments) scaffold from #58421. +1. **Management plane** — a new flag-gated product under `products/agent_stack/` (Django app + viewsets + frontend). 2. **Runtime** — three new TypeScript packages under `packages/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `packages/agent-core/`. The runtime split (ingress + runner) from the agent-stack doc still holds. This plan refines what each half looks like inside the posthog monorepo and which existing primitives we lean on conceptually (not by import). @@ -17,7 +17,7 @@ The runtime split (ingress + runner) from the agent-stack doc still holds. This Three packages under `packages/`, each its own process / deployment: -``` +```text packages/ agent-core/ # shared types, db client, queue primitives, manifest reader agent-ingress/ # process: HTTP ingress, *.agents.posthog.com terminator @@ -40,7 +40,7 @@ Cherry-pick what we want, leave the rest. The legacy concepts the agent-stack pl Shared library, no process of its own. Lives here: - TypeScript types for the session model, manifest, tool protocol, secrets. -- Postgres client(s) — one for the main posthog DB (read app/revision/secret rows; write `AgentSession`/`SandboxInstance` rows), one for the agent-runtime queue DB (jobs). Each package depends on whichever it needs. +- Postgres client(s) — one for the main posthog DB (read app/revision/encrypted_env rows; write `AgentApplicationSession`/`AgentApplicationSandboxInstance` rows), one for the agent-runtime queue DB (jobs). Each package depends on whichever it needs. - **Queue primitives** — the cyclotron-v2-shaped session queue (see next section). Single `cyclotron_jobs`-style table with `available | running | completed | failed | canceled`, `FOR UPDATE SKIP LOCKED` dequeue, `lock_id` + `last_heartbeat`, `reschedule({ scheduledAt, state })`, janitor loop. The schema and ops are a clean reimplementation in this package — we own it end-to-end, no shared migrations with `cyclotron_node`. - Internal-API HTTP client (talks to Django for resolve/decrypt). - Structured logger, Prom registry, OTel setup. @@ -54,7 +54,7 @@ The public-facing process. Responsibilities: - Domain → `(application, revision)` resolution via the Django internal `/internal/agents/applications/resolve` endpoint. In-process LRU keyed by revision id, invalidated on promotion (we expose a small admin endpoint for Django to ping after promote — or just rely on TTL, decide at impl time). - Per-app auth derived from the resolved revision's config (public / webhook signature / shared secret). - Implements `/run`, `/listen/:id`, `/send/:id`, `/webhooks/:provider`, `/health`, `/status`. Same contract as the SDK's local dev server. -- `/run` writes an `AgentSession` row + enqueues a session job in the agent-core queue, returns `{ session_id }` immediately. +- `/run` writes an `AgentApplicationSession` row + enqueues a session job in the agent-core queue, returns `{ session_id }` immediately. - `/listen` subscribes to the Redis pub-sub channel `agent_session:{id}` for SSE streaming. - `/send` publishes a message into `agent_session:{id}:input` — runner picks it up at the next yield. @@ -69,7 +69,7 @@ The session executor. Responsibilities: 3. Restores Claude Agent SDK state from the job's `state` payload. 4. Runs one "turn" — until the next tool boundary or completion. 5. Two cases: - - **Completion** → ack the job, write final `output` to `AgentSession`, publish completion to pub-sub. + - **Completion** → ack the job, write final `output` to `AgentApplicationSession`, publish completion to pub-sub. - **Suspension** (long-running tool, sandbox call, waiting on `/send`) → `reschedule({ scheduledAt, state: serialized_sdk_state })`. Heartbeats keep ticking while inside a turn so we don't get reaped mid-execution. 6. Streams events to the pub-sub bus throughout. @@ -81,14 +81,14 @@ Tool execution split: Sandbox manager: -- Looks up the live `SandboxInstance` row for `(application, revision)`. JIT-provisions on first request. +- Looks up the live `AgentApplicationSandboxInstance` row for `(application, revision)`. JIT-provisions on first request. - Updates `last_used_at` on each call. - Periodic reaper job (cooperative Postgres advisory lock) destroys sandboxes idle > TTL. Reaper: - Runs in the runner process. Two passes per tick: - 1. **Sessions** — the queue janitor resets stalled jobs; we additionally write `AgentSession.state = 'failed'` for any session whose job hit the poison-pill threshold. + 1. **Sessions** — the queue janitor resets stalled jobs; we additionally write `AgentApplicationSession.state = 'failed'` for any session whose job hit the poison-pill threshold. 2. **Sandboxes** — described above. --- @@ -99,22 +99,22 @@ A Claude Agent SDK run looks structurally identical to the CDP hog-flow executio cyclotron-v2 has solved exactly these problems in production for CDP. We **reimplement the concepts** in `agent-core`, copying the relevant code where it's cheaper than rebuilding, with no runtime dependency on `nodejs/src/cdp/services/cyclotron-v2/` or the `cyclotron_node` schema. -| cyclotron-v2 concept | Agent-core mirror | Reference (for copying) | -| --- | --- | --- | -| `JobState: available \| running \| completed \| failed \| canceled` | Same enum, drop-in for `AgentSession.state`. | [`rust/cyclotron-core/src/types.rs:10`](../../rust/cyclotron-core/src/types.rs) | -| `lock_id` + `last_heartbeat` + `FOR UPDATE SKIP LOCKED` dequeue | Same pattern. Runner owns a session via lock; heartbeats every N seconds while inside an SDK turn. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:88`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | -| `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | -| `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | -| Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | -| `parent_run_id` for batch grouping | Use for "trigger fanout" — one cron firing creates N sessions sharing a parent run id. | | -| `queue_name` + `priority` | Per-app or per-tier queue isolation. v1 = single queue; schema is open for v2 fairness work. | | -| `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | +| cyclotron-v2 concept | Agent-core mirror | Reference (for copying) | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `JobState: available \| running \| completed \| failed \| canceled` | Same enum, drop-in for `AgentApplicationSession.state`. | [`rust/cyclotron-core/src/types.rs:10`](../../rust/cyclotron-core/src/types.rs) | +| `lock_id` + `last_heartbeat` + `FOR UPDATE SKIP LOCKED` dequeue | Same pattern. Runner owns a session via lock; heartbeats every N seconds while inside an SDK turn. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:88`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| `state: BYTEA` payload | Persist Claude Agent SDK conversation/turn state between suspensions. | [`rust/cyclotron-node-migrations/20260303000001_initial_schema.sql:9`](../../rust/cyclotron-node-migrations/20260303000001_initial_schema.sql) | +| `reschedule({ scheduledAt, state })` | After every tool boundary, runner reschedules with updated state rather than blocking. | [`nodejs/src/cdp/services/cyclotron-v2/worker.ts:161`](../../nodejs/src/cdp/services/cyclotron-v2/worker.ts) | +| Janitor — stall recovery + poison-pill detection | New daemon inside agent-runner. | [`nodejs/src/cdp/services/cyclotron-v2/janitor.ts`](../../nodejs/src/cdp/services/cyclotron-v2/janitor.ts) | +| `parent_run_id` for batch grouping | Use for "trigger fanout" — one cron firing creates N sessions sharing a parent run id. | | +| `queue_name` + `priority` | Per-app or per-tier queue isolation. v1 = single queue; schema is open for v2 fairness work. | | +| `function_id` (UUID) field | Repurpose as `revision_id` for fast lookup of all sessions for a revision (promotion + reaper). | | What we add on top: - **Heartbeat-from-inside-the-SDK.** SDK tool callbacks and Anthropic streaming chunks tick the queue heartbeat. - **Session event bus.** The queue stores final state, not intermediate frames. SSE streaming lives in a Redis pub-sub keyed by `session_id`. Queue row + final-state blob is the durable record; the bus is best-effort. -- **`AgentSession` mirror in main posthog Postgres.** Queue rows live in the agent-runtime queue DB; the team-scoped mirror row in main posthog Postgres gives us FKs to `Team` / `AgentApplication` / `Revision`, activity log integration, and clean UI queries. +- **`AgentApplicationSession` mirror in main posthog Postgres.** Queue rows live in the agent-runtime queue DB; the team-scoped mirror row in main posthog Postgres gives us FKs to `Team` / `AgentApplication` / `AgentApplicationRevision`, activity log integration, and clean UI queries. ### Queue database @@ -122,12 +122,12 @@ A separate Postgres DB owned by the agent-runtime — `agent_runtime_queue` (nam --- -## Part A — `products/agents/` Django app +## Part A — `products/agent_stack/` Django app Mirror the [`products/deployments/`](../../products/deployments) scaffold from #58421: -``` -products/agents/ +```text +products/agent_stack/ __init__.py product.yaml manifest.tsx @@ -146,67 +146,69 @@ products/agents/ mcp/ # later ``` -Bootstrap with `bin/hogli product:bootstrap agents` per the [Products README](../../products/README.md), then customize. +Bootstrap with `bin/hogli product:bootstrap agent_stack` per the [Products README](../../products/README.md), then customize. Remove the `products/db_routing.yaml` entry the bootstrap adds — these models live in the main posthog DB so they can FK to `Team` / `User`. ### Models -All inherit `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)) — uuid7 PKs. All tenant-data models have `team_id` (FK to `posthog.Team`) per the CLAUDE.md rule; consider `ProductTeamModel` if the product ends up isolated. +All inherit `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)) — uuid7 PKs. All tenant-data models have `team_id` (FK to `posthog.Team`) per the CLAUDE.md rule. Models live on the main posthog Postgres DB (not an isolated product DB) so they keep real FKs to `Team` / `User`. All child-of-app models are namespaced with the `AgentApplication*` prefix. **`AgentApplication`** (team-scoped) -- `team: FK(Team)`, `name`, `slug` (unique — see open Q1), `description` -- `live_revision: FK(Revision, null=True)` — pointer-swap on promotion -- Soft delete (`deleted: bool`) +- `team: FK(Team)`, `name`, `slug` (unique — partial unique constraint where `deleted=False` so deleted slugs can be reclaimed), `description` +- `encrypted_env: EncryptedTextField` — raw `.env` contents uploaded by the developer, single encrypted blob. Plaintext never returned by the REST API after creation; decryption gated to the internal API, audit-logged per call. (Replaces a separate `AgentApplicationSecret` per-key model — single blob is enough for v1.) +- Soft delete (`deleted: bool`, `deleted_at`) - Activity-logged via `log_activity_from_viewset` ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) -**`Revision`** (immutable per deploy) +Note: there is **no `live_revision` FK** on the application. "Which revision is live" is a property of the revision itself (`deployment_status`, below). This keeps the FK graph acyclic and avoids a two-row update on promotion. + +**`AgentApplicationRevision`** (immutable per deploy) - `application: FK(AgentApplication)` -- `state: enum(pending_upload | uploaded | validating | ready | failed)` — **full state machine in the schema from day one**, even though v1 skips straight from `uploaded` to `ready` (see §C). +- `state: enum(pending_upload | uploaded | validating | ready | failed)` — **full state machine in the schema from day one**, even though v1 skips straight from `uploaded` to `ready` (see §C). Build/validation lifecycle. +- `deployment_status: enum(live | preview | disabled)` — orthogonal to `state`. How this revision serves traffic. + - Default `disabled`. Logic-layer rule: must be `state=ready` before promotion to `live` or `preview`. + - At-most-one `live` per application is enforced at the API layer, not the DB (lets promotion fail cleanly rather than via a unique-constraint violation). + - Promotion is a single-row update: set new revision `live`, demote previous `live` to `disabled`. - `bundle_s3_key`, `bundle_size`, `bundle_sha256` — content-hash binding for the presigned PUT. - `top_level_config: JSONField` — validated synchronously at deploy start by Django. - `parsed_manifest: JSONField(null=True)` — populated by the future validator package. v1 leaves this null and runner falls back to reading the bundle's `.ass.yaml` manifest section directly via `top_level_config`. - `validation_report: JSONField(null=True)` — structured errors when the future validator marks `failed`. - `created_by: FK(User)`, `created_at` -- Index: `(application_id, state, created_at desc)` for "list ready revisions". - -**`PreviewBinding`** - -- `application: FK(AgentApplication)`, `revision: FK(Revision)`, `subdomain_suffix: str` +- Indexes: `(application_id, state, created_at desc)` for "list ready revisions"; `(application_id, deployment_status)` for traffic resolution. -**`AgentApplicationSecret`** +**Preview deploys (no separate model)** -- `application: FK(AgentApplication)`, `name: str` (unique per app), `encrypted_value: EncryptedJSONStringField` -- `EncryptedJSONStringField` ([`posthog/helpers/encrypted_fields.py:137`](../../posthog/helpers/encrypted_fields.py)) — same pattern as `Integration.sensitive_config`. -- Plaintext never returned by REST API after creation. Decryption only via internal API, audit-logged per call. +`ass preview` sets `deployment_status = preview` on the revision; the ingress layer routes `-.agents.posthog.com` to that revision. (The original `PreviewBinding` model was dropped — the revision id is the suffix.) -**`AgentSession`** (mirror of queue job in main DB) +**`AgentApplicationSession`** (mirror of queue job in main DB) -- `team: FK(Team)`, `application: FK(AgentApplication)`, `revision: FK(Revision)` -- `queue_job_id: UUID` — points at the actual job in the agent-runtime queue DB -- `state: enum` — mirrors the queue's `JobState`. Updated by the runner on transition. +- `team: FK(Team)`, `application: FK(AgentApplication)`, `revision: FK(AgentApplicationRevision)` +- `queue_job_id: UUID(null=True, indexed)` — points at the actual job in the agent-runtime queue DB +- `parent_run_id: UUID(null=True, indexed)` — same id as the queue's `parent_run_id` for trigger fanouts +- `state: enum(available | running | completed | failed | canceled)` — mirrors the queue's `JobState`. Updated by the runner on transition. - `trigger_type: str`, `trigger_payload: JSONField` - `input: JSONField`, `output: JSONField(null=True)`, `error: JSONField(null=True)` -- `parent_run_id: UUID(null=True)` — same id as the queue's `parent_run_id` for trigger fanouts +- `runtime_instance: str` — identifier of the agent-runner instance currently owning the session - `started_at`, `last_heartbeat_at`, `completed_at` -- `runtime_instance: str(null=True)` — for attribution +- Indexes: `(application, state, created_at desc)` for the sessions UI; `(state, last_heartbeat_at)` for the reaper; `(parent_run_id)` for fanout queries. -**`SandboxInstance`** +**`AgentApplicationSandboxInstance`** -- `application: FK(AgentApplication)`, `revision: FK(Revision)` +- `team: FK(Team)`, `application: FK(AgentApplication)`, `revision: FK(AgentApplicationRevision)` - `modal_sandbox_id: str`, `state: enum(provisioning | ready | terminating | terminated)` -- `created_at`, `last_used_at`, `terminated_at` -- v1 = at most one per `(application, revision)`. No unique constraint at the DB level; enforced by runtime. +- `created_at`, `last_used_at`, `terminated_at`, `error_message` +- v1 = at most one per `(application, revision)`. No unique constraint at the DB level; enforced by runtime so v2 can grow concurrent sandboxes without a migration. +- Indexes: `(application, revision, state)` for lookup; `(state, last_used_at)` for the reaper. ### Migrations -Standard Django migrations under `products/agents/backend/migrations/`. Follow the [`django-migrations`](../../.claude/skills/django-migrations) skill — invoke it before writing the migration files. +Standard Django migrations under `products/agent_stack/backend/migrations/`. Follow the [`django-migrations`](../../.claude/skills/django-migrations) skill — invoke it before writing the migration files. ### API (DRF + OAuth) Invoke [`improving-drf-endpoints`](../../.claude/skills/improving-drf-endpoints) before writing viewsets/serializers — it covers `@validated_request`, `@extend_schema`, and the schema/typing pipeline that feeds frontend + MCP. -New scope objects: `agent_application`, `agent_secret`. Add to [`posthog/scopes.py:16`](../../posthog/scopes.py). +New scope object: `agent_application`. `encrypted_env` write access is gated by the same scope; there is no separate `agent_secret` scope since secrets aren't a standalone resource. Add to [`posthog/scopes.py:16`](../../posthog/scopes.py). Viewsets follow `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_function.py:469`](../../posthog/api/hog_function.py)). @@ -214,17 +216,16 @@ Endpoints (project-scoped `/api/projects/{team_id}/...`): - `agent_applications/` — CRUD + soft delete - `POST /:id/start_deploy` → `{ revision_id, presigned_put_url, expires_at, max_size, required_sha256 }` - - `POST /:id/complete_upload` → **v1: synchronously transition the revision to `ready`** (skipping `validating`). Logged so we know which revisions never went through real validation when the validator lands. - - `POST /:id/promote` → swap `live_revision` to a `ready` revision -- `revisions/` — list + retrieve (read-only) -- `preview_bindings/` — CRUD -- `agent_application_secrets/` — create/list/delete (no plaintext read) -- `agent_sessions/` — list + retrieve. Filters: `application_id`, `state`, `parent_run_id`, time range. + - `POST /:id/complete_upload` → **v1: synchronously transition the revision to `state=ready`** (skipping `validating`). Logged so we know which revisions never went through real validation when the validator lands. + - `POST /:id/promote` → atomically set the target revision's `deployment_status=live` and demote any prior live revision to `disabled`. Validates the target is `state=ready`. + - `PUT /:id/env` — replace `encrypted_env`. No plaintext read; response omits the field. +- `agent_application_revisions/` — list + retrieve (read-only). Filter by `deployment_status` for "find live", "list previews". +- `agent_application_sessions/` — list + retrieve. Filters: `application_id`, `state`, `parent_run_id`, time range. **Internal-only endpoints** (called by `agent-ingress` and `agent-runner`): -- `GET /internal/agents/applications/resolve` — given a domain or app id, returns the live revision + manifest. Cacheable ~5s. -- `POST /internal/agents/secrets/{app_id}/decrypt` — returns plaintext for a named set of secrets. Audit-logged. Separate internal scope, not exposed in OAuth UI. +- `GET /internal/agents/applications/resolve` — given a domain or app id, returns the live revision + manifest. For preview subdomains (`-`) the suffix is the revision id. Cacheable ~5s. +- `POST /internal/agents/applications/{app_id}/decrypt_env` — returns plaintext `encrypted_env`. Audit-logged. Separate internal scope, not exposed in OAuth UI. Add to `INTERNAL_API_SCOPE_OBJECTS` ([`posthog/scopes.py:121`](../../posthog/scopes.py)) so they don't appear in PAT creation flows. @@ -235,8 +236,8 @@ Mirror [`products/deployments/manifest.tsx`](../../products/deployments/manifest v1 scenes: - `AgentApplications` (list) -- `AgentApplication` (detail: revisions, secrets, sessions, sandbox state tabs) -- `AgentSession` (single-session inspection) +- `AgentApplication` (detail: revisions, env, sessions, sandbox state tabs) +- `AgentApplicationSession` (single-session inspection) Use the [`scene-menu-bar`](../../.claude/skills/scene-menu-bar) and [`making-scenes-tab-aware`](../../.claude/skills/making-scenes-tab-aware) conventions for tabs. @@ -253,7 +254,7 @@ CLI is the primary deploy surface in v1; this UI is management + observability. ## Part B — Deploy flow (v1, no async validator) 1. CLI bundles the project locally. -2. CLI calls Django `start_deploy` with the parsed top-level config. Django validates synchronously (schema-level checks on `.ass.yaml` and triggers) and creates a `Revision` row in `pending_upload`. +2. CLI calls Django `start_deploy` with the parsed top-level config. Django validates synchronously (schema-level checks on `.ass.yaml` and triggers) and creates an `AgentApplicationRevision` row in `state=pending_upload`. 3. Django returns a presigned S3 PUT URL bound to size + content hash. 4. CLI uploads the bundle to S3. 5. CLI calls `complete_upload`. @@ -289,10 +290,10 @@ Pure-function validators (`(bytes) -> (parsed, errors)`) inside the validator pa - **DBs**: - Agent-runtime queue gets its own Postgres DB (`agent_runtime_queue`). Not shared with `cyclotron_node`. Owned by `agent-core` migrations. - - `AgentSession` and `SandboxInstance` mirrors live in main posthog Postgres (team-scoped, FKs, activity log eligible). - - Runner writes to both — queue row is the work item, `AgentSession` is the user-visible record. + - `AgentApplicationSession` and `AgentApplicationSandboxInstance` mirrors live in main posthog Postgres (team-scoped, FKs, activity log eligible). + - Runner writes to both — queue row is the work item, `AgentApplicationSession` is the user-visible record. - **S3 bucket**: new `posthog-agent-bundles-{env}`, KMS-encrypted, lifecycle expires non-`ready` bundles after 7 days. Use [`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py) helpers from Django. -- **Secrets**: `EncryptedJSONStringField` (same key schedule as `Integration`). Decrypt only in `agent-runner` via the internal API. +- **Secrets**: `AgentApplication.encrypted_env` is an `EncryptedTextField` (same key schedule as `Integration.sensitive_config`). Decrypt only in `agent-runner` via the internal API. - **Per-team quotas**: enforced on Django writes (apps, secrets, revisions/day) and at `agent-ingress` (concurrent sessions per app, `/run` rate limit). Surface limits in the UI. - **Observability**: structured logs with `app_id` / `revision_id` / `session_id` / `queue_job_id`; OTel traces per session and per tool call; Prometheus metrics; Sentry tagged separately for `agent-ingress` and `agent-runner`. - **Feature flag**: `FEATURE_FLAGS.AGENTS` gates the product (frontend + API + ingress). Per-team rollout. @@ -319,19 +320,19 @@ Resolutions to the agent-stack open questions + posthog-specific ones: Each shippable behind `FEATURE_FLAGS.AGENTS`. -1. **Scaffold + models.** `products/agents/` skeleton (mirror `products/deployments/`), Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. *(unblocks parallel work)* -2. **Management API.** CRUD viewsets for apps/revisions/secrets/preview-bindings. Activity logging wired. `complete_upload` shortcut transitions straight to `ready`. +1. **Scaffold + models.** `products/agent_stack/` skeleton, Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. _(unblocks parallel work)_ +2. **Management API.** CRUD viewsets for apps and revisions. Env upload endpoint. Activity logging wired. `complete_upload` shortcut transitions straight to `state=ready`. Promote endpoint flips `deployment_status`. 3. **Deploy flow.** `start_deploy` → presigned PUT → `complete_upload` (auto-ready) → `promote`. End-to-end via CLI. No async work. -4. **Internal API.** `resolve` + `decrypt` endpoints with internal scopes. mTLS / signed-key auth. +4. **Internal API.** `resolve` + `decrypt_env` endpoints with internal scopes. mTLS / signed-key auth. 5. **`packages/agent-core/`.** Types, DB clients, queue primitives (schema + ops), pub-sub helper, internal-API client, logger/metrics. No process; tested in isolation. -6. **`packages/agent-ingress/`.** Domain resolution, `/run` writes `AgentSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. +6. **`packages/agent-ingress/`.** Domain resolution, `/run` writes `AgentApplicationSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. 7. **`packages/agent-runner/` — meta + built-in tools.** Queue consumer. Real Claude Agent SDK invocation. State serialized into queue `state`, reschedule loop on tool boundaries. Built-ins registry shared with `agent-core`. -8. **Sandboxes.** Modal integration, custom-tool execution, sandbox lifecycle + reaper. `SandboxInstance` writes from the runner. +8. **Sandboxes.** Modal integration, custom-tool execution, sandbox lifecycle + reaper. `AgentApplicationSandboxInstance` writes from the runner. 9. **Triggers.** Webhooks, cron, slack event ingestion. -10. **Frontend.** App list, app detail (revisions/secrets/sessions/sandbox tabs), session detail. -11. **Preview deploys, observability polish, quotas.** +10. **Frontend.** App list, app detail (revisions/env/sessions/sandbox tabs), session detail. +11. **Preview deploys (set `deployment_status=preview`), observability polish, quotas.** 12. **`packages/agent-validator/`.** Async bundle validator. Pure-function checks reusable from the CLI. Flip `complete_upload` to enqueue validation instead of auto-ready. -13. **Skills + registry v2** (publish flow, third-party tool publishing). Reuses the same Revision-style immutable artifacts. +13. **Skills + registry v2** (publish flow, third-party tool publishing). Reuses the same immutable revision artifacts. --- @@ -340,4 +341,4 @@ Each shippable behind `FEATURE_FLAGS.AGENTS`. - agent-stack plan: [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/agent-stack/blob/main/docs/agent-platform.md) - Reference scaffold: [`products/deployments/`](../../products/deployments) (#58421) - cyclotron-v2 (reference only, not a dependency): [`rust/cyclotron-core/src/`](../../rust/cyclotron-core/src/), [`nodejs/src/cdp/services/cyclotron-v2/`](../../nodejs/src/cdp/services/cyclotron-v2/) -- Patterns to mirror in Django: `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)), `EncryptedJSONStringField` ([`posthog/helpers/encrypted_fields.py:137`](../../posthog/helpers/encrypted_fields.py)), `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_function.py:469`](../../posthog/api/hog_function.py)), `object_storage` presigned helpers ([`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py)), activity logging ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) +- Patterns to mirror in Django: `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/utils.py)), `EncryptedTextField` ([`posthog/helpers/encrypted_fields.py:113`](../../posthog/helpers/encrypted_fields.py)), `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_function.py:469`](../../posthog/api/hog_function.py)), `object_storage` presigned helpers ([`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py)), activity logging ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) diff --git a/products/agent_stack/backend/facade/api.py b/products/agent_stack/backend/facade/api.py index b94797a37c3e..16ac2c8009c5 100644 --- a/products/agent_stack/backend/facade/api.py +++ b/products/agent_stack/backend/facade/api.py @@ -7,26 +7,3 @@ """ from __future__ import annotations - -from .. import logic -from ..models import SplineReticulator -from . import contracts -from .enums import SplineStatus - - -def _to_dto(obj: SplineReticulator) -> contracts.SplineReticulatorDTO: - return contracts.SplineReticulatorDTO( - id=obj.id, - name=obj.name, - status=SplineStatus(obj.status), - created_at=obj.created_at, - ) - - -def create(input: contracts.CreateSplineReticulatorInput) -> contracts.SplineReticulatorDTO: - obj = logic.create_spline_reticulator(team_id=input.team_id, name=input.name) - return _to_dto(obj) - - -def list_all() -> list[contracts.SplineReticulatorDTO]: - return [_to_dto(obj) for obj in logic.list_spline_reticulators()] diff --git a/products/agent_stack/backend/facade/contracts.py b/products/agent_stack/backend/facade/contracts.py index c815a4ba6041..409f3ac683ff 100644 --- a/products/agent_stack/backend/facade/contracts.py +++ b/products/agent_stack/backend/facade/contracts.py @@ -4,23 +4,3 @@ Frozen dataclasses that define what this product exposes. No Django imports. Used by facade as inputs/outputs. """ - -from dataclasses import dataclass -from datetime import datetime -from uuid import UUID - -from .enums import SplineStatus - - -@dataclass(frozen=True) -class SplineReticulatorDTO: - id: UUID - name: str - status: SplineStatus - created_at: datetime - - -@dataclass(frozen=True) -class CreateSplineReticulatorInput: - team_id: int - name: str diff --git a/products/agent_stack/backend/facade/enums.py b/products/agent_stack/backend/facade/enums.py index 39015939729d..65d09a867d3e 100644 --- a/products/agent_stack/backend/facade/enums.py +++ b/products/agent_stack/backend/facade/enums.py @@ -17,17 +17,22 @@ class RevisionState(StrEnum): FAILED = "failed" +class DeploymentStatus(StrEnum): + LIVE = "live" + PREVIEW = "preview" + DISABLED = "disabled" + + class SessionState(StrEnum): - PENDING = "pending" + AVAILABLE = "available" RUNNING = "running" - SUCCEEDED = "succeeded" + COMPLETED = "completed" FAILED = "failed" - CANCELLED = "cancelled" + CANCELED = "canceled" class SandboxState(StrEnum): PROVISIONING = "provisioning" READY = "ready" - DESTROYING = "destroying" - DESTROYED = "destroyed" - FAILED = "failed" + TERMINATING = "terminating" + TERMINATED = "terminated" diff --git a/products/agent_stack/backend/logic/__init__.py b/products/agent_stack/backend/logic/__init__.py index f458c3799292..0d790a636416 100644 --- a/products/agent_stack/backend/logic/__init__.py +++ b/products/agent_stack/backend/logic/__init__.py @@ -1,14 +1,3 @@ """Business logic for agent_stack.""" from __future__ import annotations - -from ..facade.enums import SplineStatus -from ..models import SplineReticulator - - -def create_spline_reticulator(*, team_id: int, name: str) -> SplineReticulator: - return SplineReticulator.objects.create(team_id=team_id, name=name, status=SplineStatus.PENDING) - - -def list_spline_reticulators() -> list[SplineReticulator]: - return list(SplineReticulator.objects.all()) diff --git a/products/agent_stack/backend/migrations/0001_initial.py b/products/agent_stack/backend/migrations/0001_initial.py new file mode 100644 index 000000000000..7e145cd76ee1 --- /dev/null +++ b/products/agent_stack/backend/migrations/0001_initial.py @@ -0,0 +1,312 @@ +# Generated by Django 5.2.13 on 2026-05-14 01:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import posthog.models.utils +import posthog.helpers.encrypted_fields + +import products.agent_stack.backend.facade.enums + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("posthog", "1152_fix_device_bucketing_persist_across_auth"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AgentApplication", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255)), + ("slug", models.CharField(max_length=63)), + ("description", models.TextField(blank=True, default="")), + ( + "encrypted_env", + posthog.helpers.encrypted_fields.EncryptedTextField(blank=True, default=""), + ), + ("deleted", models.BooleanField(default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team"), + ), + ], + ), + migrations.CreateModel( + name="AgentApplicationRevision", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "state", + models.CharField( + choices=[ + ("pending_upload", "pending_upload"), + ("uploaded", "uploaded"), + ("validating", "validating"), + ("ready", "ready"), + ("failed", "failed"), + ], + default=products.agent_stack.backend.facade.enums.RevisionState["PENDING_UPLOAD"], + max_length=32, + ), + ), + ( + "deployment_status", + models.CharField( + choices=[ + ("live", "live"), + ("preview", "preview"), + ("disabled", "disabled"), + ], + default=products.agent_stack.backend.facade.enums.DeploymentStatus["DISABLED"], + max_length=32, + ), + ), + ( + "bundle_s3_key", + models.CharField(blank=True, default="", max_length=512), + ), + ("bundle_size", models.BigIntegerField(blank=True, null=True)), + ( + "bundle_sha256", + models.CharField(blank=True, default="", max_length=64), + ), + ("top_level_config", models.JSONField(default=dict)), + ("parsed_manifest", models.JSONField(blank=True, null=True)), + ("validation_report", models.JSONField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="revisions", + to="agent_stack.agentapplication", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team"), + ), + ], + ), + migrations.CreateModel( + name="AgentApplicationSandboxInstance", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "modal_sandbox_id", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "state", + models.CharField( + choices=[ + ("provisioning", "provisioning"), + ("ready", "ready"), + ("terminating", "terminating"), + ("terminated", "terminated"), + ], + default=products.agent_stack.backend.facade.enums.SandboxState["PROVISIONING"], + max_length=32, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ("terminated_at", models.DateTimeField(blank=True, null=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sandbox_instances", + to="agent_stack.agentapplication", + ), + ), + ( + "revision", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="agent_stack.agentapplicationrevision", + ), + ), + ( + "team", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team"), + ), + ], + ), + migrations.CreateModel( + name="AgentApplicationSession", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "queue_job_id", + models.UUIDField(blank=True, db_index=True, null=True), + ), + ( + "parent_run_id", + models.UUIDField(blank=True, db_index=True, null=True), + ), + ( + "state", + models.CharField( + choices=[ + ("available", "available"), + ("running", "running"), + ("completed", "completed"), + ("failed", "failed"), + ("canceled", "canceled"), + ], + default=products.agent_stack.backend.facade.enums.SessionState["AVAILABLE"], + max_length=32, + ), + ), + ( + "trigger_type", + models.CharField(blank=True, default="", max_length=64), + ), + ("trigger_payload", models.JSONField(blank=True, default=dict)), + ("input", models.JSONField(blank=True, default=dict)), + ("output", models.JSONField(blank=True, null=True)), + ("error", models.JSONField(blank=True, null=True)), + ( + "runtime_instance", + models.CharField(blank=True, default="", max_length=255), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("last_heartbeat_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="agent_stack.agentapplication", + ), + ), + ( + "revision", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="agent_stack.agentapplicationrevision", + ), + ), + ( + "team", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team"), + ), + ], + ), + migrations.AddIndex( + model_name="agentapplication", + index=models.Index(fields=["team", "deleted"], name="agent_stack_team_id_9edc9f_idx"), + ), + migrations.AddConstraint( + model_name="agentapplication", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("slug",), + name="agent_stack_application_unique_active_slug", + ), + ), + migrations.AddIndex( + model_name="agentapplicationrevision", + index=models.Index( + fields=["application", "state", "-created_at"], + name="agent_stack_revision_app_state", + ), + ), + migrations.AddIndex( + model_name="agentapplicationrevision", + index=models.Index( + fields=["application", "deployment_status"], + name="agent_stack_rev_app_deploy", + ), + ), + migrations.AddIndex( + model_name="agentapplicationsandboxinstance", + index=models.Index( + fields=["application", "revision", "state"], + name="agent_stack_sandbox_lookup", + ), + ), + migrations.AddIndex( + model_name="agentapplicationsandboxinstance", + index=models.Index(fields=["state", "last_used_at"], name="agent_stack_sandbox_reaper"), + ), + migrations.AddIndex( + model_name="agentapplicationsession", + index=models.Index( + fields=["application", "state", "-created_at"], + name="agent_stack_session_app_state", + ), + ), + migrations.AddIndex( + model_name="agentapplicationsession", + index=models.Index(fields=["state", "last_heartbeat_at"], name="agent_stack_session_reaper"), + ), + migrations.AddIndex( + model_name="agentapplicationsession", + index=models.Index(fields=["parent_run_id"], name="agent_stack_session_parent_run"), + ), + ] diff --git a/products/agent_stack/backend/migrations/__init__.py b/products/agent_stack/backend/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/migrations/max_migration.txt b/products/agent_stack/backend/migrations/max_migration.txt new file mode 100644 index 000000000000..cbab66dde92a --- /dev/null +++ b/products/agent_stack/backend/migrations/max_migration.txt @@ -0,0 +1 @@ +0001_initial diff --git a/products/agent_stack/backend/models.py b/products/agent_stack/backend/models.py index 0f6ba6f462a5..1fc5e7442f26 100644 --- a/products/agent_stack/backend/models.py +++ b/products/agent_stack/backend/models.py @@ -1,30 +1,186 @@ -""" -Django models for agent_stack. +"""Django models for agent_stack.""" -Keep models thin — business logic belongs in logic/. -Use types from facade/enums.py where applicable. -Avoid ForeignKeys to models outside this app; if needed, -disallow reverse relations with related_name='+'. -""" - -import uuid +from __future__ import annotations from django.db import models -from posthog.models.scoping.product_mixin import ProductTeamModel +from posthog.helpers.encrypted_fields import EncryptedTextField +from posthog.models.utils import UUIDModel -from .facade.enums import SplineStatus +from .facade.enums import DeploymentStatus, RevisionState, SandboxState, SessionState -class SplineReticulator(ProductTeamModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) +class AgentApplication(UUIDModel): + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) name = models.CharField(max_length=255) - status = models.CharField( + slug = models.CharField(max_length=63) + description = models.TextField(blank=True, default="") + + # Raw .env contents uploaded by the developer. Plaintext never returned by the + # public API after creation; decryption is gated to the internal API used by + # agent-runner, audit-logged per call. + encrypted_env: EncryptedTextField = EncryptedTextField(blank=True, default="") + + deleted = models.BooleanField(default=False) + deleted_at = models.DateTimeField(null=True, blank=True) + + created_by = models.ForeignKey("posthog.User", on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + # Slug is the routing subdomain prefix — globally unique among live apps. + # Partial uniqueness lets a deleted app's slug be reclaimed. + models.UniqueConstraint( + fields=["slug"], + condition=models.Q(deleted=False), + name="agent_stack_application_unique_active_slug", + ), + ] + indexes = [ + models.Index(fields=["team", "deleted"]), + ] + + def __str__(self) -> str: + return self.slug + + +class AgentApplicationRevision(UUIDModel): + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) + application = models.ForeignKey(AgentApplication, on_delete=models.CASCADE, related_name="revisions") + + state = models.CharField( + max_length=32, + choices=[(s.value, s.value) for s in RevisionState], + default=RevisionState.PENDING_UPLOAD, + ) + + # How this revision serves traffic. Independent of `state` — a revision must reach + # state=ready before logic promotes it to LIVE or PREVIEW. Uniqueness of LIVE per + # application is enforced at the logic layer, not in the DB. + deployment_status = models.CharField( max_length=32, - choices=[(s.value, s.value) for s in SplineStatus], - default=SplineStatus.PENDING, + choices=[(s.value, s.value) for s in DeploymentStatus], + default=DeploymentStatus.DISABLED, ) + + # Content-hash binding for the presigned PUT. + bundle_s3_key = models.CharField(max_length=512, blank=True, default="") + bundle_size = models.BigIntegerField(null=True, blank=True) + bundle_sha256 = models.CharField(max_length=64, blank=True, default="") + + # Validated synchronously at deploy start. + top_level_config = models.JSONField(default=dict) + # Populated by the async validator; null until then. v1 reads top_level_config directly. + parsed_manifest = models.JSONField(null=True, blank=True) + # Structured errors when state=failed. + validation_report = models.JSONField(null=True, blank=True) + + created_by = models.ForeignKey("posthog.User", on_delete=models.SET_NULL, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index( + fields=["application", "state", "-created_at"], + name="agent_stack_revision_app_state", + ), + models.Index( + fields=["application", "deployment_status"], + name="agent_stack_rev_app_deploy", + ), + ] + + def __str__(self) -> str: + return f"{self.application_id}:{self.id} ({self.state})" + + +class AgentApplicationSession(UUIDModel): + """Main-DB mirror of a job in the agent-runtime queue. + + Queue rows live in the agent-runtime queue DB. This row is the team-scoped, + UI-queryable, FK-having record. The runner keeps both in sync — state + transitions mirror queue JobState. + """ + + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) + application = models.ForeignKey(AgentApplication, on_delete=models.CASCADE, related_name="sessions") + revision = models.ForeignKey(AgentApplicationRevision, on_delete=models.CASCADE) + + # Points at the job in the agent-runtime queue DB. Null until the runner enqueues. + queue_job_id = models.UUIDField(null=True, blank=True, db_index=True) + # Same id as the queue's parent_run_id — groups sessions fanned out by one trigger firing. + parent_run_id = models.UUIDField(null=True, blank=True, db_index=True) + + state = models.CharField( + max_length=32, + choices=[(s.value, s.value) for s in SessionState], + default=SessionState.AVAILABLE, + ) + + trigger_type = models.CharField(max_length=64, blank=True, default="") + trigger_payload = models.JSONField(default=dict, blank=True) + + input = models.JSONField(default=dict, blank=True) + output = models.JSONField(null=True, blank=True) + error = models.JSONField(null=True, blank=True) + + # Identifier of the agent-runner instance that currently owns the session. + runtime_instance = models.CharField(max_length=255, blank=True, default="") + + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + last_heartbeat_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["application", "state", "-created_at"], name="agent_stack_session_app_state"), + models.Index(fields=["state", "last_heartbeat_at"], name="agent_stack_session_reaper"), + models.Index(fields=["parent_run_id"], name="agent_stack_session_parent_run"), + ] + + def __str__(self) -> str: + return f"session:{self.id} ({self.state})" + + +class AgentApplicationSandboxInstance(UUIDModel): + """Modal sandbox tracker for (application, revision). + + v1 = at most one per (application, revision); not enforced at the DB level + so v2 can grow concurrent sandboxes without a migration. + """ + + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) + application = models.ForeignKey(AgentApplication, on_delete=models.CASCADE, related_name="sandbox_instances") + revision = models.ForeignKey(AgentApplicationRevision, on_delete=models.CASCADE) + + modal_sandbox_id = models.CharField(max_length=255, blank=True, default="") + state = models.CharField( + max_length=32, + choices=[(s.value, s.value) for s in SandboxState], + default=SandboxState.PROVISIONING, + ) + + error_message = models.TextField(blank=True, default="") + + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + terminated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index( + fields=["application", "revision", "state"], + name="agent_stack_sandbox_lookup", + ), + models.Index( + fields=["state", "last_used_at"], + name="agent_stack_sandbox_reaper", + ), + ] def __str__(self) -> str: - return self.name + return f"sandbox:{self.modal_sandbox_id or self.id} ({self.state})" diff --git a/products/agent_stack/backend/presentation/serializers.py b/products/agent_stack/backend/presentation/serializers.py index f2c800f672fd..850432b21477 100644 --- a/products/agent_stack/backend/presentation/serializers.py +++ b/products/agent_stack/backend/presentation/serializers.py @@ -1,15 +1 @@ """DRF serializers for agent_stack.""" - -from rest_framework import serializers -from rest_framework_dataclasses.serializers import DataclassSerializer - -from ..facade.contracts import SplineReticulatorDTO - - -class SplineReticulatorSerializer(DataclassSerializer): - class Meta: - dataclass = SplineReticulatorDTO - - -class CreateSplineReticulatorSerializer(serializers.Serializer): - name = serializers.CharField(max_length=255, help_text="Name of the spline to reticulate.") diff --git a/products/agent_stack/backend/presentation/urls.py b/products/agent_stack/backend/presentation/urls.py index e2773e2608fa..99941dd29f92 100644 --- a/products/agent_stack/backend/presentation/urls.py +++ b/products/agent_stack/backend/presentation/urls.py @@ -1,9 +1,3 @@ """URL routes for agent_stack.""" -from rest_framework.routers import DefaultRouter - -from .views import SplineReticulatorViewSet - -router = DefaultRouter() -router.register(r"spline_reticulators", SplineReticulatorViewSet, basename="spline_reticulators") -urlpatterns = router.urls +urlpatterns: list = [] diff --git a/products/agent_stack/backend/presentation/views.py b/products/agent_stack/backend/presentation/views.py index 958c6e563ed3..edf0e0d35918 100644 --- a/products/agent_stack/backend/presentation/views.py +++ b/products/agent_stack/backend/presentation/views.py @@ -1,35 +1 @@ -""" -DRF views for agent_stack. - -Validate JSON via serializers, call facade methods, -return serialized responses. No business logic here. -""" - -from drf_spectacular.utils import extend_schema -from rest_framework import status, viewsets -from rest_framework.request import Request -from rest_framework.response import Response - -from posthog.api.routing import TeamAndOrgViewSetMixin - -from ..facade import api, contracts -from .serializers import CreateSplineReticulatorSerializer, SplineReticulatorSerializer - - -class SplineReticulatorViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): - scope_object = "INTERNAL" - - @extend_schema(responses={200: SplineReticulatorSerializer(many=True)}) - def list(self, request: Request, **kwargs) -> Response: - items = api.list_all() - return Response(SplineReticulatorSerializer(items, many=True).data) - - @extend_schema(request=CreateSplineReticulatorSerializer, responses={201: SplineReticulatorSerializer}) - def create(self, request: Request, **kwargs) -> Response: - serializer = CreateSplineReticulatorSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - dto = api.create(contracts.CreateSplineReticulatorInput( - team_id=self.team_id, - **serializer.validated_data, - )) - return Response(SplineReticulatorSerializer(dto).data, status=status.HTTP_201_CREATED) +"""DRF views for agent_stack.""" diff --git a/products/agent_stack/backend/tests/test_api.py b/products/agent_stack/backend/tests/test_api.py index 0ab2a7213791..e69de29bb2d1 100644 --- a/products/agent_stack/backend/tests/test_api.py +++ b/products/agent_stack/backend/tests/test_api.py @@ -1,21 +0,0 @@ -from uuid import UUID - -import pytest - -from products.agent_stack.backend.facade import api -from products.agent_stack.backend.facade.contracts import CreateSplineReticulatorInput -from products.agent_stack.backend.facade.enums import SplineStatus - - -@pytest.mark.django_db -class TestSplineReticulatorAPI: - def test_create_and_list(self, team): - dto = api.create(CreateSplineReticulatorInput(team_id=team.id, name="test-spline")) - - assert isinstance(dto.id, UUID) - assert dto.name == "test-spline" - assert dto.status == SplineStatus.PENDING - - all_items = api.list_all() - assert len(all_items) == 1 - assert all_items[0].id == dto.id diff --git a/products/agent_stack/package.json b/products/agent_stack/package.json index a3edbf0a0b50..3d9d774a17db 100644 --- a/products/agent_stack/package.json +++ b/products/agent_stack/package.json @@ -1,5 +1,5 @@ { - "name": "@posthog/products-agent_stack", + "name": "@posthog/products-agent-stack", "scripts": { "backend:test": "pytest -c ../../pytest.ini --rootdir ../.. backend/tests -v --tb=short", "backend:contract-check": "echo 'Contract files unchanged'" diff --git a/products/db_routing.yaml b/products/db_routing.yaml index 64819c76353c..baac59dbb2c3 100644 --- a/products/db_routing.yaml +++ b/products/db_routing.yaml @@ -3,5 +3,3 @@ routes: database: visual_review - app_label: warehouse_sources_queue database: warehouse_sources_queue - - app_label: agent_stack - database: agent_stack From a0e79e02661cd7199ea0f55f906c892e49969815 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 13 May 2026 21:58:06 -0400 Subject: [PATCH 007/517] chore(agents): relocate agent runtime to services/, share eslint+prettier, inline tests - Move agent-{core,ingress,runner} from packages/ to services/ to sit alongside the other deployed services (oauth-proxy, mcp, stripe-app). - Share eslint + prettier base configs out of services/agent-core/, mirroring nodejs/.eslintrc.js and nodejs/.prettierrc so the agent platform tracks the same style. Ingress keeps its blast-radius restricted-imports rule on top. - Add lint/format/lint:fix/format:check scripts to each service. - Move all test files alongside their source (src/**/*.test.ts) and switch jest testMatch + tsconfig accordingly. tsconfig.json now excludes src/**/*.test.ts from the production build. - Refactor a handful of unnecessarily-async methods in in-memory bus, runner meta tools, and builtin stubs to satisfy @typescript-eslint/require-await. - Update docs/internal/agent-platform.md path references. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/internal/agent-platform.md | 104 ++-- packages/agent-ingress/.eslintrc.json | 20 - pnpm-lock.yaml | 463 +++++++++--------- pnpm-workspace.yaml | 6 +- services/agent-core/.eslintrc.cjs | 10 + {packages => services}/agent-core/.gitignore | 0 services/agent-core/.prettierrc.cjs | 1 + {packages => services}/agent-core/README.md | 0 .../agent-core/bin/migrate.ts | 3 +- services/agent-core/eslint.config.base.cjs | 63 +++ .../agent-core/jest.config.js | 2 +- .../migrations/0001_initial_schema.sql | 0 .../agent-core/package.json | 16 +- services/agent-core/prettier.config.base.cjs | 35 ++ .../agent-core/src/builtins/index.test.ts | 6 +- .../agent-core/src/builtins/index.ts | 0 .../agent-core/src/index.ts | 0 .../agent-core/src/internal-api/client.ts | 0 .../agent-core/src/internal-api/index.ts | 0 .../agent-core/src/internal-api/types.ts | 0 .../agent-core/src/logger.ts | 0 .../agent-core/src/manifest/index.test.ts | 2 +- .../agent-core/src/manifest/index.ts | 2 +- .../agent-core/src/metrics.ts | 0 .../agent-core/src/pubsub/in-memory.test.ts | 2 +- .../agent-core/src/pubsub/in-memory.ts | 23 +- .../agent-core/src/pubsub/index.ts | 0 .../agent-core/src/pubsub/redis.ts | 0 .../agent-core/src/pubsub/types.ts | 0 .../agent-core/src/queue/index.ts | 5 +- .../agent-core/src/queue/janitor.ts | 0 .../agent-core/src/queue/manager.ts | 0 .../agent-core/src/queue}/queue.test.ts | 27 +- .../agent-core/src/queue/types.ts | 0 .../agent-core/src/queue/worker.ts | 0 services/agent-core/tsconfig.eslint.json | 9 + .../agent-core}/tsconfig.json | 2 +- .../agent-core}/tsconfig.test.json | 2 +- services/agent-ingress/.eslintrc.cjs | 33 ++ .../agent-ingress/.gitignore | 0 services/agent-ingress/.prettierrc.cjs | 1 + .../agent-ingress/README.md | 0 .../agent-ingress}/jest.config.js | 2 +- .../agent-ingress/package.json | 14 +- .../agent-ingress/src/auth.ts | 2 +- .../agent-ingress/src/config.ts | 0 .../agent-ingress/src/index.ts | 4 +- .../agent-ingress/src/resolver.ts | 3 +- .../agent-ingress/src/routes/health.ts | 0 .../agent-ingress/src/routes/host.ts | 0 .../agent-ingress/src/routes/listen.ts | 3 +- .../agent-ingress/src/routes/run.ts | 5 +- .../agent-ingress/src/routes/send.ts | 3 +- .../agent-ingress/src/routes/status.ts | 0 .../agent-ingress/src/routes/webhooks.ts | 5 +- .../agent-ingress/src}/server.test.ts | 12 +- .../agent-ingress/src/server.ts | 3 +- .../agent-ingress/src/types.ts | 0 services/agent-ingress/tsconfig.eslint.json | 9 + .../agent-ingress}/tsconfig.json | 2 +- .../agent-ingress}/tsconfig.test.json | 2 +- services/agent-runner/.eslintrc.cjs | 28 ++ .../agent-runner/.gitignore | 0 services/agent-runner/.prettierrc.cjs | 1 + {packages => services}/agent-runner/README.md | 0 .../agent-runner}/jest.config.js | 2 +- .../agent-runner/package.json | 14 +- .../agent-runner/src/config.ts | 0 .../agent-runner/src/executor-stub.ts | 6 +- .../agent-runner/src/executor.ts | 2 +- .../agent-runner/src/index.ts | 12 +- .../agent-runner/src}/state.test.ts | 2 +- .../agent-runner/src/state.ts | 0 .../agent-runner/src/tools/builtins.ts | 10 +- .../agent-runner/src/tools/meta.ts | 12 +- .../agent-runner/src/tools/registry.test.ts | 4 +- .../agent-runner/src/tools/registry.ts | 0 .../agent-runner/src/tools/types.ts | 4 +- .../agent-runner/src}/worker.test.ts | 10 +- .../agent-runner/src/worker.ts | 7 +- services/agent-runner/tsconfig.eslint.json | 9 + .../agent-runner}/tsconfig.json | 2 +- .../agent-runner}/tsconfig.test.json | 2 +- 83 files changed, 629 insertions(+), 404 deletions(-) delete mode 100644 packages/agent-ingress/.eslintrc.json create mode 100644 services/agent-core/.eslintrc.cjs rename {packages => services}/agent-core/.gitignore (100%) create mode 100644 services/agent-core/.prettierrc.cjs rename {packages => services}/agent-core/README.md (100%) rename {packages => services}/agent-core/bin/migrate.ts (96%) create mode 100644 services/agent-core/eslint.config.base.cjs rename {packages => services}/agent-core/jest.config.js (83%) rename {packages => services}/agent-core/migrations/0001_initial_schema.sql (100%) rename {packages => services}/agent-core/package.json (71%) create mode 100644 services/agent-core/prettier.config.base.cjs rename packages/agent-core/tests/builtins.test.ts => services/agent-core/src/builtins/index.test.ts (79%) rename {packages => services}/agent-core/src/builtins/index.ts (100%) rename {packages => services}/agent-core/src/index.ts (100%) rename {packages => services}/agent-core/src/internal-api/client.ts (100%) rename {packages => services}/agent-core/src/internal-api/index.ts (100%) rename {packages => services}/agent-core/src/internal-api/types.ts (100%) rename {packages => services}/agent-core/src/logger.ts (100%) rename packages/agent-core/tests/manifest.test.ts => services/agent-core/src/manifest/index.test.ts (97%) rename {packages => services}/agent-core/src/manifest/index.ts (98%) rename {packages => services}/agent-core/src/metrics.ts (100%) rename packages/agent-core/tests/pubsub.test.ts => services/agent-core/src/pubsub/in-memory.test.ts (99%) rename {packages => services}/agent-core/src/pubsub/in-memory.ts (67%) rename {packages => services}/agent-core/src/pubsub/index.ts (100%) rename {packages => services}/agent-core/src/pubsub/redis.ts (100%) rename {packages => services}/agent-core/src/pubsub/types.ts (100%) rename {packages => services}/agent-core/src/queue/index.ts (81%) rename {packages => services}/agent-core/src/queue/janitor.ts (100%) rename {packages => services}/agent-core/src/queue/manager.ts (100%) rename {packages/agent-core/tests => services/agent-core/src/queue}/queue.test.ts (91%) rename {packages => services}/agent-core/src/queue/types.ts (100%) rename {packages => services}/agent-core/src/queue/worker.ts (100%) create mode 100644 services/agent-core/tsconfig.eslint.json rename {packages/agent-runner => services/agent-core}/tsconfig.json (91%) rename {packages/agent-runner => services/agent-core}/tsconfig.test.json (83%) create mode 100644 services/agent-ingress/.eslintrc.cjs rename {packages => services}/agent-ingress/.gitignore (100%) create mode 100644 services/agent-ingress/.prettierrc.cjs rename {packages => services}/agent-ingress/README.md (100%) rename {packages/agent-runner => services/agent-ingress}/jest.config.js (83%) rename {packages => services}/agent-ingress/package.json (68%) rename {packages => services}/agent-ingress/src/auth.ts (100%) rename {packages => services}/agent-ingress/src/config.ts (100%) rename {packages => services}/agent-ingress/src/index.ts (91%) rename {packages => services}/agent-ingress/src/resolver.ts (99%) rename {packages => services}/agent-ingress/src/routes/health.ts (100%) rename {packages => services}/agent-ingress/src/routes/host.ts (100%) rename {packages => services}/agent-ingress/src/routes/listen.ts (99%) rename {packages => services}/agent-ingress/src/routes/run.ts (98%) rename {packages => services}/agent-ingress/src/routes/send.ts (99%) rename {packages => services}/agent-ingress/src/routes/status.ts (100%) rename {packages => services}/agent-ingress/src/routes/webhooks.ts (97%) rename {packages/agent-ingress/tests => services/agent-ingress/src}/server.test.ts (96%) rename {packages => services}/agent-ingress/src/server.ts (99%) rename {packages => services}/agent-ingress/src/types.ts (100%) create mode 100644 services/agent-ingress/tsconfig.eslint.json rename {packages/agent-core => services/agent-ingress}/tsconfig.json (92%) rename {packages/agent-core => services/agent-ingress}/tsconfig.test.json (83%) create mode 100644 services/agent-runner/.eslintrc.cjs rename {packages => services}/agent-runner/.gitignore (100%) create mode 100644 services/agent-runner/.prettierrc.cjs rename {packages => services}/agent-runner/README.md (100%) rename {packages/agent-ingress => services/agent-runner}/jest.config.js (83%) rename {packages => services}/agent-runner/package.json (67%) rename {packages => services}/agent-runner/src/config.ts (100%) rename {packages => services}/agent-runner/src/executor-stub.ts (85%) rename {packages => services}/agent-runner/src/executor.ts (100%) rename {packages => services}/agent-runner/src/index.ts (86%) rename {packages/agent-runner/tests => services/agent-runner/src}/state.test.ts (91%) rename {packages => services}/agent-runner/src/state.ts (100%) rename {packages => services}/agent-runner/src/tools/builtins.ts (93%) rename {packages => services}/agent-runner/src/tools/meta.ts (72%) rename packages/agent-runner/tests/tools.test.ts => services/agent-runner/src/tools/registry.test.ts (93%) rename {packages => services}/agent-runner/src/tools/registry.ts (100%) rename {packages => services}/agent-runner/src/tools/types.ts (88%) rename {packages/agent-runner/tests => services/agent-runner/src}/worker.test.ts (96%) rename {packages => services}/agent-runner/src/worker.ts (97%) create mode 100644 services/agent-runner/tsconfig.eslint.json rename {packages/agent-ingress => services/agent-runner}/tsconfig.json (92%) rename {packages/agent-ingress => services/agent-runner}/tsconfig.test.json (83%) diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index b3aec8e6fcbd..8ff87ae5038d 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -7,7 +7,7 @@ Companion to [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/a Two things we own here: 1. **Management plane** — a new flag-gated product under `products/agent_stack/` (Django app + viewsets + frontend). -2. **Runtime** — three new TypeScript packages under `packages/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `packages/agent-core/`. +2. **Runtime** — three new TypeScript services under `services/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `services/agent-core/`. The runtime split (ingress + runner) from the agent-stack doc still holds. This plan refines what each half looks like inside the posthog monorepo and which existing primitives we lean on conceptually (not by import). @@ -15,39 +15,39 @@ The runtime split (ingress + runner) from the agent-stack doc still holds. This ## Status & nodejs TODO -Working tracker for the runtime packages only — the Django side is being built in parallel. Update inline as work lands; the rest of this doc is the spec. +Working tracker for the runtime services only — the Django side is being built in parallel. Update inline as work lands; the rest of this doc is the spec. **Last audited:** 2026-05-13. -### packages/agent-core/ (milestone 5 — substantially done) - -- [x] Queue schema + migration ([migrations/0001_initial_schema.sql](../../packages/agent-core/migrations/0001_initial_schema.sql)): state enum, `lock_id`, `last_heartbeat`, `BYTEA` state, `transition_count`, `janitor_touch_count`, indexes for dequeue/stall/cleanup -- [x] Manager / enqueue with depth limit + 1 MiB state cap ([src/queue/manager.ts](../../packages/agent-core/src/queue/manager.ts)) -- [x] Worker / dequeue + `FOR UPDATE SKIP LOCKED` + heartbeat + ack/fail/reschedule/cancel ([src/queue/worker.ts](../../packages/agent-core/src/queue/worker.ts)) -- [x] Janitor / stall recovery + poison-pill + terminal cleanup + Prom metrics ([src/queue/janitor.ts](../../packages/agent-core/src/queue/janitor.ts)) -- [x] Migrations runner ([bin/migrate.ts](../../packages/agent-core/bin/migrate.ts)) -- [x] Pub-sub interface + Redis adapter + in-memory adapter ([src/pubsub/](../../packages/agent-core/src/pubsub)) -- [x] Internal-API client (`resolve`, `decrypt`) with optional shared-key header ([src/internal-api/client.ts](../../packages/agent-core/src/internal-api/client.ts)) -- [x] Built-ins registry — `posthog.events.capture`, `posthog.feature_flags.evaluate`, `http.fetch` ([src/builtins/index.ts](../../packages/agent-core/src/builtins/index.ts)) -- [x] Manifest reader + Zod schema + built-in id validation ([src/manifest/index.ts](../../packages/agent-core/src/manifest/index.ts)) -- [x] Logger (pino) + Prom metrics ([src/logger.ts](../../packages/agent-core/src/logger.ts), [src/metrics.ts](../../packages/agent-core/src/metrics.ts)) +### services/agent-core/ (milestone 5 — substantially done) + +- [x] Queue schema + migration ([migrations/0001_initial_schema.sql](../../services/agent-core/migrations/0001_initial_schema.sql)): state enum, `lock_id`, `last_heartbeat`, `BYTEA` state, `transition_count`, `janitor_touch_count`, indexes for dequeue/stall/cleanup +- [x] Manager / enqueue with depth limit + 1 MiB state cap ([src/queue/manager.ts](../../services/agent-core/src/queue/manager.ts)) +- [x] Worker / dequeue + `FOR UPDATE SKIP LOCKED` + heartbeat + ack/fail/reschedule/cancel ([src/queue/worker.ts](../../services/agent-core/src/queue/worker.ts)) +- [x] Janitor / stall recovery + poison-pill + terminal cleanup + Prom metrics ([src/queue/janitor.ts](../../services/agent-core/src/queue/janitor.ts)) +- [x] Migrations runner ([bin/migrate.ts](../../services/agent-core/bin/migrate.ts)) +- [x] Pub-sub interface + Redis adapter + in-memory adapter ([src/pubsub/](../../services/agent-core/src/pubsub)) +- [x] Internal-API client (`resolve`, `decrypt`) with optional shared-key header ([src/internal-api/client.ts](../../services/agent-core/src/internal-api/client.ts)) +- [x] Built-ins registry — `posthog.events.capture`, `posthog.feature_flags.evaluate`, `http.fetch` ([src/builtins/index.ts](../../services/agent-core/src/builtins/index.ts)) +- [x] Manifest reader + Zod schema + built-in id validation ([src/manifest/index.ts](../../services/agent-core/src/manifest/index.ts)) +- [x] Logger (pino) + Prom metrics ([src/logger.ts](../../services/agent-core/src/logger.ts), [src/metrics.ts](../../services/agent-core/src/metrics.ts)) - [x] Tests: queue (DB-gated), pubsub in-memory, manifest, builtins - [ ] Tests: Redis pubsub integration (needs Redis in CI) - [ ] Tests: internal-API client smoke (mock server — 404, timeout, shared-key header) - [ ] Decide internal-API transport auth (mTLS vs shared key) — both supported in code, pick at infra time -### packages/agent-ingress/ (milestone 6 — wired end-to-end against fakes) +### services/agent-ingress/ (milestone 6 — wired end-to-end against fakes) -- [x] Bootstrap, Zod-validated env, SIGTERM/SIGINT shutdown ([src/index.ts](../../packages/agent-ingress/src/index.ts), [src/config.ts](../../packages/agent-ingress/src/config.ts)) -- [x] Host resolver with LRU + TTL + `invalidate()` hook ([src/resolver.ts](../../packages/agent-ingress/src/resolver.ts)) -- [x] Auth modes: `public`, `shared_secret`, `webhook_signature` (generic HMAC-SHA256) ([src/auth.ts](../../packages/agent-ingress/src/auth.ts)) -- [x] `/run` — resolves, authorizes, writes job via agent-core queue, returns 202 `{ sessionId }` ([src/routes/run.ts](../../packages/agent-ingress/src/routes/run.ts)) -- [x] `/listen/:id` — SSE wired to `bus.subscribeEvents` + 15s heartbeat ([src/routes/listen.ts](../../packages/agent-ingress/src/routes/listen.ts)) -- [x] `/send/:id` — publishes `user_message` to `bus.publishInput` ([src/routes/send.ts](../../packages/agent-ingress/src/routes/send.ts)) -- [x] `/webhooks/:provider` — host check, generic signature verify, enqueue ([src/routes/webhooks.ts](../../packages/agent-ingress/src/routes/webhooks.ts)) +- [x] Bootstrap, Zod-validated env, SIGTERM/SIGINT shutdown ([src/index.ts](../../services/agent-ingress/src/index.ts), [src/config.ts](../../services/agent-ingress/src/config.ts)) +- [x] Host resolver with LRU + TTL + `invalidate()` hook ([src/resolver.ts](../../services/agent-ingress/src/resolver.ts)) +- [x] Auth modes: `public`, `shared_secret`, `webhook_signature` (generic HMAC-SHA256) ([src/auth.ts](../../services/agent-ingress/src/auth.ts)) +- [x] `/run` — resolves, authorizes, writes job via agent-core queue, returns 202 `{ sessionId }` ([src/routes/run.ts](../../services/agent-ingress/src/routes/run.ts)) +- [x] `/listen/:id` — SSE wired to `bus.subscribeEvents` + 15s heartbeat ([src/routes/listen.ts](../../services/agent-ingress/src/routes/listen.ts)) +- [x] `/send/:id` — publishes `user_message` to `bus.publishInput` ([src/routes/send.ts](../../services/agent-ingress/src/routes/send.ts)) +- [x] `/webhooks/:provider` — host check, generic signature verify, enqueue ([src/routes/webhooks.ts](../../services/agent-ingress/src/routes/webhooks.ts)) - [x] `/health`, `/status` -- [x] ESLint hard rule blocking Anthropic / Modal / nodejs imports ([.eslintrc.json](../../packages/agent-ingress/.eslintrc.json)) -- [x] Tests: `/health`, `/status`, `/run`, `/send` happy/sad paths with FakeQueue + InMemoryBus ([tests/server.test.ts](../../packages/agent-ingress/tests/server.test.ts)) +- [x] ESLint hard rule blocking Anthropic / Modal / nodejs imports ([.eslintrc.json](../../services/agent-ingress/.eslintrc.json)) +- [x] Tests: `/health`, `/status`, `/run`, `/send` happy/sad paths with FakeQueue + InMemoryBus ([tests/server.test.ts](../../services/agent-ingress/tests/server.test.ts)) - [ ] Tests: webhook signature flow end-to-end - [ ] Tests: `/listen` SSE flow (subscribe → publish → frame received) - [ ] Tests: resolver LRU + TTL + invalidate @@ -56,23 +56,23 @@ Working tracker for the runtime packages only — the Django side is being built - [ ] `/run` rate limiter - [ ] Promotion invalidation: settle on push-from-Django call to `resolver.invalidate(...)` vs TTL-only -### packages/agent-runner/ (milestone 7 — orchestration solid, executor stubbed) +### services/agent-runner/ (milestone 7 — orchestration solid, executor stubbed) -- [x] Worker — dequeue, lock, heartbeat, reschedule on suspend, ack/fail on terminal ([src/worker.ts](../../packages/agent-runner/src/worker.ts)) -- [x] `SessionExecutor` interface + `ExecutorTurnInput/Output` shape ([src/executor.ts](../../packages/agent-runner/src/executor.ts)) -- [ ] **Real executor backed by Claude Agent SDK.** Currently `NotImplementedExecutor` ([src/executor-stub.ts](../../packages/agent-runner/src/executor-stub.ts)) returns a "not implemented" error. The real one must invoke the SDK, stream chunks, tick heartbeats, and return `tool_call | completed | failed | awaiting_input` per turn. -- [ ] State ↔ Claude Agent SDK `Message[]` / `ContentBlock` mapping. Today [src/state.ts](../../packages/agent-runner/src/state.ts) round-trips a generic `{role, content, at}` envelope. -- [x] Meta tools `complete`, `wait_for_input` ([src/tools/meta.ts](../../packages/agent-runner/src/tools/meta.ts)) -- [x] `http.fetch` builtin — real fetch with timeout ([src/tools/builtins.ts](../../packages/agent-runner/src/tools/builtins.ts)) +- [x] Worker — dequeue, lock, heartbeat, reschedule on suspend, ack/fail on terminal ([src/worker.ts](../../services/agent-runner/src/worker.ts)) +- [x] `SessionExecutor` interface + `ExecutorTurnInput/Output` shape ([src/executor.ts](../../services/agent-runner/src/executor.ts)) +- [ ] **Real executor backed by Claude Agent SDK.** Currently `NotImplementedExecutor` ([src/executor-stub.ts](../../services/agent-runner/src/executor-stub.ts)) returns a "not implemented" error. The real one must invoke the SDK, stream chunks, tick heartbeats, and return `tool_call | completed | failed | awaiting_input` per turn. +- [ ] State ↔ Claude Agent SDK `Message[]` / `ContentBlock` mapping. Today [src/state.ts](../../services/agent-runner/src/state.ts) round-trips a generic `{role, content, at}` envelope. +- [x] Meta tools `complete`, `wait_for_input` ([src/tools/meta.ts](../../services/agent-runner/src/tools/meta.ts)) +- [x] `http.fetch` builtin — real fetch with timeout ([src/tools/builtins.ts](../../services/agent-runner/src/tools/builtins.ts)) - [ ] `posthog.events.capture` builtin — currently logs to console; wire `posthog-node` + per-app credentials from secrets - [ ] `posthog.feature_flags.evaluate` builtin — currently hardcoded false; wire to PostHog API -- [x] Tool registry + dispatch ([src/tools/registry.ts](../../packages/agent-runner/src/tools/registry.ts)) -- [x] Config (Anthropic key, queue DB, internal API, Redis) ([src/config.ts](../../packages/agent-runner/src/config.ts)) +- [x] Tool registry + dispatch ([src/tools/registry.ts](../../services/agent-runner/src/tools/registry.ts)) +- [x] Config (Anthropic key, queue DB, internal API, Redis) ([src/config.ts](../../services/agent-runner/src/config.ts)) - [x] Tests: state round-trip, tool dispatch, worker outcomes (`completed` / `failed` / `tool_call` / `awaiting_input` / pendingInputs flush) - [ ] Tests: real Claude Agent SDK turn (gated on key + recorded fixtures) -- [ ] Secrets loader — [src/index.ts](../../packages/agent-runner/src/index.ts) `loadSecrets` returns `{}`; wire to `apiClient.decryptSecrets` once a tool actually needs them -- [ ] Runner-side reaper: queue janitor already resets stalled jobs; need a matching write to set `AgentSession.state = 'failed'` for the mirror row -- [ ] `AgentSession` mirror writes — direct DB vs internal API — coordinate with Django owner +- [ ] Secrets loader — [src/index.ts](../../services/agent-runner/src/index.ts) `loadSecrets` returns `{}`; wire to `apiClient.decryptSecrets` once a tool actually needs them +- [ ] Runner-side reaper: queue janitor already resets stalled jobs; need a matching write to set `AgentApplicationSession.state = 'failed'` for the mirror row +- [ ] `AgentApplicationSession` mirror writes — direct DB vs internal API — coordinate with Django owner ### Cross-package / system level @@ -88,25 +88,25 @@ Working tracker for the runtime packages only — the Django side is being built - Triggers (M9): cron, slack event ingestion — webhook endpoint exists; orchestrator still TBD - Sandboxes (M8): Modal integration, custom-tool execution, sandbox lifecycle + reaper -- Bundle validator (M12): the fourth package `packages/agent-validator/` +- Bundle validator (M12): the fourth package `services/agent-validator/` - Skills + registry v2 (M13) --- -## Runtime packages +## Runtime services -Three packages under `packages/`, each its own process / deployment: +Three services under `services/`, each its own process / deployment: ```text -packages/ +services/ agent-core/ # shared types, db client, queue primitives, manifest reader agent-ingress/ # process: HTTP ingress, *.agents.posthog.com terminator agent-runner/ # process: session executor (Claude Agent SDK + tools + sandbox) ``` -A fourth package will land later for async bundle validation (see §C below). v1 does not ship it. +A fourth service will land later for async bundle validation (see §C below). v1 does not ship it. -**Hard rule: no imports from `nodejs/`.** When we need a primitive that exists in `nodejs/` (cyclotron queue ops, structured logger, Prom metrics middleware, Postgres connection pool wrapper, Redis client, etc.) we copy the relevant code into `packages/agent-core/` and adapt it. We pay a duplication cost upfront in exchange for: +**Hard rule: no imports from `nodejs/`.** When we need a primitive that exists in `nodejs/` (cyclotron queue ops, structured logger, Prom metrics middleware, Postgres connection pool wrapper, Redis client, etc.) we copy the relevant code into `services/agent-core/` and adapt it. We pay a duplication cost upfront in exchange for: - Independent dependency graph — no plugin-server transitive cruft. - Independent deploy cadence and release process. @@ -115,7 +115,7 @@ A fourth package will land later for async bundle validation (see §C below). v1 Cherry-pick what we want, leave the rest. The legacy concepts the agent-stack plan calls out (plugin VMs, worker thread topology, event-pipeline-shaped hooks) don't come with us. -### `packages/agent-core/` +### `services/agent-core/` Shared library, no process of its own. Lives here: @@ -126,7 +126,7 @@ Shared library, no process of its own. Lives here: - Structured logger, Prom registry, OTel setup. - Manifest reader / built-ins registry (also imported by the future validator package, so the same code rejects unknown ids in both places). -### `packages/agent-ingress/` +### `services/agent-ingress/` The public-facing process. Responsibilities: @@ -140,7 +140,7 @@ The public-facing process. Responsibilities: **Hard rule (matches agent-stack plan):** ingress imports zero Anthropic / Claude Agent SDK / Modal code, and never decrypts a secret. Enforced by an `eslint-plugin-no-restricted-imports` rule in the package. The blast-radius win is the whole point of splitting from the runner. -### `packages/agent-runner/` +### `services/agent-runner/` The session executor. Responsibilities: @@ -156,7 +156,7 @@ The session executor. Responsibilities: Tool execution split: - **Meta tools** — in-process. Trivial. -- **Referenced (built-in) tools** — in-process. Built-ins registry is a hardcoded map in `agent-core` (e.g. `packages/agent-core/src/builtins/index.ts`). The future validator package imports the same map so unknown ids fail before deploy. +- **Referenced (built-in) tools** — in-process. Built-ins registry is a hardcoded map in `agent-core` (e.g. `services/agent-core/src/builtins/index.ts`). The future validator package imports the same map so unknown ids fail before deploy. - **Local tools** — proxied to a Modal sandbox via the sandbox manager. Per-invocation secrets passed in the call, never persisted in the sandbox. Sandbox manager: @@ -202,7 +202,7 @@ What we add on top: ### Queue database -A separate Postgres DB owned by the agent-runtime — `agent_runtime_queue` (name TBD). Schema lives in `packages/agent-core/migrations/`, applied by a small bin script in the same package (mirrors how Rust migrations are managed for `cyclotron_node`, but in TypeScript since we have no Rust here). Not the main posthog Postgres. Not shared with `cyclotron_node`. +A separate Postgres DB owned by the agent-runtime — `agent_runtime_queue` (name TBD). Schema lives in `services/agent-core/migrations/`, applied by a small bin script in the same package (mirrors how Rust migrations are managed for `cyclotron_node`, but in TypeScript since we have no Rust here). Not the main posthog Postgres. Not shared with `cyclotron_node`. --- @@ -352,7 +352,7 @@ The full state machine (`pending_upload → uploaded → validating → ready | ## Part C — Async bundle validator (deferred, not v1) -When we ship it, the validator will be **a fourth node package**, not a Celery task. Lives at `packages/agent-validator/`. Same shape as `agent-runner`: +When we ship it, the validator will be **a fourth node package**, not a Celery task. Lives at `services/agent-validator/`. Same shape as `agent-runner`: - Polls its own work queue (`available` revisions whose state is `uploaded` / `validating`). - Picks one up, marks `validating`, streams the bundle from S3, unpacks with size/file-count caps, walks manifests, resolves referenced ids against the shared built-ins registry in `agent-core`, runs static checks (secrets exist, allow-listed actions exist on referenced tools, triggers valid), transitions to `ready` (+ `parsed_manifest`) or `failed` (+ structured `validation_report`). @@ -408,14 +408,14 @@ Each shippable behind `FEATURE_FLAGS.AGENTS`. 2. **Management API.** CRUD viewsets for apps and revisions. Env upload endpoint. Activity logging wired. `complete_upload` shortcut transitions straight to `state=ready`. Promote endpoint flips `deployment_status`. 3. **Deploy flow.** `start_deploy` → presigned PUT → `complete_upload` (auto-ready) → `promote`. End-to-end via CLI. No async work. 4. **Internal API.** `resolve` + `decrypt_env` endpoints with internal scopes. mTLS / signed-key auth. -5. **`packages/agent-core/`.** Types, DB clients, queue primitives (schema + ops), pub-sub helper, internal-API client, logger/metrics. No process; tested in isolation. -6. **`packages/agent-ingress/`.** Domain resolution, `/run` writes `AgentApplicationSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. -7. **`packages/agent-runner/` — meta + built-in tools.** Queue consumer. Real Claude Agent SDK invocation. State serialized into queue `state`, reschedule loop on tool boundaries. Built-ins registry shared with `agent-core`. +5. **`services/agent-core/`.** Types, DB clients, queue primitives (schema + ops), pub-sub helper, internal-API client, logger/metrics. No process; tested in isolation. +6. **`services/agent-ingress/`.** Domain resolution, `/run` writes `AgentApplicationSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. +7. **`services/agent-runner/` — meta + built-in tools.** Queue consumer. Real Claude Agent SDK invocation. State serialized into queue `state`, reschedule loop on tool boundaries. Built-ins registry shared with `agent-core`. 8. **Sandboxes.** Modal integration, custom-tool execution, sandbox lifecycle + reaper. `AgentApplicationSandboxInstance` writes from the runner. 9. **Triggers.** Webhooks, cron, slack event ingestion. 10. **Frontend.** App list, app detail (revisions/env/sessions/sandbox tabs), session detail. 11. **Preview deploys (set `deployment_status=preview`), observability polish, quotas.** -12. **`packages/agent-validator/`.** Async bundle validator. Pure-function checks reusable from the CLI. Flip `complete_upload` to enqueue validation instead of auto-ready. +12. **`services/agent-validator/`.** Async bundle validator. Pure-function checks reusable from the CLI. Flip `complete_upload` to enqueue validation instead of auto-ready. 13. **Skills + registry v2** (publish flow, third-party tool publishing). Reuses the same immutable revision artifacts. --- diff --git a/packages/agent-ingress/.eslintrc.json b/packages/agent-ingress/.eslintrc.json deleted file mode 100644 index c772e7f9c683..000000000000 --- a/packages/agent-ingress/.eslintrc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "root": true, - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["@anthropic-ai/*", "@modal/*", "modal", "claude-agent-sdk"], - "message": "agent-ingress must not import the Claude Agent SDK or Modal — those belong to agent-runner. Blast-radius rule." - }, - { - "group": ["**/nodejs/*", "../../../nodejs/*", "@posthog/nodejs"], - "message": "agent-ingress must not import from nodejs/ — cherry-pick into @posthog/agent-core instead." - } - ] - } - ] - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e2e638f31f0..1c635f20f7a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1745,141 +1745,6 @@ importers: specifier: ^4.7.0 version: 4.20.5 - packages/agent-core: - dependencies: - ioredis: - specifier: ^4.27.6 - version: 4.28.5 - luxon: - specifier: ^3.4.4 - version: 3.7.2 - node-fetch: - specifier: ^2.6.1 - version: 2.7.0(encoding@0.1.13) - pg: - specifier: ^8.6.0 - version: 8.10.0 - pino: - specifier: ^8.6.0 - version: 8.11.0 - prom-client: - specifier: ^14.2.0 - version: 14.2.0 - uuid: - specifier: ^10.0.0 - version: 10.0.0 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@types/ioredis': - specifier: ^4.26.4 - version: 4.28.10 - '@types/jest': - specifier: 'catalog:' - version: 29.5.14 - '@types/luxon': - specifier: ^3.4.2 - version: 3.4.2 - '@types/node': - specifier: 'catalog:' - version: 22.18.8 - '@types/node-fetch': - specifier: ^2.5.10 - version: 2.6.4 - '@types/pg': - specifier: ^8.6.0 - version: 8.15.4 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 - jest: - specifier: 'catalog:' - version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) - ts-jest: - specifier: ^29.1.0 - version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) - tsx: - specifier: ^4.7.0 - version: 4.20.5 - typescript: - specifier: 5.9.3 - version: 5.9.3 - - packages/agent-ingress: - dependencies: - '@posthog/agent-core': - specifier: workspace:* - version: link:../agent-core - lru-cache: - specifier: ^11.0.0 - version: 11.2.4 - ultimate-express: - specifier: ^2.0.9 - version: 2.0.9 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@types/jest': - specifier: 'catalog:' - version: 29.5.14 - '@types/node': - specifier: 'catalog:' - version: 22.18.8 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.2 - jest: - specifier: 'catalog:' - version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) - supertest: - specifier: ^7.0.0 - version: 7.0.0 - ts-jest: - specifier: ^29.1.0 - version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) - tsx: - specifier: ^4.7.0 - version: 4.20.5 - typescript: - specifier: 5.9.3 - version: 5.9.3 - - packages/agent-runner: - dependencies: - '@posthog/agent-core': - specifier: workspace:* - version: link:../agent-core - luxon: - specifier: ^3.4.4 - version: 3.7.2 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@types/jest': - specifier: 'catalog:' - version: 29.5.14 - '@types/luxon': - specifier: ^3.4.2 - version: 3.4.2 - '@types/node': - specifier: 'catalog:' - version: 22.18.8 - jest: - specifier: 'catalog:' - version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) - ts-jest: - specifier: ^29.1.0 - version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) - tsx: - specifier: ^4.7.0 - version: 4.20.5 - typescript: - specifier: 5.9.3 - version: 5.9.3 - packages/quill: devDependencies: tailwindcss-scroll-mask: @@ -3579,6 +3444,213 @@ importers: specifier: ^7.3.0 version: 7.3.0 + services/agent-core: + dependencies: + ioredis: + specifier: ^4.27.6 + version: 4.28.5 + luxon: + specifier: ^3.4.4 + version: 3.7.2 + node-fetch: + specifier: ^2.6.1 + version: 2.7.0(encoding@0.1.13) + pg: + specifier: ^8.6.0 + version: 8.10.0 + pino: + specifier: ^8.6.0 + version: 8.11.0 + prom-client: + specifier: ^14.2.0 + version: 14.2.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.4.1 + version: 4.7.1(eslint@8.57.0) + '@trivago/prettier-plugin-sort-imports': + specifier: ^5.2.2 + version: 5.2.2(prettier@3.8.2) + '@types/ioredis': + specifier: ^4.26.4 + version: 4.28.10 + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@types/node-fetch': + specifier: ^2.5.10 + version: 2.6.4 + '@types/pg': + specifier: ^8.6.0 + version: 8.15.4 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.2 + version: 8.58.2(eslint@8.57.0)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-plugin-no-only-tests: + specifier: ^3.1.0 + version: 3.3.0 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.8.2 + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + services/agent-ingress: + dependencies: + '@posthog/agent-core': + specifier: workspace:* + version: link:../agent-core + lru-cache: + specifier: ^11.0.0 + version: 11.2.4 + ultimate-express: + specifier: ^2.0.9 + version: 2.0.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.4.1 + version: 4.7.1(eslint@8.57.0) + '@trivago/prettier-plugin-sort-imports': + specifier: ^5.2.2 + version: 5.2.2(prettier@3.8.2) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.2 + version: 8.58.2(eslint@8.57.0)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-plugin-no-only-tests: + specifier: ^3.1.0 + version: 3.3.0 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.8.2 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + services/agent-runner: + dependencies: + '@posthog/agent-core': + specifier: workspace:* + version: link:../agent-core + luxon: + specifier: ^3.4.4 + version: 3.7.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.4.1 + version: 4.7.1(eslint@8.57.0) + '@trivago/prettier-plugin-sort-imports': + specifier: ^5.2.2 + version: 5.2.2(prettier@3.8.2) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.2 + version: 8.58.2(eslint@8.57.0)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-plugin-no-only-tests: + specifier: ^3.1.0 + version: 3.3.0 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.8.2 + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + services/mcp: dependencies: '@modelcontextprotocol/ext-apps': @@ -4315,10 +4387,6 @@ packages: resolution: {integrity: sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.0': - resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} @@ -4521,11 +4589,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.28.0': - resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} @@ -5139,10 +5202,6 @@ packages: resolution: {integrity: sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.0': - resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -5151,10 +5210,6 @@ packages: resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -6615,12 +6670,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6631,10 +6680,6 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-community/regexpp@4.6.2': - resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -25496,14 +25541,6 @@ snapshots: - supports-color '@babel/generator@7.26.3': - dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 - jsesc: 3.1.0 - - '@babel/generator@7.28.0': dependencies: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 @@ -25978,10 +26015,6 @@ snapshots: dependencies: '@babel/types': 7.26.3 - '@babel/parser@7.28.0': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 @@ -27836,8 +27869,8 @@ snapshots: '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@babel/template@7.28.6': dependencies: @@ -27857,18 +27890,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.28.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -27886,11 +27907,6 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/types@7.28.1': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -28879,11 +28895,6 @@ snapshots: eslint: 8.57.0 ignore: 7.0.5 - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': - dependencies: - eslint: 8.57.0 - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -28891,8 +28902,6 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint-community/regexpp@4.6.2': {} - '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 @@ -35650,16 +35659,28 @@ snapshots: '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)': dependencies: - '@babel/generator': 7.28.0 - '@babel/parser': 7.28.0 - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 javascript-natural-sort: 0.7.1 - lodash: 4.17.23 + lodash: 4.18.1 prettier: 3.6.2 transitivePeerDependencies: - supports-color + '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.8.2)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + javascript-natural-sort: 0.7.1 + lodash: 4.18.1 + prettier: 3.8.2 + transitivePeerDependencies: + - supports-color + '@trysound/sax@0.2.0': {} '@tsconfig/node10@1.0.9': {} @@ -35724,7 +35745,7 @@ snapshots: '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 '@types/babel__standalone@7.1.4': dependencies: @@ -35743,8 +35764,8 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.20.4': dependencies: @@ -35752,7 +35773,7 @@ snapshots: '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: @@ -36208,7 +36229,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 10.2.3 + minimatch: 10.2.5 '@types/minimist@1.2.5': {} @@ -36445,7 +36466,7 @@ snapshots: '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.6.2 + '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.9.3) '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.0)(typescript@5.9.3) @@ -36574,9 +36595,9 @@ snapshots: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - minimatch: 10.2.3 + minimatch: 10.2.5 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -36584,7 +36605,7 @@ snapshots: '@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.5 '@typescript-eslint/scope-manager': 5.62.0 @@ -40124,8 +40145,8 @@ snapshots: eslint@8.57.0: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.6.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.0) + '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.0 '@humanwhocodes/config-array': 0.11.14 @@ -40134,8 +40155,8 @@ snapshots: '@ungap/structured-clone': 1.3.0 ajv: 6.12.6 chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.4.0 + cross-spawn: 7.0.6 + debug: 4.4.3 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -40153,7 +40174,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -40985,14 +41006,14 @@ snapshots: dependencies: foreground-child: 3.1.1 jackspeak: 4.0.2 - minimatch: 10.2.3 + minimatch: 10.2.5 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.0 glob@13.0.6: dependencies: - minimatch: 10.2.3 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1dc67b621d0c..49ec367b2206 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,15 +10,15 @@ packages: - packages/quill - packages/quill/apps/* - packages/quill/packages/* - - packages/agent-core - - packages/agent-ingress - - packages/agent-runner - frontend - playwright - nodejs - nodejs/src/scripts - products/* - docs/onboarding + - services/agent-core + - services/agent-ingress + - services/agent-runner - services/oauth-proxy - services/mcp - services/stripe-app diff --git a/services/agent-core/.eslintrc.cjs b/services/agent-core/.eslintrc.cjs new file mode 100644 index 000000000000..b0c34844badf --- /dev/null +++ b/services/agent-core/.eslintrc.cjs @@ -0,0 +1,10 @@ +const base = require('./eslint.config.base.cjs') + +module.exports = { + ...base, + root: true, + parserOptions: { + ...base.parserOptions, + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/agent-core/.gitignore b/services/agent-core/.gitignore similarity index 100% rename from packages/agent-core/.gitignore rename to services/agent-core/.gitignore diff --git a/services/agent-core/.prettierrc.cjs b/services/agent-core/.prettierrc.cjs new file mode 100644 index 000000000000..0c98c4291325 --- /dev/null +++ b/services/agent-core/.prettierrc.cjs @@ -0,0 +1 @@ +module.exports = require('./prettier.config.base.cjs') diff --git a/packages/agent-core/README.md b/services/agent-core/README.md similarity index 100% rename from packages/agent-core/README.md rename to services/agent-core/README.md diff --git a/packages/agent-core/bin/migrate.ts b/services/agent-core/bin/migrate.ts similarity index 96% rename from packages/agent-core/bin/migrate.ts rename to services/agent-core/bin/migrate.ts index eaf4371865f2..2ae19e5e57d6 100644 --- a/packages/agent-core/bin/migrate.ts +++ b/services/agent-core/bin/migrate.ts @@ -2,7 +2,7 @@ /** * Apply pending migrations to the agent-runtime queue DB. * - * Reads SQL files from packages/agent-core/migrations/, applies them in lexicographic + * Reads SQL files from services/agent-core/migrations/, applies them in lexicographic * order, records each applied id in agent_runtime_migrations. * * Usage: @@ -10,7 +10,6 @@ */ import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' - import { Pool } from 'pg' async function main(): Promise { diff --git a/services/agent-core/eslint.config.base.cjs b/services/agent-core/eslint.config.base.cjs new file mode 100644 index 000000000000..1cb8f270d9ff --- /dev/null +++ b/services/agent-core/eslint.config.base.cjs @@ -0,0 +1,63 @@ +/** + * Shared ESLint base config for services/agent-*. + * + * Mirrors nodejs/.eslintrc.js so the agent runtime services hold the same line + * as the legacy plugin-server, minus rules that depend on nodejs-only paths + * (e.g. the `~/utils/request` fetch ban). + * + * Consumers extend this from their own `.eslintrc.cjs` and add: + * - `root: true` + * - `parserOptions.tsconfigRootDir` (their own __dirname) + * - any package-specific `no-restricted-imports` patterns + */ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + project: ['./tsconfig.eslint.json'], + }, + plugins: ['@typescript-eslint', 'no-only-tests'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:@eslint-community/eslint-comments/recommended', + 'prettier', + ], + ignorePatterns: ['bin', 'dist', 'node_modules', 'migrations', '*.js', '*.cjs'], + rules: { + 'no-only-tests/no-only-tests': 'error', + 'no-constant-binary-expression': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'require-await': 'off', + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], + '@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }], + curly: 'error', + 'no-fallthrough': 'warn', + }, + overrides: [ + { + files: ['**/*.test.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'off', + // Test fakes/mocks frequently implement async signatures without ever awaiting. + '@typescript-eslint/require-await': 'off', + }, + }, + ], + reportUnusedDisableDirectives: true, +} diff --git a/packages/agent-core/jest.config.js b/services/agent-core/jest.config.js similarity index 83% rename from packages/agent-core/jest.config.js rename to services/agent-core/jest.config.js index cb031c2723fd..c53ebf19d35a 100644 --- a/packages/agent-core/jest.config.js +++ b/services/agent-core/jest.config.js @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['/tests/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts'], testTimeout: 15_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], diff --git a/packages/agent-core/migrations/0001_initial_schema.sql b/services/agent-core/migrations/0001_initial_schema.sql similarity index 100% rename from packages/agent-core/migrations/0001_initial_schema.sql rename to services/agent-core/migrations/0001_initial_schema.sql diff --git a/packages/agent-core/package.json b/services/agent-core/package.json similarity index 71% rename from packages/agent-core/package.json rename to services/agent-core/package.json index 2458de3ef4b8..619f108f8f14 100644 --- a/packages/agent-core/package.json +++ b/services/agent-core/package.json @@ -1,31 +1,37 @@ { "name": "@posthog/agent-core", "version": "0.1.0", + "private": true, "description": "Shared library for the PostHog agent platform runtime (queue primitives, types, internal-API client).", "license": "MIT", "author": "PostHog ", "repository": "https://github.com/PostHog/posthog", - "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "pnpm clean && tsc -b", "clean": "rm -rf dist", "typescript:check": "tsc --noEmit -p .", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "migrate": "tsx bin/migrate.ts" }, "dependencies": { + "ioredis": "^4.27.6", "luxon": "^3.4.4", "node-fetch": "^2.6.1", "pg": "^8.6.0", "pino": "^8.6.0", "prom-client": "^14.2.0", - "ioredis": "^4.27.6", "uuid": "^10.0.0", "zod": "^4.3.6" }, "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/ioredis": "^4.26.4", "@types/jest": "catalog:", "@types/luxon": "^3.4.2", @@ -33,7 +39,13 @@ "@types/node-fetch": "^2.5.10", "@types/pg": "^8.6.0", "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-no-only-tests": "^3.1.0", "jest": "catalog:", + "prettier": "^3.6.2", "ts-jest": "^29.1.0", "tsx": "^4.7.0", "typescript": "catalog:" diff --git a/services/agent-core/prettier.config.base.cjs b/services/agent-core/prettier.config.base.cjs new file mode 100644 index 000000000000..6413e42c18a6 --- /dev/null +++ b/services/agent-core/prettier.config.base.cjs @@ -0,0 +1,35 @@ +/** + * Shared Prettier base config for services/agent-*. + * + * Mirrors nodejs/.prettierrc verbatim — keeping the agent runtime services + * formatted identically to the legacy plugin-server reduces context-switch + * friction when contributors move between the two trees. + */ +module.exports = { + trailingComma: 'es5', + tabWidth: 4, + semi: false, + singleQuote: true, + printWidth: 120, + plugins: ['@trivago/prettier-plugin-sort-imports'], + importOrder: [ + '\\.mocks?$', + '\\.spy$', + '', + '^@posthog.*$', + '^~/(.*)$', + '^@/(.*)$', + '^public/(.*)$', + '^\\.+/', + ], + importOrderSeparation: true, + importOrderSortSpecifiers: true, + importOrderParserPlugins: ['typescript', 'jsx', 'classProperties', 'decorators-legacy'], + proseWrap: 'preserve', + overrides: [ + { + files: ['*.md', '*.mdx'], + options: { tabWidth: 2 }, + }, + ], +} diff --git a/packages/agent-core/tests/builtins.test.ts b/services/agent-core/src/builtins/index.test.ts similarity index 79% rename from packages/agent-core/tests/builtins.test.ts rename to services/agent-core/src/builtins/index.test.ts index 6e1ef990d5b4..72fbf1c66776 100644 --- a/packages/agent-core/tests/builtins.test.ts +++ b/services/agent-core/src/builtins/index.test.ts @@ -1,9 +1,11 @@ -import { getBuiltin, isBuiltinId, listBuiltins } from '../src' +import { getBuiltin, isBuiltinId, listBuiltins } from '..' describe('builtins registry', () => { it('lists all registered builtins', () => { const ids = listBuiltins().map((b) => b.id) - expect(ids).toEqual(expect.arrayContaining(['posthog.events.capture', 'posthog.feature_flags.evaluate', 'http.fetch'])) + expect(ids).toEqual( + expect.arrayContaining(['posthog.events.capture', 'posthog.feature_flags.evaluate', 'http.fetch']) + ) }) it('looks builtins up by id', () => { diff --git a/packages/agent-core/src/builtins/index.ts b/services/agent-core/src/builtins/index.ts similarity index 100% rename from packages/agent-core/src/builtins/index.ts rename to services/agent-core/src/builtins/index.ts diff --git a/packages/agent-core/src/index.ts b/services/agent-core/src/index.ts similarity index 100% rename from packages/agent-core/src/index.ts rename to services/agent-core/src/index.ts diff --git a/packages/agent-core/src/internal-api/client.ts b/services/agent-core/src/internal-api/client.ts similarity index 100% rename from packages/agent-core/src/internal-api/client.ts rename to services/agent-core/src/internal-api/client.ts diff --git a/packages/agent-core/src/internal-api/index.ts b/services/agent-core/src/internal-api/index.ts similarity index 100% rename from packages/agent-core/src/internal-api/index.ts rename to services/agent-core/src/internal-api/index.ts diff --git a/packages/agent-core/src/internal-api/types.ts b/services/agent-core/src/internal-api/types.ts similarity index 100% rename from packages/agent-core/src/internal-api/types.ts rename to services/agent-core/src/internal-api/types.ts diff --git a/packages/agent-core/src/logger.ts b/services/agent-core/src/logger.ts similarity index 100% rename from packages/agent-core/src/logger.ts rename to services/agent-core/src/logger.ts diff --git a/packages/agent-core/tests/manifest.test.ts b/services/agent-core/src/manifest/index.test.ts similarity index 97% rename from packages/agent-core/tests/manifest.test.ts rename to services/agent-core/src/manifest/index.test.ts index 76d259c94e45..b05a0b276077 100644 --- a/packages/agent-core/tests/manifest.test.ts +++ b/services/agent-core/src/manifest/index.test.ts @@ -1,4 +1,4 @@ -import { parseManifest } from '../src' +import { parseManifest } from '..' describe('parseManifest', () => { it('accepts a minimal valid manifest', () => { diff --git a/packages/agent-core/src/manifest/index.ts b/services/agent-core/src/manifest/index.ts similarity index 98% rename from packages/agent-core/src/manifest/index.ts rename to services/agent-core/src/manifest/index.ts index b4f5f2574f1e..c4de36614d41 100644 --- a/packages/agent-core/src/manifest/index.ts +++ b/services/agent-core/src/manifest/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { isBuiltinId } from '../builtins' /** - * Minimal manifest shape used by the v1 runner. The full validator (packages/agent-validator, + * Minimal manifest shape used by the v1 runner. The full validator (services/agent-validator, * deferred) will parse bundle contents, walk the YAML tree, and produce a richer parsed_manifest. * In v1, the Django side stores top_level_config from the CLI's parse step and the runner reads * it directly. Both code paths share this schema so they agree on what's valid. diff --git a/packages/agent-core/src/metrics.ts b/services/agent-core/src/metrics.ts similarity index 100% rename from packages/agent-core/src/metrics.ts rename to services/agent-core/src/metrics.ts diff --git a/packages/agent-core/tests/pubsub.test.ts b/services/agent-core/src/pubsub/in-memory.test.ts similarity index 99% rename from packages/agent-core/tests/pubsub.test.ts rename to services/agent-core/src/pubsub/in-memory.test.ts index a96438b296ab..e188269c39e0 100644 --- a/packages/agent-core/tests/pubsub.test.ts +++ b/services/agent-core/src/pubsub/in-memory.test.ts @@ -1,4 +1,4 @@ -import { InMemorySessionBus, SessionEvent, SessionInputMessage } from '../src' +import { InMemorySessionBus, SessionEvent, SessionInputMessage } from '..' describe('InMemorySessionBus', () => { let bus: InMemorySessionBus diff --git a/packages/agent-core/src/pubsub/in-memory.ts b/services/agent-core/src/pubsub/in-memory.ts similarity index 67% rename from packages/agent-core/src/pubsub/in-memory.ts rename to services/agent-core/src/pubsub/in-memory.ts index 7452abe9ccbc..b90a365a3cb3 100644 --- a/packages/agent-core/src/pubsub/in-memory.ts +++ b/services/agent-core/src/pubsub/in-memory.ts @@ -15,32 +15,37 @@ export class InMemorySessionBus implements SessionBus { this.emitter.setMaxListeners(0) } - async publishEvent(sessionId: string, event: SessionEvent): Promise { + publishEvent(sessionId: string, event: SessionEvent): Promise { this.emitter.emit(this.eventChannel(sessionId), event) + return Promise.resolve() } - async subscribeEvents(sessionId: string, listener: SessionEventListener): Promise<() => Promise> { + subscribeEvents(sessionId: string, listener: SessionEventListener): Promise<() => Promise> { const channel = this.eventChannel(sessionId) this.emitter.on(channel, listener) - return async () => { + return Promise.resolve(() => { this.emitter.off(channel, listener) - } + return Promise.resolve() + }) } - async publishInput(sessionId: string, message: SessionInputMessage): Promise { + publishInput(sessionId: string, message: SessionInputMessage): Promise { this.emitter.emit(this.inputChannel(sessionId), message) + return Promise.resolve() } - async subscribeInput(sessionId: string, listener: SessionInputListener): Promise<() => Promise> { + subscribeInput(sessionId: string, listener: SessionInputListener): Promise<() => Promise> { const channel = this.inputChannel(sessionId) this.emitter.on(channel, listener) - return async () => { + return Promise.resolve(() => { this.emitter.off(channel, listener) - } + return Promise.resolve() + }) } - async disconnect(): Promise { + disconnect(): Promise { this.emitter.removeAllListeners() + return Promise.resolve() } private eventChannel(sessionId: string): string { diff --git a/packages/agent-core/src/pubsub/index.ts b/services/agent-core/src/pubsub/index.ts similarity index 100% rename from packages/agent-core/src/pubsub/index.ts rename to services/agent-core/src/pubsub/index.ts diff --git a/packages/agent-core/src/pubsub/redis.ts b/services/agent-core/src/pubsub/redis.ts similarity index 100% rename from packages/agent-core/src/pubsub/redis.ts rename to services/agent-core/src/pubsub/redis.ts diff --git a/packages/agent-core/src/pubsub/types.ts b/services/agent-core/src/pubsub/types.ts similarity index 100% rename from packages/agent-core/src/pubsub/types.ts rename to services/agent-core/src/pubsub/types.ts diff --git a/packages/agent-core/src/queue/index.ts b/services/agent-core/src/queue/index.ts similarity index 81% rename from packages/agent-core/src/queue/index.ts rename to services/agent-core/src/queue/index.ts index 59b136989dad..54013dd623a6 100644 --- a/packages/agent-core/src/queue/index.ts +++ b/services/agent-core/src/queue/index.ts @@ -1,10 +1,7 @@ export { SessionQueueManager } from './manager' export { SessionQueueWorker } from './worker' export { SessionQueueJanitor } from './janitor' -export { - SessionJobInitSchema, - RescheduleOptionsSchema, -} from './types' +export { SessionJobInitSchema, RescheduleOptionsSchema } from './types' export type { SessionStatus, PoolConfig, diff --git a/packages/agent-core/src/queue/janitor.ts b/services/agent-core/src/queue/janitor.ts similarity index 100% rename from packages/agent-core/src/queue/janitor.ts rename to services/agent-core/src/queue/janitor.ts diff --git a/packages/agent-core/src/queue/manager.ts b/services/agent-core/src/queue/manager.ts similarity index 100% rename from packages/agent-core/src/queue/manager.ts rename to services/agent-core/src/queue/manager.ts diff --git a/packages/agent-core/tests/queue.test.ts b/services/agent-core/src/queue/queue.test.ts similarity index 91% rename from packages/agent-core/tests/queue.test.ts rename to services/agent-core/src/queue/queue.test.ts index a6b40347f6c7..ac4967bd4dcc 100644 --- a/packages/agent-core/tests/queue.test.ts +++ b/services/agent-core/src/queue/queue.test.ts @@ -1,16 +1,15 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { Pool } from 'pg' +import { v7 as uuidv7 } from 'uuid' + /** * DB-gated queue integration tests. * * Skipped automatically if AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL is unset, so this * suite is safe in environments without a Postgres available. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' - -import { Pool } from 'pg' -import { v7 as uuidv7 } from 'uuid' - -import { DequeuedSessionJob, SessionQueueJanitor, SessionQueueManager, SessionQueueWorker } from '../src' +import { DequeuedSessionJob, SessionQueueJanitor, SessionQueueManager, SessionQueueWorker } from '..' const DB_URL = process.env.AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL const describeIfDb = DB_URL ? describe : describe.skip @@ -22,7 +21,7 @@ describeIfDb('agent-core queue (DB-gated)', () => { beforeAll(async () => { pool = new Pool({ connectionString: DB_URL }) - const schema = readFileSync(join(__dirname, '..', 'migrations', '0001_initial_schema.sql'), 'utf8') + const schema = readFileSync(join(__dirname, '..', '..', 'migrations', '0001_initial_schema.sql'), 'utf8') await pool.query(`DROP TABLE IF EXISTS agent_sessions`) await pool.query(`DROP TABLE IF EXISTS agent_runtime_migrations`) await pool.query(`DROP TYPE IF EXISTS AgentSessionStatus`) @@ -74,10 +73,7 @@ describeIfDb('agent-core queue (DB-gated)', () => { expect(batch[0].teamId).toBe(42) await batch[0].ack() - const { rows } = await pool.query<{ status: string }>( - 'SELECT status FROM agent_sessions WHERE id = $1', - [id] - ) + const { rows } = await pool.query<{ status: string }>('SELECT status FROM agent_sessions WHERE id = $1', [id]) expect(rows[0].status).toBe('completed') }) @@ -130,10 +126,9 @@ describeIfDb('agent-core queue (DB-gated)', () => { const second = await janitor.runOnce() expect(second.poisoned).toBe(1) - const { rows } = await pool.query<{ status: string }>( - 'SELECT status FROM agent_sessions WHERE id = $1', - [id] - ) + const { rows } = await pool.query<{ status: string }>('SELECT status FROM agent_sessions WHERE id = $1', [ + id, + ]) expect(rows[0].status).toBe('failed') } finally { await janitor.stop() diff --git a/packages/agent-core/src/queue/types.ts b/services/agent-core/src/queue/types.ts similarity index 100% rename from packages/agent-core/src/queue/types.ts rename to services/agent-core/src/queue/types.ts diff --git a/packages/agent-core/src/queue/worker.ts b/services/agent-core/src/queue/worker.ts similarity index 100% rename from packages/agent-core/src/queue/worker.ts rename to services/agent-core/src/queue/worker.ts diff --git a/services/agent-core/tsconfig.eslint.json b/services/agent-core/tsconfig.eslint.json new file mode 100644 index 000000000000..95ae8dafd68b --- /dev/null +++ b/services/agent-core/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src", "bin"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-runner/tsconfig.json b/services/agent-core/tsconfig.json similarity index 91% rename from packages/agent-runner/tsconfig.json rename to services/agent-core/tsconfig.json index 0615be011bb6..f99b8a509159 100644 --- a/packages/agent-runner/tsconfig.json +++ b/services/agent-core/tsconfig.json @@ -22,5 +22,5 @@ "types": ["node", "jest"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "bin", "src/**/*.test.ts"] } diff --git a/packages/agent-runner/tsconfig.test.json b/services/agent-core/tsconfig.test.json similarity index 83% rename from packages/agent-runner/tsconfig.test.json rename to services/agent-core/tsconfig.test.json index 7d1d3ba5e17b..4513f4555b92 100644 --- a/packages/agent-runner/tsconfig.test.json +++ b/services/agent-core/tsconfig.test.json @@ -4,6 +4,6 @@ "rootDir": ".", "types": ["node", "jest"] }, - "include": ["src", "tests"], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/services/agent-ingress/.eslintrc.cjs b/services/agent-ingress/.eslintrc.cjs new file mode 100644 index 000000000000..1fd97a7a459f --- /dev/null +++ b/services/agent-ingress/.eslintrc.cjs @@ -0,0 +1,33 @@ +const base = require('../agent-core/eslint.config.base.cjs') + +module.exports = { + ...base, + root: true, + parserOptions: { + ...base.parserOptions, + tsconfigRootDir: __dirname, + }, + rules: { + ...base.rules, + // Blast-radius rule: agent-ingress must not pull in the Claude Agent SDK, + // Modal, or any nodejs/ legacy plugin-server primitives. Cherry-pick into + // @posthog/agent-core if you need something from those trees. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@anthropic-ai/*', '@modal/*', 'modal', 'claude-agent-sdk'], + message: + 'agent-ingress must not import the Claude Agent SDK or Modal — those belong to agent-runner. Blast-radius rule.', + }, + { + group: ['**/nodejs/*', '../../../nodejs/*', '@posthog/nodejs'], + message: + 'agent-ingress must not import from nodejs/ — cherry-pick into @posthog/agent-core instead.', + }, + ], + }, + ], + }, +} diff --git a/packages/agent-ingress/.gitignore b/services/agent-ingress/.gitignore similarity index 100% rename from packages/agent-ingress/.gitignore rename to services/agent-ingress/.gitignore diff --git a/services/agent-ingress/.prettierrc.cjs b/services/agent-ingress/.prettierrc.cjs new file mode 100644 index 000000000000..a42e97266ced --- /dev/null +++ b/services/agent-ingress/.prettierrc.cjs @@ -0,0 +1 @@ +module.exports = require('../agent-core/prettier.config.base.cjs') diff --git a/packages/agent-ingress/README.md b/services/agent-ingress/README.md similarity index 100% rename from packages/agent-ingress/README.md rename to services/agent-ingress/README.md diff --git a/packages/agent-runner/jest.config.js b/services/agent-ingress/jest.config.js similarity index 83% rename from packages/agent-runner/jest.config.js rename to services/agent-ingress/jest.config.js index cb031c2723fd..c53ebf19d35a 100644 --- a/packages/agent-runner/jest.config.js +++ b/services/agent-ingress/jest.config.js @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['/tests/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts'], testTimeout: 15_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], diff --git a/packages/agent-ingress/package.json b/services/agent-ingress/package.json similarity index 68% rename from packages/agent-ingress/package.json rename to services/agent-ingress/package.json index a89a3b4364df..758f5d2e17d2 100644 --- a/packages/agent-ingress/package.json +++ b/services/agent-ingress/package.json @@ -1,16 +1,20 @@ { "name": "@posthog/agent-ingress", "version": "0.1.0", + "private": true, "description": "HTTP ingress process for the PostHog agent platform: terminates *.agents.posthog.com traffic, enqueues sessions, streams events.", "license": "MIT", "author": "PostHog ", "repository": "https://github.com/PostHog/posthog", - "private": true, "main": "dist/index.js", "scripts": { "build": "pnpm clean && tsc -b", "clean": "rm -rf dist", "typescript:check": "tsc --noEmit -p .", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "start": "node dist/index.js", "start:dev": "tsx watch src/index.ts" @@ -22,10 +26,18 @@ "zod": "^4.3.6" }, "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/jest": "catalog:", "@types/node": "catalog:", "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-no-only-tests": "^3.1.0", "jest": "catalog:", + "prettier": "^3.6.2", "supertest": "^7.0.0", "ts-jest": "^29.1.0", "tsx": "^4.7.0", diff --git a/packages/agent-ingress/src/auth.ts b/services/agent-ingress/src/auth.ts similarity index 100% rename from packages/agent-ingress/src/auth.ts rename to services/agent-ingress/src/auth.ts index bc0a9162903d..fb108f763a1c 100644 --- a/packages/agent-ingress/src/auth.ts +++ b/services/agent-ingress/src/auth.ts @@ -1,7 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto' +import { Request } from 'ultimate-express' import { ResolvedRevision } from '@posthog/agent-core' -import { Request } from 'ultimate-express' /** * Per-app auth derived from the resolved revision. v1 supports three modes: diff --git a/packages/agent-ingress/src/config.ts b/services/agent-ingress/src/config.ts similarity index 100% rename from packages/agent-ingress/src/config.ts rename to services/agent-ingress/src/config.ts diff --git a/packages/agent-ingress/src/index.ts b/services/agent-ingress/src/index.ts similarity index 91% rename from packages/agent-ingress/src/index.ts rename to services/agent-ingress/src/index.ts index 3148b276dc4a..b3abcdb1d154 100644 --- a/packages/agent-ingress/src/index.ts +++ b/services/agent-ingress/src/index.ts @@ -24,9 +24,7 @@ async function main(): Promise { const resolver = new RevisionResolver({ client: apiClient, ttlMs: config.resolverTtlMs }) - const bus: SessionBus = config.redisUrl - ? new RedisSessionBus({ url: config.redisUrl }) - : new InMemorySessionBus() + const bus: SessionBus = config.redisUrl ? new RedisSessionBus({ url: config.redisUrl }) : new InMemorySessionBus() if (!config.redisUrl) { logger.warn('REDIS_URL not set; using in-memory bus (single-process only — not safe for production)') diff --git a/packages/agent-ingress/src/resolver.ts b/services/agent-ingress/src/resolver.ts similarity index 99% rename from packages/agent-ingress/src/resolver.ts rename to services/agent-ingress/src/resolver.ts index e685db2ad441..09c1b1f9477c 100644 --- a/packages/agent-ingress/src/resolver.ts +++ b/services/agent-ingress/src/resolver.ts @@ -1,6 +1,7 @@ -import { InternalApiClient, ResolvedRevision, logger } from '@posthog/agent-core' import { LRUCache } from 'lru-cache' +import { InternalApiClient, ResolvedRevision, logger } from '@posthog/agent-core' + export interface ResolverOptions { client: InternalApiClient ttlMs: number diff --git a/packages/agent-ingress/src/routes/health.ts b/services/agent-ingress/src/routes/health.ts similarity index 100% rename from packages/agent-ingress/src/routes/health.ts rename to services/agent-ingress/src/routes/health.ts diff --git a/packages/agent-ingress/src/routes/host.ts b/services/agent-ingress/src/routes/host.ts similarity index 100% rename from packages/agent-ingress/src/routes/host.ts rename to services/agent-ingress/src/routes/host.ts diff --git a/packages/agent-ingress/src/routes/listen.ts b/services/agent-ingress/src/routes/listen.ts similarity index 99% rename from packages/agent-ingress/src/routes/listen.ts rename to services/agent-ingress/src/routes/listen.ts index 54e82a10a72a..a706291103ea 100644 --- a/packages/agent-ingress/src/routes/listen.ts +++ b/services/agent-ingress/src/routes/listen.ts @@ -1,6 +1,7 @@ -import { SessionEvent, logger } from '@posthog/agent-core' import { Express, Request, Response } from 'ultimate-express' +import { SessionEvent, logger } from '@posthog/agent-core' + import { ServerDeps } from '../types' /** diff --git a/packages/agent-ingress/src/routes/run.ts b/services/agent-ingress/src/routes/run.ts similarity index 98% rename from packages/agent-ingress/src/routes/run.ts rename to services/agent-ingress/src/routes/run.ts index ff5c2b728f96..edf41d7db05c 100644 --- a/packages/agent-ingress/src/routes/run.ts +++ b/services/agent-ingress/src/routes/run.ts @@ -1,8 +1,9 @@ -import { logger } from '@posthog/agent-core' import { Express, Request, Response } from 'ultimate-express' import { z } from 'zod' -import { authorize, AuthRequest } from '../auth' +import { logger } from '@posthog/agent-core' + +import { AuthRequest, authorize } from '../auth' import { ServerDeps } from '../types' import { extractHost } from './host' diff --git a/packages/agent-ingress/src/routes/send.ts b/services/agent-ingress/src/routes/send.ts similarity index 99% rename from packages/agent-ingress/src/routes/send.ts rename to services/agent-ingress/src/routes/send.ts index f184112395f7..ac4b7537c712 100644 --- a/packages/agent-ingress/src/routes/send.ts +++ b/services/agent-ingress/src/routes/send.ts @@ -1,7 +1,8 @@ -import { logger } from '@posthog/agent-core' import { Express, Request, Response } from 'ultimate-express' import { z } from 'zod' +import { logger } from '@posthog/agent-core' + import { ServerDeps } from '../types' const SendBodySchema = z.object({ diff --git a/packages/agent-ingress/src/routes/status.ts b/services/agent-ingress/src/routes/status.ts similarity index 100% rename from packages/agent-ingress/src/routes/status.ts rename to services/agent-ingress/src/routes/status.ts diff --git a/packages/agent-ingress/src/routes/webhooks.ts b/services/agent-ingress/src/routes/webhooks.ts similarity index 97% rename from packages/agent-ingress/src/routes/webhooks.ts rename to services/agent-ingress/src/routes/webhooks.ts index f85589233f34..7adf1f66d43a 100644 --- a/packages/agent-ingress/src/routes/webhooks.ts +++ b/services/agent-ingress/src/routes/webhooks.ts @@ -1,7 +1,8 @@ -import { logger } from '@posthog/agent-core' import { Express, Request, Response } from 'ultimate-express' -import { authorize, AuthRequest } from '../auth' +import { logger } from '@posthog/agent-core' + +import { AuthRequest, authorize } from '../auth' import { ServerDeps } from '../types' import { extractHost } from './host' diff --git a/packages/agent-ingress/tests/server.test.ts b/services/agent-ingress/src/server.test.ts similarity index 96% rename from packages/agent-ingress/tests/server.test.ts rename to services/agent-ingress/src/server.test.ts index 35672497b9a0..6b9e9beeca5d 100644 --- a/packages/agent-ingress/tests/server.test.ts +++ b/services/agent-ingress/src/server.test.ts @@ -1,9 +1,10 @@ -import { InMemorySessionBus, ResolvedRevision, SessionInputMessage } from '@posthog/agent-core' import supertest from 'supertest' import type { Express } from 'ultimate-express' -import { RevisionResolver } from '../src/resolver' -import { buildServer, ServerDeps } from '../src/server' +import { InMemorySessionBus, ResolvedRevision, SessionInputMessage } from '@posthog/agent-core' + +import { RevisionResolver } from './resolver' +import { ServerDeps, buildServer } from './server' class FakeQueue { public created: Array> = [] @@ -140,10 +141,7 @@ describe('agent-ingress server', () => { it('POST /run rejects hosts that do not match the suffix', async () => { harness = await startServer() - const res = await supertest(harness.app) - .post('/run') - .set('x-original-host', 'evil.example.com') - .send({}) + const res = await supertest(harness.app).post('/run').set('x-original-host', 'evil.example.com').send({}) expect(res.status).toBe(400) }) diff --git a/packages/agent-ingress/src/server.ts b/services/agent-ingress/src/server.ts similarity index 99% rename from packages/agent-ingress/src/server.ts rename to services/agent-ingress/src/server.ts index 7f6056f43a0b..4afc6c37ec0f 100644 --- a/packages/agent-ingress/src/server.ts +++ b/services/agent-ingress/src/server.ts @@ -1,6 +1,7 @@ -import { collectDefaults, logger, metricsContentType, metricsText } from '@posthog/agent-core' import express, { Express } from 'ultimate-express' +import { collectDefaults, logger, metricsContentType, metricsText } from '@posthog/agent-core' + import { registerHealth } from './routes/health' import { registerListen } from './routes/listen' import { registerRun } from './routes/run' diff --git a/packages/agent-ingress/src/types.ts b/services/agent-ingress/src/types.ts similarity index 100% rename from packages/agent-ingress/src/types.ts rename to services/agent-ingress/src/types.ts diff --git a/services/agent-ingress/tsconfig.eslint.json b/services/agent-ingress/tsconfig.eslint.json new file mode 100644 index 000000000000..4513f4555b92 --- /dev/null +++ b/services/agent-ingress/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-core/tsconfig.json b/services/agent-ingress/tsconfig.json similarity index 92% rename from packages/agent-core/tsconfig.json rename to services/agent-ingress/tsconfig.json index 2ec039f5f292..795ea8a88fb1 100644 --- a/packages/agent-core/tsconfig.json +++ b/services/agent-ingress/tsconfig.json @@ -22,5 +22,5 @@ "types": ["node", "jest"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "tests", "bin"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/packages/agent-core/tsconfig.test.json b/services/agent-ingress/tsconfig.test.json similarity index 83% rename from packages/agent-core/tsconfig.test.json rename to services/agent-ingress/tsconfig.test.json index 7d1d3ba5e17b..4513f4555b92 100644 --- a/packages/agent-core/tsconfig.test.json +++ b/services/agent-ingress/tsconfig.test.json @@ -4,6 +4,6 @@ "rootDir": ".", "types": ["node", "jest"] }, - "include": ["src", "tests"], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/services/agent-runner/.eslintrc.cjs b/services/agent-runner/.eslintrc.cjs new file mode 100644 index 000000000000..18a1562dffcf --- /dev/null +++ b/services/agent-runner/.eslintrc.cjs @@ -0,0 +1,28 @@ +const base = require('../agent-core/eslint.config.base.cjs') + +module.exports = { + ...base, + root: true, + parserOptions: { + ...base.parserOptions, + tsconfigRootDir: __dirname, + }, + rules: { + ...base.rules, + // Worker is the one place in the agent platform that legitimately holds + // the Anthropic SDK + Modal control plane. We still keep `nodejs/` off + // limits — share via @posthog/agent-core instead. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/nodejs/*', '../../../nodejs/*', '@posthog/nodejs'], + message: + 'agent-runner must not import from nodejs/ — cherry-pick into @posthog/agent-core instead.', + }, + ], + }, + ], + }, +} diff --git a/packages/agent-runner/.gitignore b/services/agent-runner/.gitignore similarity index 100% rename from packages/agent-runner/.gitignore rename to services/agent-runner/.gitignore diff --git a/services/agent-runner/.prettierrc.cjs b/services/agent-runner/.prettierrc.cjs new file mode 100644 index 000000000000..a42e97266ced --- /dev/null +++ b/services/agent-runner/.prettierrc.cjs @@ -0,0 +1 @@ +module.exports = require('../agent-core/prettier.config.base.cjs') diff --git a/packages/agent-runner/README.md b/services/agent-runner/README.md similarity index 100% rename from packages/agent-runner/README.md rename to services/agent-runner/README.md diff --git a/packages/agent-ingress/jest.config.js b/services/agent-runner/jest.config.js similarity index 83% rename from packages/agent-ingress/jest.config.js rename to services/agent-runner/jest.config.js index cb031c2723fd..c53ebf19d35a 100644 --- a/packages/agent-ingress/jest.config.js +++ b/services/agent-runner/jest.config.js @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['/tests/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts'], testTimeout: 15_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], diff --git a/packages/agent-runner/package.json b/services/agent-runner/package.json similarity index 67% rename from packages/agent-runner/package.json rename to services/agent-runner/package.json index ade543925b33..59965d4b5ce4 100644 --- a/packages/agent-runner/package.json +++ b/services/agent-runner/package.json @@ -1,16 +1,20 @@ { "name": "@posthog/agent-runner", "version": "0.1.0", + "private": true, "description": "Session executor process for the PostHog agent platform: consumes the queue, runs Claude Agent SDK turns, executes tools natively.", "license": "MIT", "author": "PostHog ", "repository": "https://github.com/PostHog/posthog", - "private": true, "main": "dist/index.js", "scripts": { "build": "pnpm clean && tsc -b", "clean": "rm -rf dist", "typescript:check": "tsc --noEmit -p .", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "start": "node dist/index.js", "start:dev": "tsx watch src/index.ts" @@ -21,10 +25,18 @@ "zod": "^4.3.6" }, "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/jest": "catalog:", "@types/luxon": "^3.4.2", "@types/node": "catalog:", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-no-only-tests": "^3.1.0", "jest": "catalog:", + "prettier": "^3.6.2", "ts-jest": "^29.1.0", "tsx": "^4.7.0", "typescript": "catalog:" diff --git a/packages/agent-runner/src/config.ts b/services/agent-runner/src/config.ts similarity index 100% rename from packages/agent-runner/src/config.ts rename to services/agent-runner/src/config.ts diff --git a/packages/agent-runner/src/executor-stub.ts b/services/agent-runner/src/executor-stub.ts similarity index 85% rename from packages/agent-runner/src/executor-stub.ts rename to services/agent-runner/src/executor-stub.ts index 40caa7493e18..80c9b635ce97 100644 --- a/packages/agent-runner/src/executor-stub.ts +++ b/services/agent-runner/src/executor-stub.ts @@ -6,10 +6,10 @@ import { ExecutorTurnOutput, SessionExecutor } from './executor' * is wired in. Treats every turn as "complete with an empty output." */ export class NotImplementedExecutor implements SessionExecutor { - async runTurn(): Promise { - return { + runTurn(): Promise { + return Promise.resolve({ kind: 'failed', error: 'Claude Agent SDK executor is not implemented yet. Wire one up in src/index.ts.', - } + }) } } diff --git a/packages/agent-runner/src/executor.ts b/services/agent-runner/src/executor.ts similarity index 100% rename from packages/agent-runner/src/executor.ts rename to services/agent-runner/src/executor.ts index 4d2ebe9a21eb..0143ad7e1645 100644 --- a/packages/agent-runner/src/executor.ts +++ b/services/agent-runner/src/executor.ts @@ -1,5 +1,5 @@ -import { ToolCall } from './tools/types' import { SessionMessage, SessionState } from './state' +import { ToolCall } from './tools/types' /** * The contract the worker drives a single turn through. Concrete implementations wrap diff --git a/packages/agent-runner/src/index.ts b/services/agent-runner/src/index.ts similarity index 86% rename from packages/agent-runner/src/index.ts rename to services/agent-runner/src/index.ts index 8b46c50a37a7..168f7bbc9eef 100644 --- a/packages/agent-runner/src/index.ts +++ b/services/agent-runner/src/index.ts @@ -1,10 +1,4 @@ -import { - InMemorySessionBus, - InternalApiClient, - RedisSessionBus, - SessionBus, - logger, -} from '@posthog/agent-core' +import { InMemorySessionBus, InternalApiClient, RedisSessionBus, SessionBus, logger } from '@posthog/agent-core' import { loadConfig } from './config' import { NotImplementedExecutor } from './executor-stub' @@ -18,9 +12,7 @@ async function main(): Promise { sharedKey: config.internalApiSharedKey, }) - const bus: SessionBus = config.redisUrl - ? new RedisSessionBus({ url: config.redisUrl }) - : new InMemorySessionBus() + const bus: SessionBus = config.redisUrl ? new RedisSessionBus({ url: config.redisUrl }) : new InMemorySessionBus() if (!config.redisUrl) { logger.warn('REDIS_URL not set; using in-memory bus (single-process only — not safe for production)') diff --git a/packages/agent-runner/tests/state.test.ts b/services/agent-runner/src/state.test.ts similarity index 91% rename from packages/agent-runner/tests/state.test.ts rename to services/agent-runner/src/state.test.ts index 180a61924f9a..99a5f7059c42 100644 --- a/packages/agent-runner/tests/state.test.ts +++ b/services/agent-runner/src/state.test.ts @@ -1,4 +1,4 @@ -import { deserializeState, emptySessionState, serializeState, SessionStateSchema } from '../src/state' +import { SessionStateSchema, deserializeState, emptySessionState, serializeState } from './state' describe('state serializer', () => { it('returns an empty state when the buffer is null', () => { diff --git a/packages/agent-runner/src/state.ts b/services/agent-runner/src/state.ts similarity index 100% rename from packages/agent-runner/src/state.ts rename to services/agent-runner/src/state.ts diff --git a/packages/agent-runner/src/tools/builtins.ts b/services/agent-runner/src/tools/builtins.ts similarity index 93% rename from packages/agent-runner/src/tools/builtins.ts rename to services/agent-runner/src/tools/builtins.ts index 9685a89b7cd0..f7407d13f482 100644 --- a/packages/agent-runner/src/tools/builtins.ts +++ b/services/agent-runner/src/tools/builtins.ts @@ -11,17 +11,17 @@ import { ToolCall, ToolContext, ToolHandler, ToolResult } from './types' type BuiltinExecutor = (parsedArgs: unknown, ctx: ToolContext) => Promise const EXECUTORS: Record = { - 'posthog.events.capture': async (args, ctx) => { + 'posthog.events.capture': (args, ctx) => { // v1 stub: log the captured event. The real implementation will go through // posthog-node once we wire credentials through the secrets path. - return { + return Promise.resolve({ captured: true, teamId: ctx.teamId, event: args, - } + }) }, - 'posthog.feature_flags.evaluate': async () => { - return { enabled: false, variant: null } + 'posthog.feature_flags.evaluate': () => { + return Promise.resolve({ enabled: false, variant: null }) }, 'http.fetch': async (args) => { const { url, method, headers, body, timeoutMs } = args as { diff --git a/packages/agent-runner/src/tools/meta.ts b/services/agent-runner/src/tools/meta.ts similarity index 72% rename from packages/agent-runner/src/tools/meta.ts rename to services/agent-runner/src/tools/meta.ts index 1e4d88f504fa..cd33b6e9ae61 100644 --- a/packages/agent-runner/src/tools/meta.ts +++ b/services/agent-runner/src/tools/meta.ts @@ -12,12 +12,12 @@ const CompleteArgsSchema = z.object({ */ export const completeMetaTool: ToolHandler = { id: 'meta.complete', - async invoke(call) { + invoke(call) { const parsed = CompleteArgsSchema.safeParse(call.args) if (!parsed.success) { - return { ok: false, error: 'meta.complete args invalid: expected { output }' } + return Promise.resolve({ ok: false, error: 'meta.complete args invalid: expected { output }' }) } - return { ok: true, value: parsed.data.output } + return Promise.resolve({ ok: true, value: parsed.data.output }) }, } @@ -31,12 +31,12 @@ const WaitForInputArgsSchema = z.object({ */ export const waitForInputMetaTool: ToolHandler = { id: 'meta.wait_for_input', - async invoke(call) { + invoke(call) { const parsed = WaitForInputArgsSchema.safeParse(call.args) if (!parsed.success) { - return { ok: false, error: 'meta.wait_for_input args invalid' } + return Promise.resolve({ ok: false, error: 'meta.wait_for_input args invalid' }) } - return { ok: true, value: { suspended: true, reason: parsed.data.reason ?? null } } + return Promise.resolve({ ok: true, value: { suspended: true, reason: parsed.data.reason ?? null } }) }, } diff --git a/packages/agent-runner/tests/tools.test.ts b/services/agent-runner/src/tools/registry.test.ts similarity index 93% rename from packages/agent-runner/tests/tools.test.ts rename to services/agent-runner/src/tools/registry.test.ts index fc3a41150769..80abcf175ea8 100644 --- a/packages/agent-runner/tests/tools.test.ts +++ b/services/agent-runner/src/tools/registry.test.ts @@ -1,5 +1,5 @@ -import { executeTool, resolveHandler } from '../src/tools/registry' -import { ToolContext } from '../src/tools/types' +import { executeTool, resolveHandler } from './registry' +import { ToolContext } from './types' const CTX: ToolContext = { sessionId: 's1', diff --git a/packages/agent-runner/src/tools/registry.ts b/services/agent-runner/src/tools/registry.ts similarity index 100% rename from packages/agent-runner/src/tools/registry.ts rename to services/agent-runner/src/tools/registry.ts diff --git a/packages/agent-runner/src/tools/types.ts b/services/agent-runner/src/tools/types.ts similarity index 88% rename from packages/agent-runner/src/tools/types.ts rename to services/agent-runner/src/tools/types.ts index ba2af5316194..8ef7a1909a03 100644 --- a/packages/agent-runner/src/tools/types.ts +++ b/services/agent-runner/src/tools/types.ts @@ -17,9 +17,7 @@ export interface ToolCall { readonly args: unknown } -export type ToolResult = - | { ok: true; value: unknown } - | { ok: false; error: string } +export type ToolResult = { ok: true; value: unknown } | { ok: false; error: string } export interface ToolHandler { readonly id: string diff --git a/packages/agent-runner/tests/worker.test.ts b/services/agent-runner/src/worker.test.ts similarity index 96% rename from packages/agent-runner/tests/worker.test.ts rename to services/agent-runner/src/worker.test.ts index 8ced4992ad36..e6dd4b0d3684 100644 --- a/packages/agent-runner/tests/worker.test.ts +++ b/services/agent-runner/src/worker.test.ts @@ -2,9 +2,9 @@ import { DateTime } from 'luxon' import { InMemorySessionBus, SessionEvent } from '@posthog/agent-core' -import { ExecutorTurnOutput, SessionExecutor } from '../src/executor' -import { deserializeState, serializeState } from '../src/state' -import { RunnerWorker } from '../src/worker' +import { ExecutorTurnOutput, SessionExecutor } from './executor' +import { deserializeState, serializeState } from './state' +import { RunnerWorker } from './worker' /** * Drives processJob via a non-public hook (cast to any). The worker's queue dependency @@ -132,7 +132,9 @@ describe('RunnerWorker.processJob', () => { await (worker as unknown as { processJob(j: typeof job): Promise }).processJob(job) expect(record.map((r) => r.method)).toEqual(['fail']) - const failed = events.find((e): e is Extract => e.type === 'session_failed') + const failed = events.find( + (e): e is Extract => e.type === 'session_failed' + ) expect(failed?.error).toBe('boom') await bus.disconnect() diff --git a/packages/agent-runner/src/worker.ts b/services/agent-runner/src/worker.ts similarity index 97% rename from packages/agent-runner/src/worker.ts rename to services/agent-runner/src/worker.ts index 99fff7af95a0..8302d875538b 100644 --- a/packages/agent-runner/src/worker.ts +++ b/services/agent-runner/src/worker.ts @@ -8,7 +8,7 @@ import { } from '@posthog/agent-core' import { SessionExecutor } from './executor' -import { deserializeState, serializeState, SessionState } from './state' +import { SessionState, deserializeState, serializeState } from './state' import { executeTool } from './tools/registry' import { ToolContext } from './tools/types' @@ -169,10 +169,7 @@ export class RunnerWorker { } } - private async runToolCall( - call: { id: string; args: unknown }, - ctx: ToolContext - ): ReturnType { + private async runToolCall(call: { id: string; args: unknown }, ctx: ToolContext): ReturnType { return executeTool({ id: call.id, args: call.args }, ctx) } diff --git a/services/agent-runner/tsconfig.eslint.json b/services/agent-runner/tsconfig.eslint.json new file mode 100644 index 000000000000..4513f4555b92 --- /dev/null +++ b/services/agent-runner/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-ingress/tsconfig.json b/services/agent-runner/tsconfig.json similarity index 92% rename from packages/agent-ingress/tsconfig.json rename to services/agent-runner/tsconfig.json index 0615be011bb6..795ea8a88fb1 100644 --- a/packages/agent-ingress/tsconfig.json +++ b/services/agent-runner/tsconfig.json @@ -22,5 +22,5 @@ "types": ["node", "jest"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/packages/agent-ingress/tsconfig.test.json b/services/agent-runner/tsconfig.test.json similarity index 83% rename from packages/agent-ingress/tsconfig.test.json rename to services/agent-runner/tsconfig.test.json index 7d1d3ba5e17b..4513f4555b92 100644 --- a/packages/agent-ingress/tsconfig.test.json +++ b/services/agent-runner/tsconfig.test.json @@ -4,6 +4,6 @@ "rootDir": ".", "types": ["node", "jest"] }, - "include": ["src", "tests"], + "include": ["src"], "exclude": ["node_modules", "dist"] } From 68e1b7fbe3bd9cbc055caf2f498248e8722c6b92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 03:26:33 +0000 Subject: [PATCH 008/517] feat(agents): add agent-janitor service + session query helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime owns session state. Django reads (and cancels) sessions through this new operational service rather than mirroring queue rows into Postgres. services/agent-janitor (new) - Hosts the SessionQueueJanitor daemon (moved out of being a per-runner concern) - /internal/sessions/:id — fetch one session - /internal/sessions — list, filtered by application_id / revision_id / status / team_id / created_before / limit - POST /internal/sessions/:id/cancel — cancel an available|running session - /health and /metrics open; everything under /internal/* requires the shared AGENT_INTERNAL_API_SHARED_KEY via x-internal-key. No key configured → refuse. - 14 tests with a FakeSessionQuery cover route happy/sad paths + auth gating. services/agent-core - SessionQuery: read-only findSession / listSessions + targeted-write cancelSession. Mirrors the queue but never locks rows. Used by agent-janitor routes; will be reused by future operational tooling. - Added DB-gated SessionQuery test covering filter + cancel behaviour. - New internal-API client smoke tests (200 / 404 / 5xx / timeout / shared-key header presence + absence). services/agent-ingress - Resolver LRU + TTL + invalidate test (cache hit/miss, null replies stay uncached, per-key eviction). - /listen SSE end-to-end test (subscribe → publish → frame received over a real node:http request). docs/internal/agent-platform.md - Updated services overview from three to four (adds janitor). - Settled the "mirror writes" open question: no runner→Django writes; Django reads via the janitor surface. - Status section gains a janitor block + ticks off the resolver / listen / internal-API client tests. Test totals after this PR: agent-core 20 + 4 skipped (DB), agent-ingress 19, agent-janitor 14, agent-runner 13 — 66 passing. https://claude.ai/code/session_01Bkx1f6m35QnFZ2RbxTsrZt --- docs/internal/agent-platform.md | 37 +- pnpm-lock.yaml | 368 +++++++++++------- pnpm-workspace.yaml | 1 + .../src/internal-api/client.test.ts | 177 +++++++++ services/agent-core/src/queue/index.ts | 2 + services/agent-core/src/queue/query.ts | 200 ++++++++++ services/agent-core/src/queue/queue.test.ts | 40 +- services/agent-ingress/src/listen.test.ts | 85 ++++ services/agent-ingress/src/resolver.test.ts | 145 +++++++ services/agent-janitor/.eslintrc.cjs | 32 ++ services/agent-janitor/.gitignore | 3 + services/agent-janitor/.prettierrc.cjs | 1 + services/agent-janitor/README.md | 28 ++ services/agent-janitor/jest.config.js | 10 + services/agent-janitor/package.json | 50 +++ services/agent-janitor/src/auth.ts | 39 ++ services/agent-janitor/src/config.ts | 30 ++ services/agent-janitor/src/index.ts | 46 +++ services/agent-janitor/src/routes/sessions.ts | 111 ++++++ services/agent-janitor/src/server.test.ts | 240 ++++++++++++ services/agent-janitor/src/server.ts | 38 ++ services/agent-janitor/tsconfig.eslint.json | 9 + services/agent-janitor/tsconfig.json | 26 ++ services/agent-janitor/tsconfig.test.json | 9 + 24 files changed, 1568 insertions(+), 159 deletions(-) create mode 100644 services/agent-core/src/internal-api/client.test.ts create mode 100644 services/agent-core/src/queue/query.ts create mode 100644 services/agent-ingress/src/listen.test.ts create mode 100644 services/agent-ingress/src/resolver.test.ts create mode 100644 services/agent-janitor/.eslintrc.cjs create mode 100644 services/agent-janitor/.gitignore create mode 100644 services/agent-janitor/.prettierrc.cjs create mode 100644 services/agent-janitor/README.md create mode 100644 services/agent-janitor/jest.config.js create mode 100644 services/agent-janitor/package.json create mode 100644 services/agent-janitor/src/auth.ts create mode 100644 services/agent-janitor/src/config.ts create mode 100644 services/agent-janitor/src/index.ts create mode 100644 services/agent-janitor/src/routes/sessions.ts create mode 100644 services/agent-janitor/src/server.test.ts create mode 100644 services/agent-janitor/src/server.ts create mode 100644 services/agent-janitor/tsconfig.eslint.json create mode 100644 services/agent-janitor/tsconfig.json create mode 100644 services/agent-janitor/tsconfig.test.json diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index 8ff87ae5038d..7282c4bb5cd3 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -7,7 +7,12 @@ Companion to [`agent-stack/docs/agent-platform.md`](https://github.com/PostHog/a Two things we own here: 1. **Management plane** — a new flag-gated product under `products/agent_stack/` (Django app + viewsets + frontend). -2. **Runtime** — three new TypeScript services under `services/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `services/agent-core/`. +2. **Runtime** — four TypeScript services under `services/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `services/agent-core/`. + + - `services/agent-core/` — shared library, no process. + - `services/agent-ingress/` — public-facing `*.agents.posthog.com` terminator. + - `services/agent-runner/` — session executor (queue consumer + SDK). + - `services/agent-janitor/` — operational: queue sweeps + internal HTTP surface that Django calls to read/cancel sessions. **The runtime owns session state; Django reads via this surface, never writes to `agent_sessions`.** The runtime split (ingress + runner) from the agent-stack doc still holds. This plan refines what each half looks like inside the posthog monorepo and which existing primitives we lean on conceptually (not by import). @@ -31,9 +36,10 @@ Working tracker for the runtime services only — the Django side is being built - [x] Built-ins registry — `posthog.events.capture`, `posthog.feature_flags.evaluate`, `http.fetch` ([src/builtins/index.ts](../../services/agent-core/src/builtins/index.ts)) - [x] Manifest reader + Zod schema + built-in id validation ([src/manifest/index.ts](../../services/agent-core/src/manifest/index.ts)) - [x] Logger (pino) + Prom metrics ([src/logger.ts](../../services/agent-core/src/logger.ts), [src/metrics.ts](../../services/agent-core/src/metrics.ts)) -- [x] Tests: queue (DB-gated), pubsub in-memory, manifest, builtins +- [x] `SessionQuery` — read-only `findSession` / `listSessions` + targeted-write `cancelSession`, used by the janitor's HTTP surface ([src/queue/query.ts](../../services/agent-core/src/queue/query.ts)) +- [x] Tests: queue + SessionQuery (DB-gated), pubsub in-memory, manifest, builtins +- [x] Tests: internal-API client smoke — 200, 404, 5xx, shared-key header, timeout ([src/internal-api/client.test.ts](../../services/agent-core/src/internal-api/client.test.ts)) - [ ] Tests: Redis pubsub integration (needs Redis in CI) -- [ ] Tests: internal-API client smoke (mock server — 404, timeout, shared-key header) - [ ] Decide internal-API transport auth (mTLS vs shared key) — both supported in code, pick at infra time ### services/agent-ingress/ (milestone 6 — wired end-to-end against fakes) @@ -47,10 +53,10 @@ Working tracker for the runtime services only — the Django side is being built - [x] `/webhooks/:provider` — host check, generic signature verify, enqueue ([src/routes/webhooks.ts](../../services/agent-ingress/src/routes/webhooks.ts)) - [x] `/health`, `/status` - [x] ESLint hard rule blocking Anthropic / Modal / nodejs imports ([.eslintrc.json](../../services/agent-ingress/.eslintrc.json)) -- [x] Tests: `/health`, `/status`, `/run`, `/send` happy/sad paths with FakeQueue + InMemoryBus ([tests/server.test.ts](../../services/agent-ingress/tests/server.test.ts)) +- [x] Tests: `/health`, `/status`, `/run`, `/send` happy/sad paths with FakeQueue + InMemoryBus ([src/server.test.ts](../../services/agent-ingress/src/server.test.ts)) +- [x] Tests: `/listen` SSE flow — subscribe → publish → frame received ([src/listen.test.ts](../../services/agent-ingress/src/listen.test.ts)) +- [x] Tests: resolver LRU + TTL + invalidate ([src/resolver.test.ts](../../services/agent-ingress/src/resolver.test.ts)) - [ ] Tests: webhook signature flow end-to-end -- [ ] Tests: `/listen` SSE flow (subscribe → publish → frame received) -- [ ] Tests: resolver LRU + TTL + invalidate - [ ] Provider-specific webhook strategies (Stripe, Slack-style HMAC-with-timestamp) under the generic webhook_signature mode - [ ] Per-team concurrent-session quota enforcement on `/run` - [ ] `/run` rate limiter @@ -72,7 +78,24 @@ Working tracker for the runtime services only — the Django side is being built - [ ] Tests: real Claude Agent SDK turn (gated on key + recorded fixtures) - [ ] Secrets loader — [src/index.ts](../../services/agent-runner/src/index.ts) `loadSecrets` returns `{}`; wire to `apiClient.decryptSecrets` once a tool actually needs them - [ ] Runner-side reaper: queue janitor already resets stalled jobs; need a matching write to set `AgentApplicationSession.state = 'failed'` for the mirror row -- [ ] `AgentApplicationSession` mirror writes — direct DB vs internal API — coordinate with Django owner +- [ ] **Settled with Django owner: runtime owns session state.** No mirror writes from runner. Django reads + cancels through `agent-janitor`'s `/internal/sessions/*` surface (below). `AgentApplicationSession` (the model joshsny added) is treated as a thin request record; its `state` column may be removed once the read path lands. + +### services/agent-janitor/ (new in this branch) + +Operational process: queue sweeps + internal HTTP surface for Django. The runtime owns session state; Django **reads** sessions through this service and never writes to `agent_sessions`. + +- [x] Bootstrap, Zod-validated env, SIGTERM/SIGINT shutdown ([src/index.ts](../../services/agent-janitor/src/index.ts), [src/config.ts](../../services/agent-janitor/src/config.ts)) +- [x] Hosts the queue janitor daemon (same `SessionQueueJanitor` from agent-core) +- [x] `/internal/sessions/:id` — fetch single session ([src/routes/sessions.ts](../../services/agent-janitor/src/routes/sessions.ts)) +- [x] `/internal/sessions` — list filtered by `application_id`, `revision_id`, `status`, `team_id`, `created_before`, `limit` +- [x] `POST /internal/sessions/:id/cancel` — cancel an `available` or `running` session +- [x] Shared-key auth (`x-internal-key`, `AGENT_INTERNAL_API_SHARED_KEY`) on every `/internal/*` request; refuses traffic when no key is configured ([src/auth.ts](../../services/agent-janitor/src/auth.ts)) +- [x] `/health` and `/metrics` are open (no key required) +- [x] Tests: route-level happy/sad paths + auth gating with a `FakeSessionQuery` ([src/server.test.ts](../../services/agent-janitor/src/server.test.ts)) +- [ ] Tests: end-to-end against a real Postgres (extend the existing DB-gated suite) +- [ ] Cursor-style pagination on `/internal/sessions` once the UI needs it +- [ ] Mirror cancel to `agent-ingress` / `agent-runner` (broadcast on the bus) so an in-flight turn aborts promptly rather than waiting for the next heartbeat +- [ ] Internal-API transport: mTLS vs `x-internal-key` — settle alongside agent-core's outbound transport decision ### Cross-package / system level diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c635f20f7a3..12e3814f3b5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,10 +445,10 @@ importers: version: 7.6.24(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@storybook/react-webpack5': specifier: ^7.6.4 - version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + version: 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/test-runner': specifier: ^0.16.0 - version: 0.16.0(@types/node@22.18.8)(encoding@0.1.13)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + version: 0.16.0(encoding@0.1.13) '@storybook/theming': specifier: ^7.6.4 version: 7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -499,7 +499,7 @@ importers: version: 2.0.0(webpack@5.88.2) webpack: specifier: ^5.88.2 - version: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + version: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: specifier: ^5.1.4 version: 5.1.4(webpack@5.88.2) @@ -3593,6 +3593,73 @@ importers: specifier: 5.9.3 version: 5.9.3 + services/agent-janitor: + dependencies: + '@posthog/agent-core': + specifier: workspace:* + version: link:../agent-core + luxon: + specifier: ^3.4.4 + version: 3.7.2 + ultimate-express: + specifier: ^2.0.9 + version: 2.0.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.4.1 + version: 4.7.1(eslint@8.57.0) + '@trivago/prettier-plugin-sort-imports': + specifier: ^5.2.2 + version: 5.2.2(prettier@3.8.2) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.2 + version: 8.58.2(eslint@8.57.0)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-plugin-no-only-tests: + specifier: ^3.1.0 + version: 3.3.0 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.8.2 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + ts-jest: + specifier: ^29.1.0 + version: 29.4.1(@babel/core@7.29.0)(@jest/transform@30.0.5)(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.29.0))(jest-util@30.0.5)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.15.18)(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.20.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 + services/agent-runner: dependencies: '@posthog/agent-core': @@ -4363,10 +4430,6 @@ packages: resolution: {integrity: sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.0': - resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} @@ -4407,10 +4470,6 @@ packages: resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -4478,10 +4537,6 @@ packages: resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -4492,12 +4547,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} @@ -4552,10 +4601,6 @@ packages: resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -4576,10 +4621,6 @@ packages: resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} @@ -20497,10 +20538,6 @@ packages: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -25458,13 +25495,13 @@ snapshots: '@babel/code-frame@7.26.2': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -25476,8 +25513,6 @@ snapshots: '@babel/compat-data@7.26.3': {} - '@babel/compat-data@7.28.0': {} - '@babel/compat-data@7.29.0': {} '@babel/core@7.26.0': @@ -25505,9 +25540,9 @@ snapshots: '@ampproject/remapping': 2.2.1 '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) - '@babel/helpers': 7.27.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) + '@babel/helpers': 7.29.2 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 @@ -25569,14 +25604,6 @@ snapshots: '@babel/types': 7.29.0 '@babel/helper-compilation-targets@7.25.9': - dependencies: - '@babel/compat-data': 7.28.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 @@ -25846,13 +25873,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -25863,13 +25883,13 @@ snapshots: '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.26.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.28.6 @@ -25878,7 +25898,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 '@babel/helper-module-imports': 7.28.6 @@ -25887,15 +25907,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -25982,8 +25993,6 @@ snapshots: '@babel/helper-validator-identifier@7.25.9': {} - '@babel/helper-validator-identifier@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.25.9': {} @@ -26001,11 +26010,6 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/helpers@7.27.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 @@ -26490,7 +26494,7 @@ snapshots: '@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.26.0) transitivePeerDependencies: @@ -26499,7 +26503,7 @@ snapshots: '@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.28.0) transitivePeerDependencies: @@ -26508,7 +26512,7 @@ snapshots: '@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.29.0) transitivePeerDependencies: @@ -26596,7 +26600,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 '@babel/helper-optimise-call-expression': 7.27.1 @@ -26611,7 +26615,7 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 '@babel/helper-optimise-call-expression': 7.27.1 @@ -26626,7 +26630,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 '@babel/helper-optimise-call-expression': 7.27.1 @@ -26787,21 +26791,21 @@ snapshots: '@babel/plugin-transform-function-name@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-function-name': 7.24.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-function-name@7.23.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-function-name': 7.24.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-function-name@7.23.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-function-name': 7.24.7 '@babel/helper-plugin-utils': 7.28.6 @@ -26874,7 +26878,7 @@ snapshots: '@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.26.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -26882,7 +26886,7 @@ snapshots: '@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -26890,7 +26894,7 @@ snapshots: '@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -26898,7 +26902,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.26.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-simple-access': 7.22.5 transitivePeerDependencies: @@ -26907,7 +26911,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-simple-access': 7.22.5 transitivePeerDependencies: @@ -26916,7 +26920,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-simple-access': 7.22.5 transitivePeerDependencies: @@ -26934,7 +26938,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-hoist-variables': 7.24.7 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.26.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 transitivePeerDependencies: @@ -26944,7 +26948,7 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@babel/helper-hoist-variables': 7.24.7 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 transitivePeerDependencies: @@ -26954,7 +26958,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-hoist-variables': 7.24.7 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 transitivePeerDependencies: @@ -26963,7 +26967,7 @@ snapshots: '@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.26.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -26971,7 +26975,7 @@ snapshots: '@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -26979,7 +26983,7 @@ snapshots: '@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -27055,27 +27059,27 @@ snapshots: '@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.26.0)': dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.26.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.26.0) '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.26.0) '@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.28.0)': dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.28.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.0) '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.28.0) '@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.29.0)': dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.29.0) @@ -27868,7 +27872,7 @@ snapshots: '@babel/template@7.25.9': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.2 '@babel/types': 7.29.0 @@ -31984,7 +31988,7 @@ snapshots: react-refresh: 0.14.0 schema-utils: 3.3.0 source-map: 0.7.6 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: type-fest: 4.41.0 webpack-hot-middleware: 2.25.4 @@ -34019,7 +34023,7 @@ snapshots: - encoding - supports-color - '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4)': + '@storybook/builder-webpack5@7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4)': dependencies: '@babel/core': 7.29.0 '@storybook/channels': 7.6.4 @@ -34049,12 +34053,12 @@ snapshots: semver: 7.7.4 style-loader: 3.3.3(webpack@5.88.2) swc-loader: 0.2.3(@swc/core@1.15.18)(webpack@5.88.2) - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) ts-dedent: 2.2.0 url: 0.11.1 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-dev-middleware: 6.1.1(webpack@5.88.2) webpack-hot-middleware: 2.25.4 webpack-virtual-modules: 0.5.0 @@ -34125,7 +34129,7 @@ snapshots: get-port: 5.1.1 giget: 1.1.2 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) leven: 3.1.0 ora: 5.4.1 prettier: 2.8.8 @@ -34168,7 +34172,7 @@ snapshots: '@types/cross-spawn': 6.0.2 cross-spawn: 7.0.6 globby: 11.1.0 - jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)) + jscodeshift: 0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)) lodash: 4.18.1 prettier: 2.8.8 recast: 0.23.11 @@ -34460,7 +34464,7 @@ snapshots: '@storybook/postinstall@7.6.4': {} - '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/preset-react-webpack@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: '@babel/preset-flow': 7.23.3(@babel/core@7.26.0) '@babel/preset-react': 7.23.3(@babel/core@7.26.0) @@ -34480,7 +34484,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-refresh: 0.14.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: '@babel/core': 7.26.0 typescript: 5.9.3 @@ -34544,7 +34548,7 @@ snapshots: dequal: 2.0.3 lodash: 4.18.1 memoizerific: 1.11.3 - qs: 6.14.1 + qs: 6.15.1 synchronous-promise: 2.0.17 ts-dedent: 2.2.0 util-deprecate: 1.0.2 @@ -34563,7 +34567,7 @@ snapshots: react-docgen-typescript: 2.2.2(typescript@5.9.3) tslib: 2.8.1 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) transitivePeerDependencies: - supports-color @@ -34597,10 +34601,10 @@ snapshots: - typescript - vite-plugin-glimmerx - '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': + '@storybook/react-webpack5@7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4)': dependencies: - '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(esbuild@0.18.20)(typescript@5.9.3)(webpack-cli@5.1.4) - '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(esbuild@0.18.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) + '@storybook/builder-webpack5': 7.6.4(encoding@0.1.13)(typescript@5.9.3)(webpack-cli@5.1.4) + '@storybook/preset-react-webpack': 7.6.4(@babel/core@7.26.0)(@swc/core@1.15.18)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(type-fest@4.41.0)(typescript@5.9.3)(webpack-cli@5.1.4)(webpack-hot-middleware@2.25.4) '@storybook/react': 7.6.4(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@types/node': 18.19.130 react: 18.3.1 @@ -34759,6 +34763,46 @@ snapshots: - supports-color - ts-node + '@storybook/test-runner@0.16.0(encoding@0.1.13)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@jest/types': 29.6.3 + '@storybook/core-common': 7.6.4(encoding@0.1.13) + '@storybook/csf': 0.1.13 + '@storybook/csf-tools': 7.6.4 + '@storybook/preview-api': 7.6.20 + '@swc/core': 1.15.18 + '@swc/jest': 0.2.37(@swc/core@1.15.18) + can-bind-to-host: 1.1.2 + commander: 9.4.1 + expect-playwright: 0.8.0 + glob: 10.4.5 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-junit: 16.0.0 + jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0) + jest-runner: 29.7.0 + jest-serializer-html: 7.1.0 + jest-watch-typeahead: 2.2.2(jest@29.7.0) + node-fetch: 2.7.0(encoding@0.1.13) + playwright: 1.45.0 + read-pkg-up: 7.0.1 + tempy: 1.0.1 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@swc/helpers' + - '@types/node' + - babel-plugin-macros + - debug + - encoding + - node-notifier + - supports-color + - ts-node + '@storybook/theming@7.6.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) @@ -37014,17 +37058,17 @@ snapshots: '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.88.2)': dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.88.2) '@xmldom/xmldom@0.8.11': {} @@ -37631,14 +37675,14 @@ snapshots: loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) babel-loader@9.1.3(@babel/core@7.29.0)(webpack@5.88.2): dependencies: '@babel/core': 7.29.0 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) babel-plugin-add-react-displayname@0.0.5: {} @@ -37684,7 +37728,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.0): dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.26.0 '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.0) semver: 6.3.1 @@ -37693,7 +37737,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.28.0): dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.28.0 '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.28.0) semver: 6.3.1 @@ -37702,7 +37746,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.29.0): dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.29.0 '@babel/core': 7.29.0 '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.29.0) semver: 6.3.1 @@ -38833,7 +38877,7 @@ snapshots: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) css-loader@6.8.1(webpack@5.88.2): dependencies: @@ -38845,7 +38889,7 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-value-parser: 4.2.0 semver: 7.7.4 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) css-prefers-color-scheme@10.0.0(postcss@8.5.2): dependencies: @@ -40582,7 +40626,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) file-system-cache@2.3.0: dependencies: @@ -40726,7 +40770,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) form-data@4.0.5: dependencies: @@ -41405,7 +41449,7 @@ snapshots: html-webpack-harddisk-plugin@2.0.0(html-webpack-plugin@5.5.3(webpack@5.88.2))(webpack@5.88.2): dependencies: html-webpack-plugin: 5.5.3(webpack@5.88.2) - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) html-webpack-plugin@5.5.3(webpack@5.88.2): dependencies: @@ -41414,7 +41458,7 @@ snapshots: lodash: 4.17.21 pretty-error: 4.0.0 tapable: 2.2.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) htmlnano@2.1.1(cssnano@7.0.6(postcss@8.5.6))(postcss@8.5.6)(relateurl@0.2.7)(svgo@3.3.2)(terser@5.46.0)(typescript@5.9.3): dependencies: @@ -42730,6 +42774,22 @@ snapshots: - debug - supports-color + jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0): + dependencies: + expect-playwright: 0.8.0 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-process-manager: 0.4.0 + jest-runner: 29.7.0 + nyc: 15.1.0 + playwright-core: 1.45.0 + rimraf: 3.0.2 + uuid: 8.3.2 + transitivePeerDependencies: + - debug + - supports-color + jest-pnp-resolver@1.2.3(jest-resolve@27.5.1): optionalDependencies: jest-resolve: 27.5.1 @@ -43148,6 +43208,17 @@ snapshots: string-length: 5.0.1 strip-ansi: 7.2.0 + jest-watch-typeahead@2.2.2(jest@29.7.0): + dependencies: + ansi-escapes: 6.0.0 + chalk: 5.6.2 + jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.1(@swc/core@1.11.4)(@types/node@22.18.8)(typescript@5.9.3)) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + jest-watcher@27.5.1: dependencies: '@jest/test-result': 27.5.1 @@ -43295,7 +43366,7 @@ snapshots: jsbn@1.1.0: {} - jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.26.0)): + jscodeshift@0.15.1(@babel/preset-env@7.23.5(@babel/core@7.29.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -43318,7 +43389,7 @@ snapshots: temp: 0.8.4 write-file-atomic: 2.4.3 optionalDependencies: - '@babel/preset-env': 7.23.5(@babel/core@7.26.0) + '@babel/preset-env': 7.23.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -43535,7 +43606,7 @@ snapshots: '@babel/core': 7.28.0 '@babel/preset-env': 7.23.5(@babel/core@7.28.0) '@babel/preset-typescript': 7.23.3(@babel/core@7.28.0) - prettier: 3.6.2 + prettier: 3.8.2 recast: 0.23.4 ts-clone-node: 3.0.0(typescript@5.9.3) typescript: 5.9.3 @@ -43592,7 +43663,7 @@ snapshots: less: 4.2.2 loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) less@3.13.1: dependencies: @@ -46088,7 +46159,7 @@ snapshots: postcss: 8.5.6 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) postcss-logical@8.0.0(postcss@8.5.2): dependencies: @@ -47015,10 +47086,6 @@ snapshots: dependencies: side-channel: 1.1.0 - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -47074,7 +47141,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -48211,7 +48278,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 semver: 7.7.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: sass: 1.56.0 @@ -48919,11 +48986,11 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) style-loader@3.3.3(webpack@5.88.2): dependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) style-search@0.1.0: {} @@ -49127,7 +49194,7 @@ snapshots: swc-loader@0.2.3(@swc/core@1.15.18)(webpack@5.88.2): dependencies: '@swc/core': 1.15.18 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) symbol-tree@3.2.4: {} @@ -49163,7 +49230,7 @@ snapshots: table@6.8.1: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -49321,17 +49388,16 @@ snapshots: '@swc/core': 1.15.18 esbuild: 0.27.7 - terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2): + terser-webpack-plugin@5.3.9(@swc/core@1.15.18)(webpack@5.88.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.19.1 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.15.18 - esbuild: 0.18.20 terser@5.19.1: dependencies: @@ -50547,7 +50613,7 @@ snapshots: import-local: 3.1.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-merge: 5.8.0 webpack-dev-middleware@6.1.1(webpack@5.88.2): @@ -50558,7 +50624,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.2.0 optionalDependencies: - webpack: 5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4) + webpack: 5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4) webpack-hot-middleware@2.25.4: dependencies: @@ -50609,7 +50675,7 @@ snapshots: - esbuild - uglify-js - webpack@5.88.2(@swc/core@1.15.18)(esbuild@0.18.20)(webpack-cli@5.1.4): + webpack@5.88.2(@swc/core@1.15.18)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.4 '@types/estree': 1.0.1 @@ -50632,7 +50698,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(esbuild@0.18.20)(webpack@5.88.2) + terser-webpack-plugin: 5.3.9(@swc/core@1.15.18)(webpack@5.88.2) watchpack: 2.4.0 webpack-sources: 3.2.3 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 49ec367b2206..2893088f5cd7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ packages: - docs/onboarding - services/agent-core - services/agent-ingress + - services/agent-janitor - services/agent-runner - services/oauth-proxy - services/mcp diff --git a/services/agent-core/src/internal-api/client.test.ts b/services/agent-core/src/internal-api/client.test.ts new file mode 100644 index 000000000000..332f3372a253 --- /dev/null +++ b/services/agent-core/src/internal-api/client.test.ts @@ -0,0 +1,177 @@ +import { createServer, Server } from 'node:http' +import { AddressInfo } from 'node:net' + +import { InternalApiClient } from './client' + +interface RecordedRequest { + method: string + url: string + headers: Record + body: string +} + +interface FakeServerHandle { + server: Server + baseUrl: string + recorded: RecordedRequest[] + setHandler: (handler: HandlerFn) => void + close: () => Promise +} + +type HandlerFn = (req: RecordedRequest) => { status: number; body?: unknown; delayMs?: number } + +async function startFakeServer(initialHandler: HandlerFn): Promise { + const recorded: RecordedRequest[] = [] + let handler: HandlerFn = initialHandler + + const server = createServer((req, res) => { + let body = '' + req.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8') + }) + req.on('end', () => { + const recordedReq: RecordedRequest = { + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + body, + } + recorded.push(recordedReq) + const reply = handler(recordedReq) + const send = (): void => { + res.statusCode = reply.status + if (reply.body !== undefined) { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(reply.body)) + } else { + res.end() + } + } + if (reply.delayMs) { + setTimeout(send, reply.delayMs) + } else { + send() + } + }) + }) + + await new Promise((resolve) => server.listen(0, resolve)) + const port = (server.address() as AddressInfo).port + return { + server, + baseUrl: `http://localhost:${port}`, + recorded, + setHandler: (next) => { + handler = next + }, + close: () => + new Promise((resolve) => { + server.close(() => resolve()) + }), + } +} + +const VALID_RESOLVE_PAYLOAD = { + applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', + applicationSlug: 'analytics-bot', + teamId: 7, + revisionId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a02', + revisionState: 'ready', + bundleS3Key: 's3://bundles/abc', + bundleSha256: 'abcd', + topLevelConfig: {}, + parsedManifest: null, + auth: { mode: 'public' }, +} + +describe('InternalApiClient', () => { + let fake: FakeServerHandle + + afterEach(async () => { + await fake.close() + }) + + describe('resolve', () => { + it('returns the parsed payload on 200', async () => { + fake = await startFakeServer(() => ({ status: 200, body: VALID_RESOLVE_PAYLOAD })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl }) + + const result = await client.resolve({ domain: 'analytics-bot.agents.posthog.com' }) + + expect(result?.applicationSlug).toBe('analytics-bot') + expect(fake.recorded[0].url).toContain('/internal/agents/applications/resolve') + expect(fake.recorded[0].url).toContain('domain=analytics-bot.agents.posthog.com') + }) + + it('returns null on 404', async () => { + fake = await startFakeServer(() => ({ status: 404 })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl }) + + const result = await client.resolve({ domain: 'missing.agents.posthog.com' }) + expect(result).toBeNull() + }) + + it('throws on 5xx', async () => { + fake = await startFakeServer(() => ({ status: 500, body: { error: 'boom' } })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl }) + + await expect(client.resolve({ domain: 'x.agents.posthog.com' })).rejects.toThrow(/500/) + }) + + it('forwards the shared key on the x-internal-key header', async () => { + fake = await startFakeServer(() => ({ status: 200, body: VALID_RESOLVE_PAYLOAD })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl, sharedKey: 'sek-ret' }) + + await client.resolve({ domain: 'analytics-bot.agents.posthog.com' }) + + expect(fake.recorded[0].headers['x-internal-key']).toBe('sek-ret') + }) + + it('does not send x-internal-key when no key is configured', async () => { + fake = await startFakeServer(() => ({ status: 200, body: VALID_RESOLVE_PAYLOAD })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl }) + + await client.resolve({ domain: 'analytics-bot.agents.posthog.com' }) + + expect(fake.recorded[0].headers['x-internal-key']).toBeUndefined() + }) + + it('aborts when the request exceeds the timeout', async () => { + fake = await startFakeServer(() => ({ status: 200, body: VALID_RESOLVE_PAYLOAD, delayMs: 200 })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl, timeoutMs: 25 }) + + await expect(client.resolve({ domain: 'slow.agents.posthog.com' })).rejects.toThrow() + }) + }) + + describe('decryptSecrets', () => { + it('sends a POST with the requested names and parses the reply', async () => { + fake = await startFakeServer(() => ({ + status: 200, + body: { secrets: { OPENAI_API_KEY: 'sk-1', POSTHOG_KEY: 'phc' } }, + })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl, sharedKey: 'sek-ret' }) + + const result = await client.decryptSecrets('b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', [ + 'OPENAI_API_KEY', + 'POSTHOG_KEY', + ]) + + expect(result.secrets.OPENAI_API_KEY).toBe('sk-1') + const recorded = fake.recorded[0] + expect(recorded.method).toBe('POST') + expect(recorded.url).toBe('/internal/agents/secrets/b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01/decrypt') + expect(JSON.parse(recorded.body)).toEqual({ names: ['OPENAI_API_KEY', 'POSTHOG_KEY'] }) + expect(recorded.headers['x-internal-key']).toBe('sek-ret') + }) + + it('throws on non-2xx replies', async () => { + fake = await startFakeServer(() => ({ status: 403, body: { error: 'forbidden' } })) + const client = new InternalApiClient({ baseUrl: fake.baseUrl }) + + await expect( + client.decryptSecrets('b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', ['ANY']) + ).rejects.toThrow(/403/) + }) + }) +}) diff --git a/services/agent-core/src/queue/index.ts b/services/agent-core/src/queue/index.ts index 54013dd623a6..fa01ffc85dde 100644 --- a/services/agent-core/src/queue/index.ts +++ b/services/agent-core/src/queue/index.ts @@ -1,6 +1,8 @@ export { SessionQueueManager } from './manager' export { SessionQueueWorker } from './worker' export { SessionQueueJanitor } from './janitor' +export { SessionQuery } from './query' +export type { SessionView, ListSessionsFilter } from './query' export { SessionJobInitSchema, RescheduleOptionsSchema } from './types' export type { SessionStatus, diff --git a/services/agent-core/src/queue/query.ts b/services/agent-core/src/queue/query.ts new file mode 100644 index 000000000000..7890b90911a1 --- /dev/null +++ b/services/agent-core/src/queue/query.ts @@ -0,0 +1,200 @@ +import { DateTime } from 'luxon' +import { Pool } from 'pg' + +import { PoolConfig, SessionStatus } from './types' + +export interface SessionView { + readonly id: string + readonly teamId: number + readonly applicationId: string | null + readonly revisionId: string | null + readonly queueName: string + readonly status: SessionStatus + readonly scheduled: DateTime + readonly created: DateTime + readonly lastTransition: DateTime + readonly lastHeartbeat: DateTime | null + readonly transitionCount: number + readonly janitorTouchCount: number + readonly stateByteSize: number | null +} + +export interface ListSessionsFilter { + teamId?: number + applicationId?: string + revisionId?: string + status?: SessionStatus | readonly SessionStatus[] + /** Strict upper bound on `created`; for keyset pagination. */ + createdBefore?: Date + limit?: number +} + +interface RawSessionRow { + id: string + team_id: number + application_id: string | null + revision_id: string | null + queue_name: string + status: SessionStatus + scheduled: string + created: string + last_transition: string + last_heartbeat: string | null + transition_count: number + janitor_touch_count: number + state_byte_size: number | null +} + +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 200 + +const STATUSES: readonly SessionStatus[] = ['available', 'running', 'completed', 'failed', 'canceled'] + +/** + * Read-only + targeted-write queries over the agent_sessions queue. + * + * Worker / manager / janitor handle the lifecycle transitions; this is the surface + * the operational HTTP endpoints (Django → runtime internal API) use to render + * session lists and to cancel an in-flight session. + * + * Distinct from the worker dequeue path — these queries never lock rows. + */ +export class SessionQuery { + private readonly pool: Pool + + constructor(config: { pool: PoolConfig }) { + this.pool = new Pool({ + connectionString: config.pool.dbUrl, + max: config.pool.maxConnections ?? 5, + idleTimeoutMillis: config.pool.idleTimeoutMs ?? 30_000, + }) + } + + async connect(): Promise { + const client = await this.pool.connect() + client.release() + } + + async disconnect(): Promise { + await this.pool.end() + } + + async findSession(id: string): Promise { + const result = await this.pool.query( + `SELECT id, team_id, application_id, revision_id, queue_name, status, + scheduled, created, last_transition, last_heartbeat, + transition_count, janitor_touch_count, state_byte_size + FROM agent_sessions + WHERE id = $1`, + [id] + ) + if (result.rows.length === 0) { + return null + } + return rowToView(result.rows[0]) + } + + async listSessions(filter: ListSessionsFilter = {}): Promise { + const where: string[] = [] + const params: unknown[] = [] + const push = (clause: string, value: unknown): void => { + params.push(value) + where.push(clause.replace('?', `$${params.length}`)) + } + if (filter.teamId !== undefined) { + push('team_id = ?', filter.teamId) + } + if (filter.applicationId) { + push('application_id = ?', filter.applicationId) + } + if (filter.revisionId) { + push('revision_id = ?', filter.revisionId) + } + if (filter.status) { + const statuses = normalizeStatuses(filter.status) + push('status = ANY(?::AgentSessionStatus[])', statuses) + } + if (filter.createdBefore) { + push('created < ?', filter.createdBefore) + } + + const limit = clampLimit(filter.limit) + params.push(limit) + + const result = await this.pool.query( + `SELECT id, team_id, application_id, revision_id, queue_name, status, + scheduled, created, last_transition, last_heartbeat, + transition_count, janitor_touch_count, state_byte_size + FROM agent_sessions + ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY created DESC + LIMIT $${params.length}`, + params + ) + return result.rows.map(rowToView) + } + + /** + * Cancel a session that hasn't reached a terminal state yet. Returns the resulting + * view (or `null` if no row matches) so the caller can distinguish "already terminal" + * from "doesn't exist". + */ + async cancelSession(id: string): Promise { + const result = await this.pool.query( + `UPDATE agent_sessions + SET status = 'canceled', + lock_id = NULL, + last_heartbeat = NULL, + last_transition = NOW(), + transition_count = transition_count + 1 + WHERE id = $1 + AND status IN ('available', 'running') + RETURNING id, team_id, application_id, revision_id, queue_name, status, + scheduled, created, last_transition, last_heartbeat, + transition_count, janitor_touch_count, state_byte_size`, + [id] + ) + if (result.rows.length > 0) { + return rowToView(result.rows[0]) + } + return this.findSession(id) + } +} + +function rowToView(row: RawSessionRow): SessionView { + return { + id: row.id, + teamId: row.team_id, + applicationId: row.application_id, + revisionId: row.revision_id, + queueName: row.queue_name, + status: row.status, + scheduled: DateTime.fromISO(row.scheduled, { zone: 'utc' }), + created: DateTime.fromISO(row.created, { zone: 'utc' }), + lastTransition: DateTime.fromISO(row.last_transition, { zone: 'utc' }), + lastHeartbeat: row.last_heartbeat ? DateTime.fromISO(row.last_heartbeat, { zone: 'utc' }) : null, + transitionCount: row.transition_count, + janitorTouchCount: row.janitor_touch_count, + stateByteSize: row.state_byte_size, + } +} + +function normalizeStatuses(input: SessionStatus | readonly SessionStatus[]): SessionStatus[] { + const asArray = Array.isArray(input) ? input : [input as SessionStatus] + for (const s of asArray) { + if (!STATUSES.includes(s)) { + throw new Error(`SessionQuery: unknown status filter: ${s}`) + } + } + return asArray as SessionStatus[] +} + +function clampLimit(limit: number | undefined): number { + if (limit === undefined) { + return DEFAULT_LIMIT + } + if (!Number.isInteger(limit) || limit <= 0) { + return DEFAULT_LIMIT + } + return Math.min(limit, MAX_LIMIT) +} diff --git a/services/agent-core/src/queue/queue.test.ts b/services/agent-core/src/queue/queue.test.ts index ac4967bd4dcc..858198459ec6 100644 --- a/services/agent-core/src/queue/queue.test.ts +++ b/services/agent-core/src/queue/queue.test.ts @@ -9,7 +9,7 @@ import { v7 as uuidv7 } from 'uuid' * Skipped automatically if AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL is unset, so this * suite is safe in environments without a Postgres available. */ -import { DequeuedSessionJob, SessionQueueJanitor, SessionQueueManager, SessionQueueWorker } from '..' +import { DequeuedSessionJob, SessionQuery, SessionQueueJanitor, SessionQueueManager, SessionQueueWorker } from '..' const DB_URL = process.env.AGENT_RUNTIME_QUEUE_TEST_DATABASE_URL const describeIfDb = DB_URL ? describe : describe.skip @@ -96,6 +96,44 @@ describeIfDb('agent-core queue (DB-gated)', () => { expect(second[0].state?.toString('utf8')).toBe('world') }) + it('SessionQuery.findSession / listSessions / cancelSession', async () => { + const appA = '11111111-1111-4111-8111-111111111111' + const appB = '22222222-2222-4222-8222-222222222222' + await manager.createJob({ teamId: 1, applicationId: appA, queueName: 'test-queue' }) + await manager.createJob({ teamId: 1, applicationId: appA, queueName: 'test-queue' }) + await manager.createJob({ teamId: 2, applicationId: appB, queueName: 'test-queue' }) + + const query = new SessionQuery({ pool: { dbUrl: DB_URL! } }) + try { + await query.connect() + + // findSession returns null for unknown ids. + const unknown = await query.findSession('99999999-9999-4999-8999-999999999999') + expect(unknown).toBeNull() + + // listSessions filters by application. + const onlyAppA = await query.listSessions({ applicationId: appA }) + expect(onlyAppA).toHaveLength(2) + + // listSessions filters by status; everything is 'available' right now. + const completed = await query.listSessions({ status: 'completed' }) + expect(completed).toHaveLength(0) + + // cancelSession moves an available row to canceled and returns the new view. + const toCancel = onlyAppA[0] + const canceled = await query.cancelSession(toCancel.id) + expect(canceled?.status).toBe('canceled') + const refound = await query.findSession(toCancel.id) + expect(refound?.status).toBe('canceled') + + // Cancelling a canceled row is a no-op; we still return the current view. + const noop = await query.cancelSession(toCancel.id) + expect(noop?.status).toBe('canceled') + } finally { + await query.disconnect() + } + }) + it('janitor resets stalled jobs and fails poison pills', async () => { const id = await manager.createJob({ teamId: 1, queueName: 'test-queue' }) // Force the job into 'running' with an ancient heartbeat to simulate a stall. diff --git a/services/agent-ingress/src/listen.test.ts b/services/agent-ingress/src/listen.test.ts new file mode 100644 index 000000000000..632dc06d3241 --- /dev/null +++ b/services/agent-ingress/src/listen.test.ts @@ -0,0 +1,85 @@ +import { request as httpRequest } from 'node:http' + +import { InMemorySessionBus, SessionQueueManager } from '@posthog/agent-core' + +import { RevisionResolver } from './resolver' +import { ServerDeps, buildServer } from './server' + +/** + * SSE flow test for `/listen/:id`. Drives a real HTTP request via node:http so we can + * read frames as they arrive (supertest waits for the full body, which never completes + * for an SSE stream). + */ +describe('agent-ingress /listen SSE flow', () => { + it('streams subscribed events as SSE frames', async () => { + const bus = new InMemorySessionBus() + const deps: ServerDeps = { + queue: {} as unknown as SessionQueueManager, + bus, + resolver: {} as unknown as RevisionResolver, + domainSuffix: '.agents.posthog.com', + } + const app = buildServer(deps) + + let port = 0 + await new Promise((resolve, reject) => { + try { + app.listen(0, () => { + // ultimate-express adds address() at runtime — the Express type doesn't expose it. + port = (app as unknown as { address(): { port: number } }).address().port + resolve() + }) + } catch (err) { + reject(err) + } + }) + + try { + await new Promise((resolve, reject) => { + const req = httpRequest( + { + host: 'localhost', + port, + path: '/listen/abc', + method: 'GET', + }, + (res) => { + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + + let buffer = '' + res.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8') + if (buffer.includes('event: turn_completed')) { + expect(buffer).toContain('event: turn_started') + expect(buffer).toContain('"type":"turn_started"') + req.destroy() + resolve() + } + }) + res.on('error', reject) + } + ) + req.on('error', (err) => { + // destroy() emits ECONNRESET on the request — ignore that case + // because we triggered the close ourselves. + if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') { + return + } + reject(err) + }) + req.end() + + // Publish a few events once the subscription should be established. + // Small delay so the `subscribeEvents` listener is wired before we publish. + setTimeout(() => { + void bus.publishEvent('abc', { type: 'turn_started', at: '2026-05-14T00:00:00Z' }) + void bus.publishEvent('abc', { type: 'turn_completed', at: '2026-05-14T00:00:01Z' }) + }, 50) + }) + } finally { + await bus.disconnect() + } + }) +}) diff --git a/services/agent-ingress/src/resolver.test.ts b/services/agent-ingress/src/resolver.test.ts new file mode 100644 index 000000000000..9acb4f5c6d3b --- /dev/null +++ b/services/agent-ingress/src/resolver.test.ts @@ -0,0 +1,145 @@ +import { InternalApiClient, ResolvedRevision } from '@posthog/agent-core' + +import { RevisionResolver } from './resolver' + +function makeRevision(overrides: Partial = {}): ResolvedRevision { + return { + applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01', + applicationSlug: 'analytics-bot', + teamId: 7, + revisionId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a02', + revisionState: 'ready', + bundleS3Key: 's3://bundles/abc', + bundleSha256: 'abcd', + topLevelConfig: {}, + parsedManifest: null, + auth: { mode: 'public' }, + ...overrides, + } +} + +interface FakeClientCalls { + domains: string[] + applications: string[] +} + +function makeFakeClient(reply: ResolvedRevision | null): { + client: InternalApiClient + calls: FakeClientCalls +} { + const calls: FakeClientCalls = { domains: [], applications: [] } + const client = { + resolve: async ({ domain, applicationId }: { domain?: string; applicationId?: string }) => { + if (domain) { + calls.domains.push(domain) + } + if (applicationId) { + calls.applications.push(applicationId) + } + return reply + }, + } as unknown as InternalApiClient + return { client, calls } +} + +describe('RevisionResolver', () => { + it('caches the first lookup and serves subsequent calls from the LRU', async () => { + const { client, calls } = makeFakeClient(makeRevision()) + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + + expect(calls.domains).toEqual(['analytics-bot.agents.posthog.com']) + }) + + it('separates the domain and application keyspaces', async () => { + const { client, calls } = makeFakeClient(makeRevision()) + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + await resolver.resolveApplication('b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01') + + // Hitting the same domain a second time uses the cache; hitting the same applicationId + // through resolveApplication does not (different keyspace). + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + await resolver.resolveApplication('b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01') + + expect(calls.domains).toEqual(['analytics-bot.agents.posthog.com']) + expect(calls.applications).toEqual(['b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01']) + }) + + it('does not cache null replies — keeps trying on subsequent lookups', async () => { + const { client, calls } = makeFakeClient(null) + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await resolver.resolveDomain('missing.agents.posthog.com') + await resolver.resolveDomain('missing.agents.posthog.com') + + // Avoids caching a stale 404 between deploys. + expect(calls.domains.length).toBeGreaterThan(1) + }) + + it('expires entries after the TTL', async () => { + const { client, calls } = makeFakeClient(makeRevision()) + const resolver = new RevisionResolver({ client, ttlMs: 1 }) + + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + // Wait past the TTL. + await new Promise((resolve) => setTimeout(resolve, 10)) + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + + expect(calls.domains).toEqual([ + 'analytics-bot.agents.posthog.com', + 'analytics-bot.agents.posthog.com', + ]) + }) + + it('invalidate() evicts the cached entry for a domain', async () => { + const { client, calls } = makeFakeClient(makeRevision()) + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + resolver.invalidate({ domain: 'analytics-bot.agents.posthog.com' }) + await resolver.resolveDomain('analytics-bot.agents.posthog.com') + + expect(calls.domains).toEqual([ + 'analytics-bot.agents.posthog.com', + 'analytics-bot.agents.posthog.com', + ]) + }) + + it('invalidate() evicts only the requested key', async () => { + const { client, calls } = makeFakeClient(makeRevision()) + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await resolver.resolveDomain('a.agents.posthog.com') + await resolver.resolveDomain('b.agents.posthog.com') + resolver.invalidate({ domain: 'a.agents.posthog.com' }) + + await resolver.resolveDomain('a.agents.posthog.com') + await resolver.resolveDomain('b.agents.posthog.com') + + // a was invalidated and re-fetched; b stayed cached. + expect(calls.domains).toEqual([ + 'a.agents.posthog.com', + 'b.agents.posthog.com', + 'a.agents.posthog.com', + ]) + }) + + it('propagates errors from the client and does not cache the failure', async () => { + let attempt = 0 + const client = { + resolve: async () => { + attempt += 1 + throw new Error(`boom ${attempt}`) + }, + } as unknown as InternalApiClient + const resolver = new RevisionResolver({ client, ttlMs: 60_000 }) + + await expect(resolver.resolveDomain('x.agents.posthog.com')).rejects.toThrow('boom 1') + await expect(resolver.resolveDomain('x.agents.posthog.com')).rejects.toThrow('boom 2') + }) +}) diff --git a/services/agent-janitor/.eslintrc.cjs b/services/agent-janitor/.eslintrc.cjs new file mode 100644 index 000000000000..545e4e1ca410 --- /dev/null +++ b/services/agent-janitor/.eslintrc.cjs @@ -0,0 +1,32 @@ +const base = require('../agent-core/eslint.config.base.cjs') + +module.exports = { + ...base, + root: true, + parserOptions: { + ...base.parserOptions, + tsconfigRootDir: __dirname, + }, + rules: { + ...base.rules, + // Same blast-radius rule as ingress — janitor is operational, not a session executor. + // Anything that touches the SDK belongs in agent-runner. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@anthropic-ai/*', '@modal/*', 'modal', 'claude-agent-sdk'], + message: + 'agent-janitor must not import the Claude Agent SDK or Modal — those belong to agent-runner.', + }, + { + group: ['**/nodejs/*', '../../../nodejs/*', '@posthog/nodejs'], + message: + 'agent-janitor must not import from nodejs/ — cherry-pick into @posthog/agent-core instead.', + }, + ], + }, + ], + }, +} diff --git a/services/agent-janitor/.gitignore b/services/agent-janitor/.gitignore new file mode 100644 index 000000000000..83631f817f87 --- /dev/null +++ b/services/agent-janitor/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/services/agent-janitor/.prettierrc.cjs b/services/agent-janitor/.prettierrc.cjs new file mode 100644 index 000000000000..a42e97266ced --- /dev/null +++ b/services/agent-janitor/.prettierrc.cjs @@ -0,0 +1 @@ +module.exports = require('../agent-core/prettier.config.base.cjs') diff --git a/services/agent-janitor/README.md b/services/agent-janitor/README.md new file mode 100644 index 000000000000..d2f95766d2ef --- /dev/null +++ b/services/agent-janitor/README.md @@ -0,0 +1,28 @@ +# @posthog/agent-janitor + +Operational process for the PostHog agent platform. + +Two responsibilities, one process: + +1. **Janitor loop** — periodic sweep of the queue: reset stalled jobs, fail poison pills, clean up terminal rows, publish per-queue depth gauges. Owns the same `SessionQueueJanitor` from `@posthog/agent-core` that agent-runner used to host. +2. **Internal HTTP surface** — `/internal/sessions/*` endpoints that the PostHog app (Django) calls to render the sessions UI and to cancel sessions. + +The runtime owns session state. Django **never** writes to `agent_sessions`; the queue row is the single source of truth and Django reads it through this service. + +## Routes + +| Method | Path | Purpose | +| ------ | ----------------------------------- | -------------------------------------------- | +| GET | `/internal/sessions/:id` | Fetch a single session by queue id | +| GET | `/internal/sessions` | List sessions filtered by `application_id`, `revision_id`, `status`, `team_id`, `created_before`, `limit` | +| POST | `/internal/sessions/:id/cancel` | Cancel an `available` or `running` session | +| GET | `/health` | Always returns `{ok: true}` while listening | +| GET | `/metrics` | Prometheus scrape endpoint | + +All `/internal/*` routes are gated by the `x-internal-key` header (`AGENT_INTERNAL_API_SHARED_KEY`). + +## Hard rules + +- **No imports from `nodejs/`.** Cherry-pick by copy. +- **No Anthropic / Modal / Claude Agent SDK imports.** Same blast-radius rule as ingress. +- Reads + cancels only — never enqueues. Enqueueing is ingress (`/run`) and is intentionally separated. diff --git a/services/agent-janitor/jest.config.js b/services/agent-janitor/jest.config.js new file mode 100644 index 000000000000..c53ebf19d35a --- /dev/null +++ b/services/agent-janitor/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + testTimeout: 15_000, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, +} diff --git a/services/agent-janitor/package.json b/services/agent-janitor/package.json new file mode 100644 index 000000000000..b97e964ea642 --- /dev/null +++ b/services/agent-janitor/package.json @@ -0,0 +1,50 @@ +{ + "name": "@posthog/agent-janitor", + "version": "0.1.0", + "private": true, + "description": "Operational process for the PostHog agent platform: queue janitor sweeps + internal HTTP surface for Django (read sessions, cancel sessions).", + "license": "MIT", + "author": "PostHog ", + "repository": "https://github.com/PostHog/posthog", + "main": "dist/index.js", + "scripts": { + "build": "pnpm clean && tsc -b", + "clean": "rm -rf dist", + "typescript:check": "tsc --noEmit -p .", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "jest --runInBand --forceExit", + "start": "node dist/index.js", + "start:dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@posthog/agent-core": "workspace:*", + "luxon": "^3.4.4", + "ultimate-express": "^2.0.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/jest": "catalog:", + "@types/luxon": "^3.4.2", + "@types/node": "catalog:", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-no-only-tests": "^3.1.0", + "jest": "catalog:", + "prettier": "^3.6.2", + "supertest": "^7.0.0", + "ts-jest": "^29.1.0", + "tsx": "^4.7.0", + "typescript": "catalog:" + }, + "engines": { + "node": ">=24 <25" + } +} diff --git a/services/agent-janitor/src/auth.ts b/services/agent-janitor/src/auth.ts new file mode 100644 index 000000000000..b732eb96e5d7 --- /dev/null +++ b/services/agent-janitor/src/auth.ts @@ -0,0 +1,39 @@ +import { timingSafeEqual } from 'node:crypto' + +import { NextFunction, Request, Response } from 'ultimate-express' + +export interface InternalAuthOptions { + /** Shared key the proxy/Django supplies via `x-internal-key`. */ + sharedKey: string | undefined +} + +/** + * Gate `/internal/*` routes behind a static shared key. When the key isn't configured + * we refuse all traffic — a janitor without a key is a foot-gun in production. + * + * mTLS at the mesh level (when we get there) is the longer-term plan; this header + * check is a defense-in-depth layer that doesn't need infra cooperation. + */ +export function requireInternalKey(options: InternalAuthOptions) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!options.sharedKey) { + res.status(500).json({ error: 'AGENT_INTERNAL_API_SHARED_KEY not configured' }) + return + } + const presented = req.header('x-internal-key') ?? '' + if (!constantTimeEqual(presented, options.sharedKey)) { + res.status(401).json({ error: 'invalid internal key' }) + return + } + next() + } +} + +function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8') + const bb = Buffer.from(b, 'utf8') + if (ab.length !== bb.length) { + return false + } + return timingSafeEqual(ab, bb) +} diff --git a/services/agent-janitor/src/config.ts b/services/agent-janitor/src/config.ts new file mode 100644 index 000000000000..a1081c60855e --- /dev/null +++ b/services/agent-janitor/src/config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +const ConfigSchema = z.object({ + port: z.coerce.number().int().min(1).max(65_535).default(3031), + queueDbUrl: z.string().min(1), + /** + * Shared key required on every `/internal/*` request. Django supplies it via + * `x-internal-key`. When unset, internal routes refuse all traffic — keep the + * service operable in dev by setting it explicitly. + */ + internalApiSharedKey: z.string().min(1).optional(), + janitorIntervalMs: z.coerce.number().int().min(0).default(10_000), + janitorStallTimeoutMs: z.coerce.number().int().min(0).default(30_000), + janitorMaxTouchCount: z.coerce.number().int().min(1).default(3), + janitorCleanupGraceMs: z.coerce.number().int().min(0).default(10_000), +}) + +export type JanitorServiceConfig = z.infer + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): JanitorServiceConfig { + return ConfigSchema.parse({ + port: env.PORT, + queueDbUrl: env.AGENT_RUNTIME_QUEUE_DATABASE_URL, + internalApiSharedKey: env.AGENT_INTERNAL_API_SHARED_KEY, + janitorIntervalMs: env.JANITOR_INTERVAL_MS, + janitorStallTimeoutMs: env.JANITOR_STALL_TIMEOUT_MS, + janitorMaxTouchCount: env.JANITOR_MAX_TOUCH_COUNT, + janitorCleanupGraceMs: env.JANITOR_CLEANUP_GRACE_MS, + }) +} diff --git a/services/agent-janitor/src/index.ts b/services/agent-janitor/src/index.ts new file mode 100644 index 000000000000..bd085ea7f89e --- /dev/null +++ b/services/agent-janitor/src/index.ts @@ -0,0 +1,46 @@ +import { SessionQuery, SessionQueueJanitor, logger } from '@posthog/agent-core' + +import { loadConfig } from './config' +import { buildServer } from './server' + +async function main(): Promise { + const config = loadConfig() + + if (!config.internalApiSharedKey) { + logger.warn('agent-janitor starting without AGENT_INTERNAL_API_SHARED_KEY — /internal routes will refuse traffic') + } + + const query = new SessionQuery({ pool: { dbUrl: config.queueDbUrl } }) + await query.connect() + + const janitor = new SessionQueueJanitor({ + pool: { dbUrl: config.queueDbUrl }, + cleanupIntervalMs: config.janitorIntervalMs, + stallTimeoutMs: config.janitorStallTimeoutMs, + maxTouchCount: config.janitorMaxTouchCount, + cleanupGraceMs: config.janitorCleanupGraceMs, + }) + await janitor.start() + + const app = buildServer({ query, internalApiSharedKey: config.internalApiSharedKey }) + + const server = app.listen(config.port, () => { + logger.info('agent-janitor listening', { port: config.port }) + }) + + const shutdown = async (signal: string): Promise => { + logger.info('agent-janitor shutting down', { signal }) + server.close() + await janitor.stop() + await query.disconnect() + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) +} + +main().catch((err) => { + logger.error('agent-janitor fatal', { error: String(err) }) + process.exit(1) +}) diff --git a/services/agent-janitor/src/routes/sessions.ts b/services/agent-janitor/src/routes/sessions.ts new file mode 100644 index 000000000000..3da46bdb47d2 --- /dev/null +++ b/services/agent-janitor/src/routes/sessions.ts @@ -0,0 +1,111 @@ +import { Express, Request, Response } from 'ultimate-express' +import { z } from 'zod' + +import { ListSessionsFilter, SessionQuery, SessionStatus, SessionView, logger } from '@posthog/agent-core' + +const STATUS_VALUES = ['available', 'running', 'completed', 'failed', 'canceled'] as const +const StatusSchema = z.enum(STATUS_VALUES) + +const ListQuerySchema = z.object({ + team_id: z.coerce.number().int().optional(), + application_id: z.string().uuid().optional(), + revision_id: z.string().uuid().optional(), + status: z + .union([StatusSchema, z.array(StatusSchema)]) + .optional() + .transform((v) => (v === undefined ? undefined : Array.isArray(v) ? v : [v])), + created_before: z.coerce.date().optional(), + limit: z.coerce.number().int().positive().optional(), +}) + +export interface SessionsRouteDeps { + query: SessionQuery +} + +export function registerSessionsRoutes(app: Express, deps: SessionsRouteDeps): void { + app.get('/internal/sessions/:id', async (req: Request, res: Response) => { + const id = parseSessionId(req.params.id) + if (!id) { + res.status(400).json({ error: 'invalid session id' }) + return + } + try { + const view = await deps.query.findSession(id) + if (!view) { + res.status(404).json({ error: 'session not found' }) + return + } + res.json(viewToJson(view)) + } catch (err) { + logger.error('agent-janitor findSession failed', { id, error: String(err) }) + res.status(503).json({ error: 'session lookup failed' }) + } + }) + + app.get('/internal/sessions', async (req: Request, res: Response) => { + const parsed = ListQuerySchema.safeParse(req.query) + if (!parsed.success) { + res.status(400).json({ error: 'invalid query', issues: parsed.error.issues }) + return + } + const filter: ListSessionsFilter = { + teamId: parsed.data.team_id, + applicationId: parsed.data.application_id, + revisionId: parsed.data.revision_id, + status: parsed.data.status as readonly SessionStatus[] | undefined, + createdBefore: parsed.data.created_before, + limit: parsed.data.limit, + } + try { + const results = await deps.query.listSessions(filter) + res.json({ results: results.map(viewToJson) }) + } catch (err) { + logger.error('agent-janitor listSessions failed', { error: String(err) }) + res.status(503).json({ error: 'session list failed' }) + } + }) + + app.post('/internal/sessions/:id/cancel', async (req: Request, res: Response) => { + const id = parseSessionId(req.params.id) + if (!id) { + res.status(400).json({ error: 'invalid session id' }) + return + } + try { + const view = await deps.query.cancelSession(id) + if (!view) { + res.status(404).json({ error: 'session not found' }) + return + } + res.json(viewToJson(view)) + } catch (err) { + logger.error('agent-janitor cancelSession failed', { id, error: String(err) }) + res.status(503).json({ error: 'session cancel failed' }) + } + }) +} + +function parseSessionId(raw: string | undefined): string | null { + if (!raw) { + return null + } + return z.string().uuid().safeParse(raw).success ? raw : null +} + +function viewToJson(view: SessionView): Record { + return { + id: view.id, + team_id: view.teamId, + application_id: view.applicationId, + revision_id: view.revisionId, + queue_name: view.queueName, + status: view.status, + scheduled: view.scheduled.toISO(), + created: view.created.toISO(), + last_transition: view.lastTransition.toISO(), + last_heartbeat: view.lastHeartbeat?.toISO() ?? null, + transition_count: view.transitionCount, + janitor_touch_count: view.janitorTouchCount, + state_byte_size: view.stateByteSize, + } +} diff --git a/services/agent-janitor/src/server.test.ts b/services/agent-janitor/src/server.test.ts new file mode 100644 index 000000000000..ea926d8eb30c --- /dev/null +++ b/services/agent-janitor/src/server.test.ts @@ -0,0 +1,240 @@ +import { DateTime } from 'luxon' +import supertest from 'supertest' +import type { Express } from 'ultimate-express' + +import { ListSessionsFilter, SessionQuery, SessionView } from '@posthog/agent-core' + +import { JanitorServerDeps, buildServer } from './server' + +const VALID_UUID = 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a01' +const OTHER_UUID = 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a02' +const SHARED_KEY = 'unit-test-internal-key' + +class FakeSessionQuery { + public lastList: ListSessionsFilter | null = null + public canceled: string[] = [] + + constructor(private readonly rows: SessionView[]) {} + + findSession = async (id: string): Promise => this.rows.find((r) => r.id === id) ?? null + + listSessions = async (filter: ListSessionsFilter): Promise => { + this.lastList = filter + return this.rows.filter((row) => { + if (filter.teamId !== undefined && row.teamId !== filter.teamId) { + return false + } + if (filter.applicationId && row.applicationId !== filter.applicationId) { + return false + } + if (filter.revisionId && row.revisionId !== filter.revisionId) { + return false + } + if (filter.status) { + const statuses = Array.isArray(filter.status) ? filter.status : [filter.status] + if (!statuses.includes(row.status)) { + return false + } + } + return true + }) + } + + cancelSession = async (id: string): Promise => { + const row = this.rows.find((r) => r.id === id) + if (!row) { + return null + } + this.canceled.push(id) + return { ...row, status: 'canceled' } + } +} + +function makeView(overrides: Partial = {}): SessionView { + const now = DateTime.utc() + return { + id: VALID_UUID, + teamId: 7, + applicationId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a03', + revisionId: 'b1f3d6e4-4c2a-4b0e-9d5a-1c9f7e1d8a04', + queueName: 'default', + status: 'running', + scheduled: now, + created: now, + lastTransition: now, + lastHeartbeat: now, + transitionCount: 1, + janitorTouchCount: 0, + stateByteSize: 42, + ...overrides, + } +} + +interface TestHarness { + query: FakeSessionQuery + app: Express +} + +async function startServer(args: { + rows?: SessionView[] + sharedKey?: string | undefined +}): Promise { + const query = new FakeSessionQuery(args.rows ?? []) + const deps: JanitorServerDeps = { + query: query as unknown as SessionQuery, + internalApiSharedKey: args.sharedKey, + } + const app = buildServer(deps) + await new Promise((resolve, reject) => { + try { + app.listen(0, () => resolve()) + } catch (err) { + reject(err) + } + }) + return { query, app } +} + +describe('agent-janitor server', () => { + let harness: TestHarness + + afterEach(() => { + // app does not expose close() in ultimate-express, and the supertest agent does + // not hold onto the underlying server; relying on jest --forceExit to tear it down. + }) + + describe('public routes', () => { + it('GET /health is open and returns ok', async () => { + harness = await startServer({ sharedKey: SHARED_KEY }) + const res = await supertest(harness.app).get('/health') + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true }) + }) + + it('GET /metrics is open and returns prometheus text', async () => { + harness = await startServer({ sharedKey: SHARED_KEY }) + const res = await supertest(harness.app).get('/metrics') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + }) + }) + + describe('auth gating on /internal/*', () => { + it('refuses with 500 when no shared key is configured', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: undefined }) + const res = await supertest(harness.app).get(`/internal/sessions/${VALID_UUID}`) + expect(res.status).toBe(500) + }) + + it('refuses with 401 when the key is missing', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app).get(`/internal/sessions/${VALID_UUID}`) + expect(res.status).toBe(401) + }) + + it('refuses with 401 when the key is wrong', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get(`/internal/sessions/${VALID_UUID}`) + .set('x-internal-key', 'not-the-key') + expect(res.status).toBe(401) + }) + + it('accepts with 200 when the key matches', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get(`/internal/sessions/${VALID_UUID}`) + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(200) + }) + }) + + describe('GET /internal/sessions/:id', () => { + it('returns the session as snake_case JSON', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get(`/internal/sessions/${VALID_UUID}`) + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + id: VALID_UUID, + team_id: 7, + status: 'running', + queue_name: 'default', + state_byte_size: 42, + }) + }) + + it('returns 404 when the session does not exist', async () => { + harness = await startServer({ rows: [], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get(`/internal/sessions/${VALID_UUID}`) + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(404) + }) + + it('returns 400 for non-uuid ids', async () => { + harness = await startServer({ rows: [], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get('/internal/sessions/not-a-uuid') + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(400) + }) + }) + + describe('GET /internal/sessions', () => { + it('lists sessions filtered by application_id + status', async () => { + const rowA = makeView({ id: VALID_UUID, status: 'running' }) + const rowB = makeView({ id: OTHER_UUID, status: 'completed' }) + harness = await startServer({ rows: [rowA, rowB], sharedKey: SHARED_KEY }) + + const res = await supertest(harness.app) + .get(`/internal/sessions?application_id=${rowA.applicationId}&status=running`) + .set('x-internal-key', SHARED_KEY) + + expect(res.status).toBe(200) + expect(res.body.results).toHaveLength(1) + expect(res.body.results[0].id).toBe(VALID_UUID) + expect(harness.query.lastList).toMatchObject({ + applicationId: rowA.applicationId, + status: ['running'], + }) + }) + + it('rejects invalid query parameters', async () => { + harness = await startServer({ rows: [], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .get('/internal/sessions?application_id=not-a-uuid') + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(400) + }) + }) + + describe('POST /internal/sessions/:id/cancel', () => { + it('cancels an existing session and returns the new view', async () => { + harness = await startServer({ rows: [makeView()], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .post(`/internal/sessions/${VALID_UUID}/cancel`) + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(200) + expect(res.body.status).toBe('canceled') + expect(harness.query.canceled).toEqual([VALID_UUID]) + }) + + it('returns 404 when the session does not exist', async () => { + harness = await startServer({ rows: [], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .post(`/internal/sessions/${VALID_UUID}/cancel`) + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(404) + }) + + it('returns 400 for non-uuid ids', async () => { + harness = await startServer({ rows: [], sharedKey: SHARED_KEY }) + const res = await supertest(harness.app) + .post('/internal/sessions/not-a-uuid/cancel') + .set('x-internal-key', SHARED_KEY) + expect(res.status).toBe(400) + }) + }) +}) diff --git a/services/agent-janitor/src/server.ts b/services/agent-janitor/src/server.ts new file mode 100644 index 000000000000..c5c34fb2b2f4 --- /dev/null +++ b/services/agent-janitor/src/server.ts @@ -0,0 +1,38 @@ +import express, { Express } from 'ultimate-express' + +import { SessionQuery, collectDefaults, logger, metricsContentType, metricsText } from '@posthog/agent-core' + +import { requireInternalKey } from './auth' +import { registerSessionsRoutes } from './routes/sessions' + +export interface JanitorServerDeps { + query: SessionQuery + /** Required for `/internal/*` routes. Routes refuse traffic when undefined. */ + internalApiSharedKey: string | undefined +} + +export function buildServer(deps: JanitorServerDeps): Express { + collectDefaults() + const app = express() + + app.use(express.json({ limit: '64kb' })) + app.use((req, _res, next) => { + logger.debug('agent-janitor request', { method: req.method, path: req.path }) + next() + }) + + app.get('/health', (_req, res) => { + res.json({ ok: true }) + }) + + app.get('/metrics', async (_req, res) => { + res.set('content-type', metricsContentType()) + res.send(await metricsText()) + }) + + // Path-prefix middleware: any request under /internal/* runs through the shared-key check. + app.use('/internal', requireInternalKey({ sharedKey: deps.internalApiSharedKey })) + registerSessionsRoutes(app, { query: deps.query }) + + return app +} diff --git a/services/agent-janitor/tsconfig.eslint.json b/services/agent-janitor/tsconfig.eslint.json new file mode 100644 index 000000000000..4513f4555b92 --- /dev/null +++ b/services/agent-janitor/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/agent-janitor/tsconfig.json b/services/agent-janitor/tsconfig.json new file mode 100644 index 000000000000..795ea8a88fb1 --- /dev/null +++ b/services/agent-janitor/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist/", + "rootDir": "src/", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/services/agent-janitor/tsconfig.test.json b/services/agent-janitor/tsconfig.test.json new file mode 100644 index 000000000000..4513f4555b92 --- /dev/null +++ b/services/agent-janitor/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 7222127dc6b87ecca8bf80cc7add9a395fc200b2 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Wed, 13 May 2026 23:31:20 -0400 Subject: [PATCH 009/517] api --- docs/internal/agent-platform.md | 35 +- posthog/api/__init__.py | 24 + posthog/scopes.py | 5 +- posthog/settings/object_storage.py | 5 + products/agent_stack/backend/api.py | 283 ++++++++++++ products/agent_stack/backend/deploys.py | 153 +++++++ .../agent_stack/backend/{facade => }/enums.py | 8 +- .../agent_stack/backend/facade/__init__.py | 0 products/agent_stack/backend/facade/api.py | 9 - .../agent_stack/backend/facade/contracts.py | 6 - .../agent_stack/backend/logic/__init__.py | 3 - .../backend/migrations/0001_initial.py | 14 +- products/agent_stack/backend/models.py | 6 +- .../backend/presentation/__init__.py | 0 .../backend/presentation/serializers.py | 1 - .../agent_stack/backend/presentation/urls.py | 3 - .../agent_stack/backend/presentation/views.py | 1 - products/agent_stack/backend/serializers.py | 216 +++++++++ .../agent_stack/backend/tests/test_api.py | 409 ++++++++++++++++++ .../agent_stack/backend/tests/test_logic.py | 0 .../agent_stack/backend/tests/test_models.py | 0 .../backend/tests/test_presentation.py | 0 .../agent_stack/backend/tests/test_tasks.py | 0 23 files changed, 1127 insertions(+), 54 deletions(-) create mode 100644 products/agent_stack/backend/api.py create mode 100644 products/agent_stack/backend/deploys.py rename products/agent_stack/backend/{facade => }/enums.py (73%) delete mode 100644 products/agent_stack/backend/facade/__init__.py delete mode 100644 products/agent_stack/backend/facade/api.py delete mode 100644 products/agent_stack/backend/facade/contracts.py delete mode 100644 products/agent_stack/backend/logic/__init__.py delete mode 100644 products/agent_stack/backend/presentation/__init__.py delete mode 100644 products/agent_stack/backend/presentation/serializers.py delete mode 100644 products/agent_stack/backend/presentation/urls.py delete mode 100644 products/agent_stack/backend/presentation/views.py create mode 100644 products/agent_stack/backend/serializers.py delete mode 100644 products/agent_stack/backend/tests/test_logic.py delete mode 100644 products/agent_stack/backend/tests/test_models.py delete mode 100644 products/agent_stack/backend/tests/test_presentation.py delete mode 100644 products/agent_stack/backend/tests/test_tasks.py diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index b3aec8e6fcbd..504db07940bb 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -131,7 +131,7 @@ Shared library, no process of its own. Lives here: The public-facing process. Responsibilities: - All `*.agents.posthog.com` traffic terminates here. -- Domain → `(application, revision)` resolution via the Django internal `/internal/agents/applications/resolve` endpoint. In-process LRU keyed by revision id, invalidated on promotion (we expose a small admin endpoint for Django to ping after promote — or just rely on TTL, decide at impl time). +- Domain → `(application, revision)` resolution by direct Postgres query against the main posthog DB (no Django HTTP hop). In-process LRU keyed by revision id, TTL-based invalidation on promotion. - Per-app auth derived from the resolved revision's config (public / webhook signature / shared secret). - Implements `/run`, `/listen/:id`, `/send/:id`, `/webhooks/:provider`, `/health`, `/status`. Same contract as the SDK's local dev server. - `/run` writes an `AgentApplicationSession` row + enqueues a session job in the agent-core queue, returns `{ session_id }` immediately. @@ -239,7 +239,7 @@ All inherit `UUIDModel` ([`posthog/models/utils.py:183`](../../posthog/models/ut **`AgentApplication`** (team-scoped) - `team: FK(Team)`, `name`, `slug` (unique — partial unique constraint where `deleted=False` so deleted slugs can be reclaimed), `description` -- `encrypted_env: EncryptedTextField` — raw `.env` contents uploaded by the developer, single encrypted blob. Plaintext never returned by the REST API after creation; decryption gated to the internal API, audit-logged per call. (Replaces a separate `AgentApplicationSecret` per-key model — single blob is enough for v1.) +- `encrypted_env: EncryptedTextField(null=True)` — raw `.env` contents uploaded by the developer, single encrypted blob. Null when no env is set (`EncryptedFieldMixin.get_prep_value` writes None for falsy values, so `null=True` is required to avoid a NOT NULL violation on insert). Plaintext never returned by the REST API after creation; the application serializer exposes a derived `env_redacted` field rendering one `KEY=********` line per declared key for UI display. Only the agent-runner decrypts the plaintext in-process via Fernet, audit-logged from the runner. (Replaces a separate `AgentApplicationSecret` per-key model — single blob is enough for v1.) - Soft delete (`deleted: bool`, `deleted_at`) - Activity-logged via `log_activity_from_viewset` ([`posthog/api/hog_function.py:640`](../../posthog/api/hog_function.py)) @@ -253,7 +253,7 @@ Note: there is **no `live_revision` FK** on the application. "Which revision is - Default `disabled`. Logic-layer rule: must be `state=ready` before promotion to `live` or `preview`. - At-most-one `live` per application is enforced at the API layer, not the DB (lets promotion fail cleanly rather than via a unique-constraint violation). - Promotion is a single-row update: set new revision `live`, demote previous `live` to `disabled`. -- `bundle_s3_key`, `bundle_size`, `bundle_sha256` — content-hash binding for the presigned PUT. +- `bundle_s3_key`, `bundle_size`, `bundle_sha256` — S3 location, exact size (enforced by the presigned POST via `content-length-range`), and the CLI-reported SHA-256. The hash is metadata only at upload time; the future async validator re-hashes the uploaded bundle and verifies. Aligns with every other presigned-POST endpoint in the codebase (error_tracking, visual_review, tasks) which similarly trusts the client hash. - `top_level_config: JSONField` — validated synchronously at deploy start by Django. - `parsed_manifest: JSONField(null=True)` — populated by the future validator package. v1 leaves this null and runner falls back to reading the bundle's `.ass.yaml` manifest section directly via `top_level_config`. - `validation_report: JSONField(null=True)` — structured errors when the future validator marks `failed`. @@ -299,19 +299,26 @@ Viewsets follow `TeamAndOrgViewSetMixin` + `scope_object` ([`posthog/api/hog_fun Endpoints (project-scoped `/api/projects/{team_id}/...`): - `agent_applications/` — CRUD + soft delete - - `POST /:id/start_deploy` → `{ revision_id, presigned_put_url, expires_at, max_size, required_sha256 }` + - `POST /:id/start_deploy` → `{ revision_id, upload_url, upload_fields, expires_at, max_size, required_sha256 }` (presigned S3 POST with `content-length-range` bound to the exact size; `required_sha256` is what the CLI claimed and is stored on the revision row for the validator to re-verify later) - `POST /:id/complete_upload` → **v1: synchronously transition the revision to `state=ready`** (skipping `validating`). Logged so we know which revisions never went through real validation when the validator lands. - `POST /:id/promote` → atomically set the target revision's `deployment_status=live` and demote any prior live revision to `disabled`. Validates the target is `state=ready`. - - `PUT /:id/env` — replace `encrypted_env`. No plaintext read; response omits the field. -- `agent_application_revisions/` — list + retrieve (read-only). Filter by `deployment_status` for "find live", "list previews". -- `agent_application_sessions/` — list + retrieve. Filters: `application_id`, `state`, `parent_run_id`, time range. + - `POST /:id/preview` → set the target revision's `deployment_status=preview`. Validates `state=ready`. Previews coexist — no siblings demoted. + - `POST /:id/disable_revision` → set the target revision's `deployment_status=disabled`. Allowed from any state. Use to pull a broken live or preview out of traffic. + - `PUT /:id/env` — replace `encrypted_env`. No plaintext read; the response carries `env_redacted` (one `KEY=********` line per declared key) instead. +- `agent_applications/:slug-or-uuid/revisions/` — nested, list + retrieve (read-only). Filter by `deployment_status`, `state`. +- `agent_applications/:slug-or-uuid/sessions/` — nested, list + retrieve. Filter by `revision`, `state`, `parent_run_id`, `created_after`, `created_before`. -**Internal-only endpoints** (called by `agent-ingress` and `agent-runner`): +`ass secrets list` is intentionally not exposed as a dedicated endpoint. The set of configured key names is already surfaced via `env_redacted` on the application detail response, so the CLI / UI can render "your env contains these keys" without a separate call. The developer's local `.env` remains the source of truth for values. -- `GET /internal/agents/applications/resolve` — given a domain or app id, returns the live revision + manifest. For preview subdomains (`-`) the suffix is the revision id. Cacheable ~5s. -- `POST /internal/agents/applications/{app_id}/decrypt_env` — returns plaintext `encrypted_env`. Audit-logged. Separate internal scope, not exposed in OAuth UI. +Sandboxes have no dedicated viewset. Sandbox usage is inferred per session (the runner annotates session events with the sandbox id when a tool call runs there); a sandbox-level dashboard would be additive later. -Add to `INTERNAL_API_SCOPE_OBJECTS` ([`posthog/scopes.py:121`](../../posthog/scopes.py)) so they don't appear in PAT creation flows. +**No internal HTTP API for v1.** The runtime packages read from the main posthog Postgres DB directly: + +- `agent-ingress` queries `AgentApplication` + `AgentApplicationRevision` for domain resolution. +- `agent-runner` reads `encrypted_env` and decrypts in-process using `ENCRYPTION_SALT_KEYS` passed via deployment env (Fernet is reimplemented in TS — well-defined spec, small surface). +- Per-decrypt audit log is emitted from the runner, not Django. + +Keep `ENCRYPTION_SALT_KEYS` out of `agent-ingress` — only the runner needs them. The HTTP boundary is worth revisiting if/when the runtime stops being a first-party service. ### Frontend @@ -339,7 +346,7 @@ CLI is the primary deploy surface in v1; this UI is management + observability. 1. CLI bundles the project locally. 2. CLI calls Django `start_deploy` with the parsed top-level config. Django validates synchronously (schema-level checks on `.ass.yaml` and triggers) and creates an `AgentApplicationRevision` row in `state=pending_upload`. -3. Django returns a presigned S3 PUT URL bound to size + content hash. +3. Django returns a presigned S3 POST URL bound to the exact bundle size. The CLI-reported sha256 is stored on the revision row; not enforced at upload time (matches existing presigned-POST patterns in the codebase). 4. CLI uploads the bundle to S3. 5. CLI calls `complete_upload`. 6. **v1 shortcut**: Django transitions the revision `uploaded → ready` immediately, with no manifest parsing. The bundle is trusted as-is. @@ -377,7 +384,7 @@ Pure-function validators (`(bytes) -> (parsed, errors)`) inside the validator pa - `AgentApplicationSession` and `AgentApplicationSandboxInstance` mirrors live in main posthog Postgres (team-scoped, FKs, activity log eligible). - Runner writes to both — queue row is the work item, `AgentApplicationSession` is the user-visible record. - **S3 bucket**: new `posthog-agent-bundles-{env}`, KMS-encrypted, lifecycle expires non-`ready` bundles after 7 days. Use [`posthog/storage/object_storage.py:33`](../../posthog/storage/object_storage.py) helpers from Django. -- **Secrets**: `AgentApplication.encrypted_env` is an `EncryptedTextField` (same key schedule as `Integration.sensitive_config`). Decrypt only in `agent-runner` via the internal API. +- **Secrets**: `AgentApplication.encrypted_env` is an `EncryptedTextField` (same key schedule as `Integration.sensitive_config`). Only `agent-runner` decrypts (in-process Fernet), and only the runner deployment receives `ENCRYPTION_SALT_KEYS`. Audit log emitted from the runner. - **Per-team quotas**: enforced on Django writes (apps, secrets, revisions/day) and at `agent-ingress` (concurrent sessions per app, `/run` rate limit). Surface limits in the UI. - **Observability**: structured logs with `app_id` / `revision_id` / `session_id` / `queue_job_id`; OTel traces per session and per tool call; Prometheus metrics; Sentry tagged separately for `agent-ingress` and `agent-runner`. - **Feature flag**: `FEATURE_FLAGS.AGENTS` gates the product (frontend + API + ingress). Per-team rollout. @@ -407,7 +414,7 @@ Each shippable behind `FEATURE_FLAGS.AGENTS`. 1. **Scaffold + models.** `products/agent_stack/` skeleton, Django app, models with the **full state machine in the schema**, migrations. New scope entries. UI stub. _(unblocks parallel work)_ 2. **Management API.** CRUD viewsets for apps and revisions. Env upload endpoint. Activity logging wired. `complete_upload` shortcut transitions straight to `state=ready`. Promote endpoint flips `deployment_status`. 3. **Deploy flow.** `start_deploy` → presigned PUT → `complete_upload` (auto-ready) → `promote`. End-to-end via CLI. No async work. -4. **Internal API.** `resolve` + `decrypt_env` endpoints with internal scopes. mTLS / signed-key auth. +4. **Runtime DB access.** Wire `agent-ingress` to read `AgentApplication` + `AgentApplicationRevision` directly; wire `agent-runner` to read `encrypted_env` and decrypt in-process via Fernet. Pass `ENCRYPTION_SALT_KEYS` to runner deployment only. Audit log emitted from runner. 5. **`packages/agent-core/`.** Types, DB clients, queue primitives (schema + ops), pub-sub helper, internal-API client, logger/metrics. No process; tested in isolation. 6. **`packages/agent-ingress/`.** Domain resolution, `/run` writes `AgentApplicationSession` + enqueues job, `/listen` SSE wired to pub-sub, `/send` publishes to pub-sub. Runner stubbed. 7. **`packages/agent-runner/` — meta + built-in tools.** Queue consumer. Real Claude Agent SDK invocation. State serialized into queue `state`, reschedule loop on tool boundaries. Built-ins registry shared with `agent-core`. diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index 71571097a9dc..dab458268682 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -41,6 +41,11 @@ import products.data_warehouse.backend.api.fix_hogql as fix_hogql import products.mcp_store.backend.presentation.views as mcp_store import products.legal_documents.backend.presentation.views as legal_documents +from products.agent_stack.backend.api import ( + AgentApplicationRevisionViewSet, + AgentApplicationSessionViewSet, + AgentApplicationViewSet, +) from products.dashboards.backend.api import dashboard, dashboard_templates from products.data_modeling.backend.api import DAGViewSet, EdgeViewSet, NodeViewSet from products.data_warehouse.backend.api import ( @@ -1328,6 +1333,25 @@ def register_grandfathered_environment_nested_viewset( ["project_id"], ) +agent_applications_router = projects_router.register( + r"agent_applications", + AgentApplicationViewSet, + "project_agent_applications", + ["project_id"], +) +agent_applications_router.register( + r"revisions", + AgentApplicationRevisionViewSet, + "project_agent_application_revisions", + ["project_id", "application_id"], +) +agent_applications_router.register( + r"sessions", + AgentApplicationSessionViewSet, + "project_agent_application_sessions", + ["project_id", "application_id"], +) + environments_router.register( r"tracing/spans", TracingSpansViewSet, diff --git a/posthog/scopes.py b/posthog/scopes.py index 9a543214622e..42d00355bc4f 100644 --- a/posthog/scopes.py +++ b/posthog/scopes.py @@ -17,6 +17,7 @@ "action", "access_control", "activity_log", + "agent_application", "alert", "annotation", "approvals", @@ -123,7 +124,9 @@ # OAuth metadata. Used for alpha / not-yet-public products where a user can # manually paste the scope into a PAT but where we don't want OAuth-based # clients (the consent screen, MCP, third-party apps) to discover it. -OAUTH_HIDDEN_SCOPE_OBJECTS: frozenset[APIScopeObject] = frozenset({"user_interview_DO_NOT_USE", "replay_lens"}) +OAUTH_HIDDEN_SCOPE_OBJECTS: frozenset[APIScopeObject] = frozenset( + {"agent_application", "user_interview_DO_NOT_USE", "replay_lens"} +) PROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION: list[tuple[APIScopeObject, APIScopeActions]] = [("endpoint", "read")] diff --git a/posthog/settings/object_storage.py b/posthog/settings/object_storage.py index 1e17bada83dd..9c29ac1daf48 100644 --- a/posthog/settings/object_storage.py +++ b/posthog/settings/object_storage.py @@ -46,3 +46,8 @@ # service consumes. Falls back to the general bucket if not set so dev / # self-hosted continue to work without extra configuration. BILLING_USAGE_REPORTS_S3_BUCKET = os.getenv("BILLING_USAGE_REPORTS_S3_BUCKET") or OBJECT_STORAGE_BUCKET + +# Agent platform bundle bucket — stores `ass deploy` bundles. Lifecycle should +# expire non-`ready` bundles after a grace period (handled by infra). Falls +# back to the general bucket in dev / self-hosted. +AGENT_BUNDLES_S3_BUCKET = os.getenv("AGENT_BUNDLES_S3_BUCKET") or OBJECT_STORAGE_BUCKET diff --git a/products/agent_stack/backend/api.py b/products/agent_stack/backend/api.py new file mode 100644 index 000000000000..dfa2232606c2 --- /dev/null +++ b/products/agent_stack/backend/api.py @@ -0,0 +1,283 @@ +"""DRF viewsets for agent_stack.""" + +from __future__ import annotations + +from uuid import UUID + +from django.db.models import QuerySet +from django.utils import timezone + +import django_filters +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +from posthog.api.mixins import ValidatedRequest, validated_request +from posthog.api.routing import TeamAndOrgViewSetMixin + +from . import deploys +from .models import AgentApplication, AgentApplicationRevision, AgentApplicationSession +from .serializers import ( + AgentApplicationRevisionSerializer, + AgentApplicationSerializer, + AgentApplicationSessionSerializer, + CompleteUploadRequestSerializer, + DisableRevisionRequestSerializer, + PreviewRevisionRequestSerializer, + PromoteRevisionRequestSerializer, + StartDeployRequestSerializer, + StartDeployResponseSerializer, + UpdateEnvRequestSerializer, +) + + +@extend_schema(tags=["agent_stack"]) +class AgentApplicationViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): + """Agent applications — the deployable unit of the agent platform.""" + + scope_object = "agent_application" + scope_object_write_actions = [ + "create", + "update", + "partial_update", + "destroy", + "start_deploy", + "complete_upload", + "promote", + "preview", + "disable_revision", + "env", + ] + scope_object_read_actions = ["list", "retrieve"] + serializer_class = AgentApplicationSerializer + queryset = AgentApplication.objects.all() + + def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: + return queryset.filter(deleted=False) + + def safely_get_object(self, queryset: QuerySet) -> AgentApplication | None: + """Look up by UUID if the URL value parses as one, otherwise by slug.""" + lookup_value = self.kwargs[self.lookup_url_kwarg or self.lookup_field] + try: + UUID(str(lookup_value)) + field = "pk" + except (ValueError, TypeError): + field = "slug" + return queryset.filter(**{field: lookup_value}).first() + + def perform_create(self, serializer: AgentApplicationSerializer) -> None: + serializer.save(team_id=self.team_id, created_by=self.request.user) + + def perform_destroy(self, instance: AgentApplication) -> None: + instance.deleted = True + instance.deleted_at = timezone.now() + instance.save(update_fields=["deleted", "deleted_at", "updated_at"]) + + # --- Deploy lifecycle --- + + def _get_revision_for_app( + self, + application: AgentApplication, + revision_id: UUID, + ) -> AgentApplicationRevision: + try: + return AgentApplicationRevision.objects.get( + pk=revision_id, + application=application, + team_id=self.team_id, + ) + except AgentApplicationRevision.DoesNotExist as e: + raise NotFound("Revision not found") from e + + @validated_request( + request_serializer=StartDeployRequestSerializer, + responses={ + 201: OpenApiResponse(response=StartDeployResponseSerializer), + 503: OpenApiResponse(description="Object storage unavailable"), + }, + ) + @action(detail=True, methods=["post"], url_path="start_deploy") + def start_deploy(self, request: ValidatedRequest, **kwargs) -> Response: + """Create a pending revision and return a presigned upload target.""" + application = self.get_object() + data = request.validated_data + try: + revision, presigned = deploys.start_deploy( + application=application, + bundle_sha256=data["bundle_sha256"], + bundle_size=data["bundle_size"], + top_level_config=data["top_level_config"], + created_by_id=getattr(request.user, "id", None), + ) + except deploys.StorageUnavailableError: + return Response( + {"detail": "object storage is unavailable"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return Response( + StartDeployResponseSerializer( + { + "revision_id": revision.id, + "upload_url": presigned["url"], + "upload_fields": presigned["fields"], + "expires_at": presigned["expires_at"], + "max_size": revision.bundle_size or 0, + "required_sha256": revision.bundle_sha256, + } + ).data, + status=status.HTTP_201_CREATED, + ) + + @validated_request( + request_serializer=CompleteUploadRequestSerializer, + responses={ + 200: OpenApiResponse(response=AgentApplicationRevisionSerializer), + 409: OpenApiResponse(description="Revision in wrong state"), + }, + ) + @action(detail=True, methods=["post"], url_path="complete_upload") + def complete_upload(self, request: ValidatedRequest, **kwargs) -> Response: + """v1: transitions the revision straight to state=ready.""" + application = self.get_object() + revision = self._get_revision_for_app(application, request.validated_data["revision_id"]) + try: + revision = deploys.complete_upload(revision=revision) + except deploys.RevisionStateError as e: + return Response({"detail": str(e)}, status=status.HTTP_409_CONFLICT) + return Response(AgentApplicationRevisionSerializer(revision).data) + + @validated_request( + request_serializer=PromoteRevisionRequestSerializer, + responses={ + 200: OpenApiResponse(response=AgentApplicationRevisionSerializer), + 409: OpenApiResponse(description="Revision is not ready"), + }, + ) + @action(detail=True, methods=["post"], url_path="promote") + def promote(self, request: ValidatedRequest, **kwargs) -> Response: + """Promote a ready revision to live. Demotes the previous live revision atomically.""" + application = self.get_object() + revision = self._get_revision_for_app(application, request.validated_data["revision_id"]) + try: + revision = deploys.promote_revision(revision=revision) + except deploys.RevisionStateError as e: + return Response({"detail": str(e)}, status=status.HTTP_409_CONFLICT) + return Response(AgentApplicationRevisionSerializer(revision).data) + + @validated_request( + request_serializer=PreviewRevisionRequestSerializer, + responses={ + 200: OpenApiResponse(response=AgentApplicationRevisionSerializer), + 409: OpenApiResponse(description="Revision is not ready"), + }, + ) + @action(detail=True, methods=["post"], url_path="preview") + def preview(self, request: ValidatedRequest, **kwargs) -> Response: + """Mark a ready revision as preview. Multiple previews can coexist; no siblings demoted.""" + application = self.get_object() + revision = self._get_revision_for_app(application, request.validated_data["revision_id"]) + try: + revision = deploys.preview_revision(revision=revision) + except deploys.RevisionStateError as e: + return Response({"detail": str(e)}, status=status.HTTP_409_CONFLICT) + return Response(AgentApplicationRevisionSerializer(revision).data) + + @validated_request( + request_serializer=DisableRevisionRequestSerializer, + responses={200: OpenApiResponse(response=AgentApplicationRevisionSerializer)}, + ) + @action(detail=True, methods=["post"], url_path="disable_revision") + def disable_revision(self, request: ValidatedRequest, **kwargs) -> Response: + """Set a revision's deployment_status to disabled. Pulls it out of any traffic role.""" + application = self.get_object() + revision = self._get_revision_for_app(application, request.validated_data["revision_id"]) + revision = deploys.disable_revision(revision=revision) + return Response(AgentApplicationRevisionSerializer(revision).data) + + @validated_request( + request_serializer=UpdateEnvRequestSerializer, + responses={200: OpenApiResponse(response=AgentApplicationSerializer)}, + ) + @action(detail=True, methods=["put"], url_path="env") + def env(self, request: ValidatedRequest, **kwargs) -> Response: + """Replace the application's encrypted `.env`. Plaintext is not returned.""" + application = self.get_object() + application = deploys.update_env(application=application, env=request.validated_data["env"]) + return Response(AgentApplicationSerializer(application).data) + + +def _filter_by_parent_application(queryset: QuerySet, lookup_value: str) -> QuerySet: + """Filter a child queryset to rows whose `application` matches the URL kwarg. + + Accepts either an application UUID or a slug — symmetric with the parent + viewset's `safely_get_object` so nested URLs work both ways. + """ + try: + UUID(str(lookup_value)) + return queryset.filter(application_id=lookup_value) + except (ValueError, TypeError): + return queryset.filter(application__slug=lookup_value, application__deleted=False) + + +@extend_schema(tags=["agent_stack"]) +class AgentApplicationRevisionViewSet(TeamAndOrgViewSetMixin, viewsets.ReadOnlyModelViewSet): + """Revisions for an application — read-only, nested under agent_applications.""" + + scope_object = "agent_application" + scope_object_read_actions = ["list", "retrieve"] + serializer_class = AgentApplicationRevisionSerializer + queryset = AgentApplicationRevision.objects.all().order_by("-created_at") + filter_backends = [DjangoFilterBackend] + filterset_fields = ["deployment_status", "state"] + + def _should_skip_parents_filter(self) -> bool: + # We resolve the parent slug-or-UUID ourselves below; the auto-filter + # only knows how to match by id, which breaks for slugs. + return True + + def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: + return _filter_by_parent_application( + queryset.filter(team_id=self.team_id), + self.parents_query_dict["application_id"], + ) + + +class AgentApplicationSessionFilter(django_filters.FilterSet): + created_after = django_filters.IsoDateTimeFilter( + field_name="created_at", + lookup_expr="gte", + help_text="Inclusive lower bound on created_at (ISO-8601). Used by `ass logs --follow` polling.", + ) + created_before = django_filters.IsoDateTimeFilter( + field_name="created_at", + lookup_expr="lt", + help_text="Exclusive upper bound on created_at (ISO-8601).", + ) + + class Meta: + model = AgentApplicationSession + fields = ["revision", "state", "parent_run_id"] + + +@extend_schema(tags=["agent_stack"]) +class AgentApplicationSessionViewSet(TeamAndOrgViewSetMixin, viewsets.ReadOnlyModelViewSet): + """Sessions for an application — read-only, nested under agent_applications.""" + + scope_object = "agent_application" + scope_object_read_actions = ["list", "retrieve"] + serializer_class = AgentApplicationSessionSerializer + queryset = AgentApplicationSession.objects.all().order_by("-created_at") + filter_backends = [DjangoFilterBackend] + filterset_class = AgentApplicationSessionFilter + + def _should_skip_parents_filter(self) -> bool: + return True + + def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: + return _filter_by_parent_application( + queryset.filter(team_id=self.team_id), + self.parents_query_dict["application_id"], + ) diff --git a/products/agent_stack/backend/deploys.py b/products/agent_stack/backend/deploys.py new file mode 100644 index 000000000000..1d253233c81e --- /dev/null +++ b/products/agent_stack/backend/deploys.py @@ -0,0 +1,153 @@ +"""Deploy state machine for agent_stack. + +The four multi-step operations that drive a revision from upload to live: +start_deploy, complete_upload, promote, update_env. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from uuid import UUID + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from posthog.models.utils import uuid7 +from posthog.storage import object_storage + +from .enums import DeploymentStatus, RevisionState +from .models import AgentApplication, AgentApplicationRevision + +# Hard upper bound on bundle size accepted by the presigned upload (50 MiB). +MAX_BUNDLE_SIZE = 50 * 1024 * 1024 +PRESIGNED_URL_TTL_SECONDS = 15 * 60 + + +class RevisionStateError(Exception): + """A revision is in the wrong state for the requested transition.""" + + +class StorageUnavailableError(Exception): + """The object storage backend didn't return a presigned URL.""" + + +def _bundle_key(application_id: UUID, revision_id: UUID) -> str: + return f"agent-bundles/{application_id}/{revision_id}.tar.gz" + + +def start_deploy( + *, + application: AgentApplication, + bundle_sha256: str, + bundle_size: int, + top_level_config: dict, + created_by_id: int | None = None, +) -> tuple[AgentApplicationRevision, dict[str, Any]]: + """Create a pending revision and return the presigned upload target. + + The presigned POST binds the upload to an exact size. The CLI-reported + `bundle_sha256` is stored on the revision row as metadata for the future + validator to re-verify; consistent with every other presigned-POST endpoint + in the codebase, which doesn't enforce hashes at S3 upload time either. + """ + if bundle_size <= 0 or bundle_size > MAX_BUNDLE_SIZE: + raise ValueError(f"bundle_size must be 1..{MAX_BUNDLE_SIZE} bytes") + if len(bundle_sha256) != 64: + raise ValueError("bundle_sha256 must be a 64-char hex sha256") + + # Generate the id upfront so the bundle key can be set on the initial insert + # — saves a follow-up UPDATE that would otherwise be needed to patch in the key. + revision_id = uuid7() + key = _bundle_key(application.id, revision_id) + revision = AgentApplicationRevision.objects.create( + id=revision_id, + team_id=application.team_id, + application=application, + state=RevisionState.PENDING_UPLOAD, + bundle_s3_key=key, + bundle_sha256=bundle_sha256, + bundle_size=bundle_size, + top_level_config=top_level_config, + created_by_id=created_by_id, + ) + + conditions: list[Any] = [ + {"bucket": settings.AGENT_BUNDLES_S3_BUCKET}, + ["content-length-range", bundle_size, bundle_size], + ] + presigned = object_storage.object_storage_client().get_presigned_post( + bucket=settings.AGENT_BUNDLES_S3_BUCKET, + file_key=key, + conditions=conditions, + expiration=PRESIGNED_URL_TTL_SECONDS, + ) + if presigned is None: + raise StorageUnavailableError("could not generate presigned upload URL") + + expires_at = timezone.now() + timedelta(seconds=PRESIGNED_URL_TTL_SECONDS) + return revision, { + "url": presigned["url"], + "fields": presigned["fields"], + "expires_at": expires_at, + } + + +def complete_upload(*, revision: AgentApplicationRevision) -> AgentApplicationRevision: + """v1 shortcut: transition the revision straight to state=ready.""" + with transaction.atomic(): + locked = AgentApplicationRevision.objects.select_for_update().get(pk=revision.pk) + if locked.state not in (RevisionState.PENDING_UPLOAD, RevisionState.UPLOADED): + raise RevisionStateError(f"revision {locked.id} in state {locked.state}, cannot complete upload") + locked.state = RevisionState.READY + locked.save(update_fields=["state", "updated_at"]) + return locked + + +def promote_revision(*, revision: AgentApplicationRevision) -> AgentApplicationRevision: + """Atomically set the revision live, demote any prior live revision to disabled.""" + with transaction.atomic(): + locked = AgentApplicationRevision.objects.select_for_update().get(pk=revision.pk) + if locked.state != RevisionState.READY: + raise RevisionStateError(f"revision {locked.id} is state={locked.state}; only ready can be promoted") + + ( + AgentApplicationRevision.objects.filter( + team_id=locked.team_id, + application_id=locked.application_id, + deployment_status=DeploymentStatus.LIVE, + ) + .exclude(id=locked.id) + .update(deployment_status=DeploymentStatus.DISABLED, updated_at=timezone.now()) + ) + locked.deployment_status = DeploymentStatus.LIVE + locked.save(update_fields=["deployment_status", "updated_at"]) + return locked + + +def preview_revision(*, revision: AgentApplicationRevision) -> AgentApplicationRevision: + """Mark a ready revision as preview. Previews coexist — no siblings demoted.""" + with transaction.atomic(): + locked = AgentApplicationRevision.objects.select_for_update().get(pk=revision.pk) + if locked.state != RevisionState.READY: + raise RevisionStateError(f"revision {locked.id} is state={locked.state}; only ready can be previewed") + locked.deployment_status = DeploymentStatus.PREVIEW + locked.save(update_fields=["deployment_status", "updated_at"]) + return locked + + +def disable_revision(*, revision: AgentApplicationRevision) -> AgentApplicationRevision: + """Force a revision off any traffic role. Allowed from any state — useful for killing broken revisions.""" + with transaction.atomic(): + locked = AgentApplicationRevision.objects.select_for_update().get(pk=revision.pk) + locked.deployment_status = DeploymentStatus.DISABLED + locked.save(update_fields=["deployment_status", "updated_at"]) + return locked + + +def update_env(*, application: AgentApplication, env: str) -> AgentApplication: + """Replace encrypted_env. Plaintext flows in via this function only.""" + application.encrypted_env = env + application.save(update_fields=["encrypted_env", "updated_at"]) + return application diff --git a/products/agent_stack/backend/facade/enums.py b/products/agent_stack/backend/enums.py similarity index 73% rename from products/agent_stack/backend/facade/enums.py rename to products/agent_stack/backend/enums.py index 65d09a867d3e..f5ddf975b1a5 100644 --- a/products/agent_stack/backend/facade/enums.py +++ b/products/agent_stack/backend/enums.py @@ -1,10 +1,4 @@ -""" -Exported enums for agent_stack. - -If an enum appears in a contract dataclass field, it belongs here. -Internal-only constants (DB magic values, feature flags) stay in -the implementation (logic.py, models.py). -""" +"""Enums for agent_stack.""" from enum import StrEnum diff --git a/products/agent_stack/backend/facade/__init__.py b/products/agent_stack/backend/facade/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/products/agent_stack/backend/facade/api.py b/products/agent_stack/backend/facade/api.py deleted file mode 100644 index 16ac2c8009c5..000000000000 --- a/products/agent_stack/backend/facade/api.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Facade for agent_stack. - -The ONLY module other products are allowed to import. -Accept frozen dataclasses, call logic/, return frozen -dataclasses. Never return ORM instances or import DRF. -""" - -from __future__ import annotations diff --git a/products/agent_stack/backend/facade/contracts.py b/products/agent_stack/backend/facade/contracts.py deleted file mode 100644 index 409f3ac683ff..000000000000 --- a/products/agent_stack/backend/facade/contracts.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Contract types for agent_stack. - -Frozen dataclasses that define what this product exposes. -No Django imports. Used by facade as inputs/outputs. -""" diff --git a/products/agent_stack/backend/logic/__init__.py b/products/agent_stack/backend/logic/__init__.py deleted file mode 100644 index 0d790a636416..000000000000 --- a/products/agent_stack/backend/logic/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Business logic for agent_stack.""" - -from __future__ import annotations diff --git a/products/agent_stack/backend/migrations/0001_initial.py b/products/agent_stack/backend/migrations/0001_initial.py index 7e145cd76ee1..8707c4f30ddb 100644 --- a/products/agent_stack/backend/migrations/0001_initial.py +++ b/products/agent_stack/backend/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.13 on 2026-05-14 01:05 +# Generated by Django 5.2.13 on 2026-05-14 03:02 import django.db.models.deletion from django.conf import settings @@ -7,7 +7,7 @@ import posthog.models.utils import posthog.helpers.encrypted_fields -import products.agent_stack.backend.facade.enums +import products.agent_stack.backend.enums class Migration(migrations.Migration): @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ("description", models.TextField(blank=True, default="")), ( "encrypted_env", - posthog.helpers.encrypted_fields.EncryptedTextField(blank=True, default=""), + posthog.helpers.encrypted_fields.EncryptedTextField(blank=True, null=True), ), ("deleted", models.BooleanField(default=False)), ("deleted_at", models.DateTimeField(blank=True, null=True)), @@ -79,7 +79,7 @@ class Migration(migrations.Migration): ("ready", "ready"), ("failed", "failed"), ], - default=products.agent_stack.backend.facade.enums.RevisionState["PENDING_UPLOAD"], + default=products.agent_stack.backend.enums.RevisionState["PENDING_UPLOAD"], max_length=32, ), ), @@ -91,7 +91,7 @@ class Migration(migrations.Migration): ("preview", "preview"), ("disabled", "disabled"), ], - default=products.agent_stack.backend.facade.enums.DeploymentStatus["DISABLED"], + default=products.agent_stack.backend.enums.DeploymentStatus["DISABLED"], max_length=32, ), ), @@ -157,7 +157,7 @@ class Migration(migrations.Migration): ("terminating", "terminating"), ("terminated", "terminated"), ], - default=products.agent_stack.backend.facade.enums.SandboxState["PROVISIONING"], + default=products.agent_stack.backend.enums.SandboxState["PROVISIONING"], max_length=32, ), ), @@ -216,7 +216,7 @@ class Migration(migrations.Migration): ("failed", "failed"), ("canceled", "canceled"), ], - default=products.agent_stack.backend.facade.enums.SessionState["AVAILABLE"], + default=products.agent_stack.backend.enums.SessionState["AVAILABLE"], max_length=32, ), ), diff --git a/products/agent_stack/backend/models.py b/products/agent_stack/backend/models.py index 1fc5e7442f26..311248dc4d7d 100644 --- a/products/agent_stack/backend/models.py +++ b/products/agent_stack/backend/models.py @@ -7,7 +7,7 @@ from posthog.helpers.encrypted_fields import EncryptedTextField from posthog.models.utils import UUIDModel -from .facade.enums import DeploymentStatus, RevisionState, SandboxState, SessionState +from .enums import DeploymentStatus, RevisionState, SandboxState, SessionState class AgentApplication(UUIDModel): @@ -19,7 +19,9 @@ class AgentApplication(UUIDModel): # Raw .env contents uploaded by the developer. Plaintext never returned by the # public API after creation; decryption is gated to the internal API used by # agent-runner, audit-logged per call. - encrypted_env: EncryptedTextField = EncryptedTextField(blank=True, default="") + # null when no env is set — `EncryptedFieldMixin.get_prep_value` writes None for + # falsy values, so a `default=""` would still hit a NOT NULL violation on insert. + encrypted_env: EncryptedTextField = EncryptedTextField(null=True, blank=True) deleted = models.BooleanField(default=False) deleted_at = models.DateTimeField(null=True, blank=True) diff --git a/products/agent_stack/backend/presentation/__init__.py b/products/agent_stack/backend/presentation/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/products/agent_stack/backend/presentation/serializers.py b/products/agent_stack/backend/presentation/serializers.py deleted file mode 100644 index 850432b21477..000000000000 --- a/products/agent_stack/backend/presentation/serializers.py +++ /dev/null @@ -1 +0,0 @@ -"""DRF serializers for agent_stack.""" diff --git a/products/agent_stack/backend/presentation/urls.py b/products/agent_stack/backend/presentation/urls.py deleted file mode 100644 index 99941dd29f92..000000000000 --- a/products/agent_stack/backend/presentation/urls.py +++ /dev/null @@ -1,3 +0,0 @@ -"""URL routes for agent_stack.""" - -urlpatterns: list = [] diff --git a/products/agent_stack/backend/presentation/views.py b/products/agent_stack/backend/presentation/views.py deleted file mode 100644 index edf0e0d35918..000000000000 --- a/products/agent_stack/backend/presentation/views.py +++ /dev/null @@ -1 +0,0 @@ -"""DRF views for agent_stack.""" diff --git a/products/agent_stack/backend/serializers.py b/products/agent_stack/backend/serializers.py new file mode 100644 index 000000000000..f99037600942 --- /dev/null +++ b/products/agent_stack/backend/serializers.py @@ -0,0 +1,216 @@ +"""DRF serializers for agent_stack.""" + +from __future__ import annotations + +from rest_framework import serializers + +from .models import AgentApplication, AgentApplicationRevision, AgentApplicationSession + +REDACTED_VALUE = "********" + + +def redact_env(env_content: str | None) -> str: + """Render a `.env` blob with every value replaced by `REDACTED_VALUE`, one + `KEY=********` line per declared key. Suitable for UI display in a textarea + or monospace block; preserves the original key order. + """ + if not env_content: + return "" + lines: list[str] = [] + for raw_line in env_content.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key = stripped.split("=", 1)[0].strip() + if key: + lines.append(f"{key}={REDACTED_VALUE}") + return "\n".join(lines) + + +# --- Output serializers --- + + +class AgentApplicationSerializer(serializers.ModelSerializer): + slug = serializers.RegexField( + regex=r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", + max_length=63, + help_text=( + "Subdomain prefix for the application. Globally unique across all teams. " + "Lowercase letters, digits, and hyphens only; must start and end with a letter or digit." + ), + ) + has_env = serializers.SerializerMethodField( + help_text="True if an encrypted env is set. Plaintext is never returned.", + ) + env_redacted = serializers.SerializerMethodField( + help_text=( + "The application's `.env` rendered as text with every value replaced by " + "asterisks (`KEY=********`). Suitable for showing in a textarea so the user " + "can confirm which keys are set. Empty string when no env is configured." + ), + ) + + class Meta: + model = AgentApplication + fields = [ + "id", + "team", + "name", + "slug", + "description", + "has_env", + "env_redacted", + "created_by", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "team", + "has_env", + "env_redacted", + "created_by", + "created_at", + "updated_at", + ] + extra_kwargs = { + "name": {"help_text": "Human-readable display name for the application."}, + "description": {"help_text": "Optional free-text description shown in the management UI."}, + } + + def get_has_env(self, obj: AgentApplication) -> bool: + return bool(obj.encrypted_env) + + def get_env_redacted(self, obj: AgentApplication) -> str: + return redact_env(obj.encrypted_env) + + def validate_slug(self, value: str) -> str: + qs = AgentApplication.objects.filter(slug=value, deleted=False) + if self.instance is not None: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError(f"slug '{value}' is already taken") + return value + + +class AgentApplicationRevisionSerializer(serializers.ModelSerializer): + class Meta: + model = AgentApplicationRevision + fields = [ + "id", + "team", + "application", + "state", + "deployment_status", + "bundle_size", + "bundle_sha256", + "top_level_config", + "parsed_manifest", + "validation_report", + "created_by", + "created_at", + "updated_at", + ] + read_only_fields = fields # Revisions are immutable via the public API. + + +class AgentApplicationSessionSerializer(serializers.ModelSerializer): + class Meta: + model = AgentApplicationSession + fields = [ + "id", + "team", + "application", + "revision", + "state", + "queue_job_id", + "parent_run_id", + "trigger_type", + "trigger_payload", + "input", + "output", + "error", + "runtime_instance", + "created_at", + "started_at", + "last_heartbeat_at", + "completed_at", + ] + read_only_fields = fields + + +# --- Input serializers for custom actions --- + + +class StartDeployRequestSerializer(serializers.Serializer): + bundle_sha256 = serializers.RegexField( + regex=r"^[0-9a-f]{64}$", + help_text="SHA-256 of the bundle the CLI is about to upload, lowercase hex (64 chars).", + ) + bundle_size = serializers.IntegerField( + min_value=1, + help_text="Bundle size in bytes. The presigned upload is bound to this exact size.", + ) + top_level_config = serializers.JSONField( + help_text=( + "Parsed contents of `.ass.yaml`. Validated synchronously at deploy start; " + "bundle-level checks are deferred to the async validator when it lands." + ), + ) + + +class StartDeployResponseSerializer(serializers.Serializer): + revision_id = serializers.UUIDField(help_text="The newly-created revision in state=pending_upload.") + upload_url = serializers.CharField(help_text="Presigned S3 POST URL the CLI uploads the bundle to.") + upload_fields = serializers.DictField( + child=serializers.CharField(), + help_text="Form fields the CLI must include in the multipart POST.", + ) + expires_at = serializers.DateTimeField(help_text="When the presigned URL stops being valid.") + max_size = serializers.IntegerField(help_text="Exact size in bytes the upload must be.") + required_sha256 = serializers.CharField(help_text="SHA-256 the uploaded bundle must hash to.") + + +class CompleteUploadRequestSerializer(serializers.Serializer): + revision_id = serializers.UUIDField( + help_text="ID of the revision returned from start_deploy whose bundle has been uploaded.", + ) + + +class PromoteRevisionRequestSerializer(serializers.Serializer): + revision_id = serializers.UUIDField( + help_text=( + "ID of the revision to promote. Must be state=ready. Any prior live revision " + "on this application is atomically demoted to deployment_status=disabled." + ), + ) + + +class PreviewRevisionRequestSerializer(serializers.Serializer): + revision_id = serializers.UUIDField( + help_text=( + "ID of the revision to mark as preview. Must be state=ready. Multiple preview " + "revisions can coexist; no siblings are demoted." + ), + ) + + +class DisableRevisionRequestSerializer(serializers.Serializer): + revision_id = serializers.UUIDField( + help_text=( + "ID of the revision to set deployment_status=disabled. Allowed from any state — " + "use this to take a broken live or preview revision out of traffic." + ), + ) + + +class UpdateEnvRequestSerializer(serializers.Serializer): + env = serializers.CharField( + trim_whitespace=False, + allow_blank=True, + style={"base_template": "textarea.html"}, + help_text=( + "Raw `.env` contents to encrypt and store. Replaces the entire existing env. " + "Plaintext never leaves the server after creation — the agent-runner decrypts in-process." + ), + ) diff --git a/products/agent_stack/backend/tests/test_api.py b/products/agent_stack/backend/tests/test_api.py index e69de29bb2d1..fe7f2cea9ae7 100644 --- a/products/agent_stack/backend/tests/test_api.py +++ b/products/agent_stack/backend/tests/test_api.py @@ -0,0 +1,409 @@ +"""API tests for agent_stack.""" + +from __future__ import annotations + +import hashlib + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from rest_framework import status + +from products.agent_stack.backend.enums import DeploymentStatus, RevisionState, SessionState +from products.agent_stack.backend.models import AgentApplication, AgentApplicationRevision, AgentApplicationSession + +SHA = hashlib.sha256(b"bundle").hexdigest() + + +def _presigned_stub(*args, **kwargs): + return {"url": "https://example-bucket.s3.amazonaws.com/", "fields": {"key": "x", "policy": "y"}} + + +def _create_app(team, slug="myapp", name="My App") -> AgentApplication: + return AgentApplication.objects.create(team=team, slug=slug, name=name) + + +def _create_revision(app, *, state=RevisionState.READY, deployment_status=DeploymentStatus.DISABLED): + return AgentApplicationRevision.objects.create( + team=app.team, + application=app, + state=state, + deployment_status=deployment_status, + bundle_sha256=SHA, + bundle_size=1024, + top_level_config={}, + ) + + +class TestAgentApplicationCRUD(APIBaseTest): + def _url(self, *segments) -> str: + return f"/api/projects/{self.team.id}/agent_applications/" + "".join(segments) + + def test_list_empty(self): + response = self.client.get(self._url()) + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [] + + def test_create_application(self): + response = self.client.post( + self._url(), + data={"name": "My App", "slug": "myapp", "description": "hello"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, response.json() + body = response.json() + assert body["slug"] == "myapp" + assert body["name"] == "My App" + assert body["has_env"] is False + assert AgentApplication.objects.filter(slug="myapp", team=self.team).exists() + + def test_create_rejects_invalid_slug(self): + response = self.client.post( + self._url(), + data={"name": "X", "slug": "-bad-start"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_create_rejects_duplicate_active_slug(self): + _create_app(self.team, slug="taken") + response = self.client.post( + self._url(), + data={"name": "Other", "slug": "taken"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["attr"] == "slug" + + def test_retrieve_by_uuid_and_by_slug(self): + app = _create_app(self.team, slug="dual") + for lookup in (str(app.id), "dual"): + response = self.client.get(self._url(f"{lookup}/")) + assert response.status_code == status.HTTP_200_OK, lookup + assert response.json()["slug"] == "dual" + + def test_retrieve_404(self): + response = self.client.get(self._url("nope/")) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_partial_update(self): + app = _create_app(self.team) + response = self.client.patch( + self._url(f"{app.slug}/"), + data={"description": "updated"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["description"] == "updated" + + def test_destroy_is_soft_delete(self): + app = _create_app(self.team) + response = self.client.delete(self._url(f"{app.id}/")) + assert response.status_code == status.HTTP_204_NO_CONTENT + app.refresh_from_db() + assert app.deleted is True + assert app.deleted_at is not None + # List no longer surfaces it. + assert self.client.get(self._url()).json()["results"] == [] + + def test_team_scoping(self): + from posthog.models import Organization, Team + + other_org = Organization.objects.create(name="Other Org") + other_team = Team.objects.create(organization=other_org, name="Other Team") + _create_app(other_team, slug="other-team-app") + _create_app(self.team, slug="my-team-app") + + response = self.client.get(self._url()) + slugs = {r["slug"] for r in response.json()["results"]} + assert slugs == {"my-team-app"} + + +class TestStartDeploy(APIBaseTest): + def _url(self, app) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.slug}/start_deploy/" + + @patch( + "products.agent_stack.backend.deploys.object_storage.object_storage_client", + ) + def test_happy_path_creates_pending_revision(self, mock_client): + mock_client.return_value.get_presigned_post.side_effect = _presigned_stub + app = _create_app(self.team) + + response = self.client.post( + self._url(app), + data={"bundle_sha256": SHA, "bundle_size": 1024, "top_level_config": {"foo": "bar"}}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.json() + body = response.json() + assert body["required_sha256"] == SHA + assert body["max_size"] == 1024 + assert "upload_url" in body and "upload_fields" in body + + rev = AgentApplicationRevision.objects.get(pk=body["revision_id"]) + assert rev.application_id == app.id + assert rev.state == RevisionState.PENDING_UPLOAD + assert rev.bundle_s3_key.startswith(f"agent-bundles/{app.id}/") + + @patch( + "products.agent_stack.backend.deploys.object_storage.object_storage_client", + ) + def test_validation_errors(self, mock_client): + mock_client.return_value.get_presigned_post.side_effect = _presigned_stub + app = _create_app(self.team) + + # bad sha256 + response = self.client.post( + self._url(app), + data={"bundle_sha256": "short", "bundle_size": 1024, "top_level_config": {}}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # zero size + response = self.client.post( + self._url(app), + data={"bundle_sha256": SHA, "bundle_size": 0, "top_level_config": {}}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch( + "products.agent_stack.backend.deploys.object_storage.object_storage_client", + ) + def test_storage_unavailable(self, mock_client): + mock_client.return_value.get_presigned_post.return_value = None + app = _create_app(self.team) + response = self.client.post( + self._url(app), + data={"bundle_sha256": SHA, "bundle_size": 1024, "top_level_config": {}}, + format="json", + ) + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + +class TestCompleteUpload(APIBaseTest): + def _url(self, app) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.id}/complete_upload/" + + def test_happy_path(self): + app = _create_app(self.team) + rev = _create_revision(app, state=RevisionState.PENDING_UPLOAD) + + response = self.client.post(self._url(app), data={"revision_id": str(rev.id)}, format="json") + assert response.status_code == status.HTTP_200_OK + rev.refresh_from_db() + assert rev.state == RevisionState.READY + + def test_wrong_state_returns_409(self): + app = _create_app(self.team) + rev = _create_revision(app, state=RevisionState.FAILED) + response = self.client.post(self._url(app), data={"revision_id": str(rev.id)}, format="json") + assert response.status_code == status.HTTP_409_CONFLICT + + def test_revision_for_other_app_returns_404(self): + app = _create_app(self.team, slug="a") + other = _create_app(self.team, slug="b") + rev = _create_revision(other, state=RevisionState.PENDING_UPLOAD) + + response = self.client.post(self._url(app), data={"revision_id": str(rev.id)}, format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestPromote(APIBaseTest): + def _url(self, app) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.id}/promote/" + + def test_promote_sets_live_and_demotes_old(self): + app = _create_app(self.team) + old_live = _create_revision(app, deployment_status=DeploymentStatus.LIVE) + new = _create_revision(app) + + response = self.client.post(self._url(app), data={"revision_id": str(new.id)}, format="json") + assert response.status_code == status.HTTP_200_OK + + new.refresh_from_db() + old_live.refresh_from_db() + assert new.deployment_status == DeploymentStatus.LIVE + assert old_live.deployment_status == DeploymentStatus.DISABLED + + def test_promote_requires_ready(self): + app = _create_app(self.team) + rev = _create_revision(app, state=RevisionState.UPLOADED) + response = self.client.post(self._url(app), data={"revision_id": str(rev.id)}, format="json") + assert response.status_code == status.HTTP_409_CONFLICT + + +class TestPreviewAndDisable(APIBaseTest): + def _url(self, app, action) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.id}/{action}/" + + def test_preview_does_not_demote_live(self): + app = _create_app(self.team) + live = _create_revision(app, deployment_status=DeploymentStatus.LIVE) + target = _create_revision(app) + + response = self.client.post(self._url(app, "preview"), data={"revision_id": str(target.id)}, format="json") + assert response.status_code == status.HTTP_200_OK + + target.refresh_from_db() + live.refresh_from_db() + assert target.deployment_status == DeploymentStatus.PREVIEW + assert live.deployment_status == DeploymentStatus.LIVE + + def test_preview_requires_ready(self): + app = _create_app(self.team) + rev = _create_revision(app, state=RevisionState.PENDING_UPLOAD) + response = self.client.post(self._url(app, "preview"), data={"revision_id": str(rev.id)}, format="json") + assert response.status_code == status.HTTP_409_CONFLICT + + def test_disable_works_from_any_state(self): + app = _create_app(self.team) + rev = _create_revision(app, state=RevisionState.FAILED, deployment_status=DeploymentStatus.PREVIEW) + response = self.client.post( + self._url(app, "disable_revision"), + data={"revision_id": str(rev.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + rev.refresh_from_db() + assert rev.deployment_status == DeploymentStatus.DISABLED + + +class TestUpdateEnv(APIBaseTest): + def _url(self, app) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.id}/env/" + + def test_env_is_write_only(self): + app = _create_app(self.team) + plaintext = "SECRET_KEY=hunter2\nOTHER=abc" + + response = self.client.put(self._url(app), data={"env": plaintext}, format="json") + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert "encrypted_env" not in body + assert "env" not in body + assert body["has_env"] is True + # Keys are surfaced as a .env-formatted string, values are redacted. + assert body["env_redacted"] == "SECRET_KEY=********\nOTHER=********" + + # Plaintext value does not appear in any read endpoint. + get_response = self.client.get(f"/api/projects/{self.team.id}/agent_applications/{app.id}/") + assert "hunter2" not in get_response.content.decode() + assert "abc" not in get_response.content.decode() + + # But it IS stored (decrypts back). + app.refresh_from_db() + assert app.encrypted_env == plaintext + + def test_env_redacted_skips_comments_and_blank_lines(self): + app = _create_app(self.team) + app.encrypted_env = "# a comment\n\nA=1\n # indented comment\nB=2\nNO_EQUALS_LINE\n" + app.save(update_fields=["encrypted_env"]) + + response = self.client.get(f"/api/projects/{self.team.id}/agent_applications/{app.id}/") + assert response.json()["env_redacted"] == "A=********\nB=********" + + def test_env_can_be_cleared_with_empty_string(self): + app = _create_app(self.team) + app.encrypted_env = "OLD=value" + app.save(update_fields=["encrypted_env"]) + + response = self.client.put(self._url(app), data={"env": ""}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.json()["has_env"] is False + app.refresh_from_db() + # Cleared value reads back as None (encrypted mixin writes falsy → null). + assert not app.encrypted_env + + +class TestNestedRevisions(APIBaseTest): + def _url(self, app, *segments) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.slug}/revisions/" + "".join(segments) + + def test_list_scoped_to_parent_app(self): + app = _create_app(self.team, slug="a") + other = _create_app(self.team, slug="b") + _create_revision(app) + _create_revision(app) + _create_revision(other) + + response = self.client.get(self._url(app)) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 2 + assert {r["application"] for r in results} == {str(app.id)} + + def test_list_accepts_uuid_or_slug_for_parent(self): + app = _create_app(self.team) + _create_revision(app) + + for parent in (str(app.id), app.slug): + url = f"/api/projects/{self.team.id}/agent_applications/{parent}/revisions/" + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK, parent + assert len(response.json()["results"]) == 1 + + def test_filter_by_deployment_status(self): + app = _create_app(self.team) + _create_revision(app, deployment_status=DeploymentStatus.LIVE) + _create_revision(app, deployment_status=DeploymentStatus.DISABLED) + + response = self.client.get(self._url(app) + "?deployment_status=live") + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["deployment_status"] == DeploymentStatus.LIVE + + +class TestNestedSessions(APIBaseTest): + def _url(self, app) -> str: + return f"/api/projects/{self.team.id}/agent_applications/{app.id}/sessions/" + + def _create_session(self, app, revision, **kwargs) -> AgentApplicationSession: + return AgentApplicationSession.objects.create( + team=app.team, + application=app, + revision=revision, + state=kwargs.get("state", SessionState.RUNNING), + input=kwargs.get("input", {}), + ) + + def test_list_scoped_to_parent_app(self): + app = _create_app(self.team, slug="a") + other = _create_app(self.team, slug="b") + rev_a = _create_revision(app) + rev_b = _create_revision(other) + self._create_session(app, rev_a) + self._create_session(other, rev_b) + + response = self.client.get(self._url(app)) + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["application"] == str(app.id) + + def test_filter_by_state(self): + app = _create_app(self.team) + rev = _create_revision(app) + self._create_session(app, rev, state=SessionState.RUNNING) + self._create_session(app, rev, state=SessionState.COMPLETED) + + response = self.client.get(self._url(app) + "?state=running") + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["state"] == SessionState.RUNNING + + def test_filter_by_created_after(self): + app = _create_app(self.team) + rev = _create_revision(app) + older = self._create_session(app, rev) + newer = self._create_session(app, rev) + + cutoff = older.created_at.isoformat().replace("+00:00", "Z") + response = self.client.get(self._url(app) + f"?created_after={cutoff}") + # gte cutoff includes the older row too — verify cutoff > older returns only newer. + bumped_cutoff = newer.created_at.isoformat().replace("+00:00", "Z") + response = self.client.get(self._url(app) + f"?created_after={bumped_cutoff}") + ids = {r["id"] for r in response.json()["results"]} + assert ids == {str(newer.id)} diff --git a/products/agent_stack/backend/tests/test_logic.py b/products/agent_stack/backend/tests/test_logic.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/products/agent_stack/backend/tests/test_models.py b/products/agent_stack/backend/tests/test_models.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/products/agent_stack/backend/tests/test_presentation.py b/products/agent_stack/backend/tests/test_presentation.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/products/agent_stack/backend/tests/test_tasks.py b/products/agent_stack/backend/tests/test_tasks.py deleted file mode 100644 index e69de29bb2d1..000000000000 From d59db0d5f3acc6f9c51a3d91131dbce878c159d3 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:00:32 -0400 Subject: [PATCH 010/517] feat(agents): wire agent runtime into hogli start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the agent platform services to the local dev stack so `hogli start` with the `agents` intent brings up agent-ingress, agent-runner, and agent-janitor alongside an `agent_runtime_queue` Postgres DB with its schema migrated. - docker/postgres-init-scripts/create-agent-runtime-queue-db.sh — same pattern as cyclotron; creates the DB on first Postgres init. - bin/migrate --scope=agent_runtime — delegates to services/agent-core/bin/migrate-agent-runtime which runs the TS migration runner against AGENT_RUNTIME_QUEUE_DATABASE_URL. - bin/mprocs.yaml — new entries for migrate-agent-runtime, agent-ingress, agent-runner, agent-janitor (each builds @posthog/agent-core first so tsx can resolve the workspace import via dist/). - devenv/intent-map.yaml — new `agent_runtime` capability + `agents` intent so `hogli dev:setup` exposes it. - bin/seed-agent-session — psql-inserts a sample available session into agent_sessions so the runner can pick it up. Default queue=default, team=1, count=1; flags --queue/--count/--team. - services/agent-runner/src/executor-stub.ts — rename NotImplementedExecutor → EchoExecutor that completes turns with an echo of the initial input, so seeded sessions actually finish. Keeps the deprecated NotImplementedExecutor alias for callers. Local run: hogli dev:setup --intents=agents # one-time hogli start # brings up the stack bin/seed-agent-session # see it process Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/migrate | 8 +++ bin/mprocs.yaml | 51 ++++++++++++++ bin/seed-agent-session | 66 +++++++++++++++++++ devenv/intent-map.yaml | 9 +++ .../create-agent-runtime-queue-db.sh | 16 +++++ services/agent-core/bin/migrate-agent-runtime | 19 ++++++ services/agent-runner/src/executor-stub.ts | 24 +++++-- 7 files changed, 186 insertions(+), 7 deletions(-) create mode 100755 bin/seed-agent-session create mode 100755 docker/postgres-init-scripts/create-agent-runtime-queue-db.sh create mode 100755 services/agent-core/bin/migrate-agent-runtime diff --git a/bin/migrate b/bin/migrate index 6871970e8cca..1a83e3e0b0c0 100755 --- a/bin/migrate +++ b/bin/migrate @@ -58,6 +58,14 @@ if [ -d "$SCRIPT_DIR/../rust/bin" ] && [ "${DEPLOYMENT:-}" != "hobby" ]; then fi fi +if run_scope "agent_runtime"; then + bash $SCRIPT_DIR/../services/agent-core/bin/migrate-agent-runtime + if [ $? -ne 0 ]; then + echo "Error in services/agent-core/bin/migrate-agent-runtime, exiting." + exit 1 + fi +fi + if run_scope "clickhouse"; then RUN_CLICKHOUSE=1; else RUN_CLICKHOUSE=0; fi if run_scope "postgres"; then RUN_POSTGRES=1; else RUN_POSTGRES=0; fi diff --git a/bin/mprocs.yaml b/bin/mprocs.yaml index d0684ea1da17..e666a5c4cc54 100755 --- a/bin/mprocs.yaml +++ b/bin/mprocs.yaml @@ -215,6 +215,49 @@ procs: layer: Processing tech: Rust + agent-ingress: + shell: |- + bin/wait-for-docker && \ + pnpm --filter @posthog/agent-core build && \ + AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ + INTERNAL_API_BASE_URL=${INTERNAL_API_BASE_URL:-http://localhost:8000} \ + REDIS_URL=${REDIS_URL:-redis://localhost:6379/0} \ + PORT=${AGENT_INGRESS_PORT:-3030} \ + pnpm --filter @posthog/agent-ingress start:dev + capability: agent_runtime + ready_pattern: 'agent-ingress listening' + groups: + layer: Product services + tech: Node + + agent-runner: + shell: |- + bin/wait-for-docker && \ + pnpm --filter @posthog/agent-core build && \ + AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ + INTERNAL_API_BASE_URL=${INTERNAL_API_BASE_URL:-http://localhost:8000} \ + REDIS_URL=${REDIS_URL:-redis://localhost:6379/0} \ + pnpm --filter @posthog/agent-runner start:dev + capability: agent_runtime + ready_pattern: 'agent-runner started' + groups: + layer: Processing + tech: Node + + agent-janitor: + shell: |- + bin/wait-for-docker && \ + pnpm --filter @posthog/agent-core build && \ + AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ + AGENT_INTERNAL_API_SHARED_KEY=${AGENT_INTERNAL_API_SHARED_KEY:-dev-shared-key} \ + PORT=${AGENT_JANITOR_PORT:-3031} \ + pnpm --filter @posthog/agent-janitor start:dev + capability: agent_runtime + ready_pattern: 'agent-janitor listening' + groups: + layer: Processing + tech: Node + cymbal: shell: |- bin/wait-for-docker && \ @@ -444,6 +487,14 @@ procs: layer: Infrastructure tech: Migrations + migrate-agent-runtime: + shell: 'bin/wait-for-docker && bin/migrate --scope=agent_runtime' + capability: agent_runtime + ready_pattern: 'All migrations completed successfully' + groups: + layer: Infrastructure + tech: Migrations + migrate-persons-db: shell: 'bin/wait-for-docker && bin/migrate --scope=persons' ready_pattern: 'All migrations completed successfully' diff --git a/bin/seed-agent-session b/bin/seed-agent-session new file mode 100755 index 000000000000..8bb2e24e96d4 --- /dev/null +++ b/bin/seed-agent-session @@ -0,0 +1,66 @@ +#!/bin/bash +# Insert a sample available session into agent_runtime_queue.agent_sessions +# so the locally-running agent-runner picks it up and the echo executor +# completes it. Quick way to see the queue + runner moving without needing +# the Django side wired up. +# +# Usage: +# bin/seed-agent-session # one default session +# bin/seed-agent-session --count=5 # multiple +# bin/seed-agent-session --queue=other # custom queue name +# +# Requires: psql, an up agent_runtime_queue DB (run `bin/migrate --scope=agent_runtime` +# first if you haven't). + +set -euo pipefail + +DB_URL="${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue}" +QUEUE_NAME="default" +COUNT=1 +TEAM_ID=1 + +for arg in "$@"; do + case "$arg" in + --queue=*) QUEUE_NAME="${arg#*=}" ;; + --count=*) COUNT="${arg#*=}" ;; + --team=*) TEAM_ID="${arg#*=}" ;; + -h|--help) + sed -n '2,11p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $arg" >&2 + exit 1 + ;; + esac +done + +# uuidgen is on macOS by default; on Linux some distros need util-linux. +if ! command -v uuidgen >/dev/null 2>&1; then + echo "uuidgen not found — install util-linux or similar" >&2 + exit 1 +fi + +for i in $(seq 1 "$COUNT"); do + SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') + PAYLOAD=$(printf '{"echo":"hello from seed","i":%d}' "$i") + psql "$DB_URL" -v ON_ERROR_STOP=1 < { +export class EchoExecutor implements SessionExecutor { + runTurn(input: { state: { initialInput: unknown } }): Promise { return Promise.resolve({ - kind: 'failed', - error: 'Claude Agent SDK executor is not implemented yet. Wire one up in src/index.ts.', + kind: 'completed', + message: { + role: 'assistant', + content: 'echo executor — replace with the Claude Agent SDK executor', + at: new Date().toISOString(), + }, + output: { echo: input.state.initialInput ?? null }, }) } } + +/** @deprecated Renamed to `EchoExecutor`. Kept as an alias during the SDK-executor rollout. */ +export const NotImplementedExecutor = EchoExecutor From 2bf0d327c8d4b3af119c9c5f6aa551c8a0038163 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:07:02 -0400 Subject: [PATCH 011/517] chore(agents): describe the seed:agent:session hogli entry The previous commit auto-registered the new bin script under hogli.yaml with a placeholder description; fill it in. Co-Authored-By: Claude Opus 4.7 (1M context) --- hogli.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hogli.yaml b/hogli.yaml index c893039b7393..4b3a9a822553 100644 --- a/hogli.yaml +++ b/hogli.yaml @@ -991,3 +991,6 @@ environment: bin_script: rust-jumphost description: 'Shell into the flags-cache-jumphost pod (uses $KUBE_NAMESPACE, defaults to posthog)' hidden: true + seed:agent:session: + bin_script: seed-agent-session + description: 'Insert a sample available session into agent_runtime_queue.agent_sessions so the local agent-runner picks it up' From 116c0446dc03e05150a67c118cfc10d0440d886d Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:15:19 -0400 Subject: [PATCH 012/517] feat(agents): runnable end-to-end demo via POST /run + SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the minimum needed to exercise the api → queue → worker → SSE path from a developer's machine, without depending on Django's `resolve` endpoint being implemented yet. services/agent-ingress - RevisionResolver gains an optional `localRevisions` map; lookups check it before falling through to the Internal API. - index.ts loads `AGENT_DEV_REVISIONS_PATH` (JSON ResolvedRevision[]) on startup if set, indexed by both `app:` and `domain:`. - dev/revisions.json — one canned `demo` application (id 0000…0001, revision 0000…0002, auth=public, state=ready) for the local stack. bin/mprocs.yaml - agent-ingress entry exports AGENT_DEV_REVISIONS_PATH pointing at services/agent-ingress/dev/revisions.json by default. bin/run-agent - POSTs to localhost:3030/run with the demo application id, then tails /listen/:sessionId for SSE events. Flags --app, --input, --no-listen, --url. Pretty-prints with jq when available. Local demo: hogli start # bring up the agents intent bin/run-agent # see ingress accept the request, runner # complete it via EchoExecutor, SSE events # arriving on /listen Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mprocs.yaml | 1 + bin/run-agent | 72 +++++++++++++++++++++ services/agent-ingress/dev/revisions.json | 14 ++++ services/agent-ingress/src/index.ts | 34 +++++++++- services/agent-ingress/src/resolver.test.ts | 16 +---- services/agent-ingress/src/resolver.ts | 15 +++++ 6 files changed, 138 insertions(+), 14 deletions(-) create mode 100755 bin/run-agent create mode 100644 services/agent-ingress/dev/revisions.json diff --git a/bin/mprocs.yaml b/bin/mprocs.yaml index e666a5c4cc54..3dacfcebcfd1 100755 --- a/bin/mprocs.yaml +++ b/bin/mprocs.yaml @@ -222,6 +222,7 @@ procs: AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ INTERNAL_API_BASE_URL=${INTERNAL_API_BASE_URL:-http://localhost:8000} \ REDIS_URL=${REDIS_URL:-redis://localhost:6379/0} \ + AGENT_DEV_REVISIONS_PATH=${AGENT_DEV_REVISIONS_PATH:-$PWD/services/agent-ingress/dev/revisions.json} \ PORT=${AGENT_INGRESS_PORT:-3030} \ pnpm --filter @posthog/agent-ingress start:dev capability: agent_runtime diff --git a/bin/run-agent b/bin/run-agent new file mode 100755 index 000000000000..5aea0dcd4764 --- /dev/null +++ b/bin/run-agent @@ -0,0 +1,72 @@ +#!/bin/bash +# POST to local agent-ingress /run and tail SSE events from /listen/:id. +# Exercises the full ingress → queue → runner → bus → SSE path against the +# canned dev revision in services/agent-ingress/dev/revisions.json. +# +# Usage: +# bin/run-agent # default app (slug=demo) +# bin/run-agent --app=00000000-0000-... # explicit applicationId +# bin/run-agent --input='{"foo":"bar"}' # input payload +# bin/run-agent --no-listen # skip SSE tail +# +# Requires the agent stack to be running (`hogli start` with the `agents` +# intent selected, or run agent-ingress + agent-runner directly). + +set -euo pipefail + +INGRESS_URL="${AGENT_INGRESS_URL:-http://localhost:3030}" +APPLICATION_ID="00000000-0000-4000-8000-000000000001" +INPUT='{"hello":"world"}' +LISTEN=1 + +for arg in "$@"; do + case "$arg" in + --app=*) APPLICATION_ID="${arg#*=}" ;; + --input=*) INPUT="${arg#*=}" ;; + --no-listen) LISTEN=0 ;; + --url=*) INGRESS_URL="${arg#*=}" ;; + -h|--help) + sed -n '2,13p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $arg" >&2 + exit 1 + ;; + esac +done + +# Pretty-print JSON if jq is around, otherwise raw. +if command -v jq >/dev/null 2>&1; then PRETTY="jq ."; else PRETTY="cat"; fi + +echo "POST $INGRESS_URL/run (applicationId=$APPLICATION_ID)" +RUN_RESPONSE=$(curl -sS -w '\n%{http_code}' \ + -X POST "$INGRESS_URL/run" \ + -H 'content-type: application/json' \ + -d "$(printf '{"applicationId":"%s","input":%s}' "$APPLICATION_ID" "$INPUT")") + +# curl -w writes the status code on the last line, separated from the body. +HTTP_CODE=$(echo "$RUN_RESPONSE" | tail -n1) +BODY=$(echo "$RUN_RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" != "202" ]; then + echo "ERROR: /run returned HTTP $HTTP_CODE" >&2 + echo "$BODY" | $PRETTY >&2 + exit 1 +fi + +echo "$BODY" | $PRETTY + +SESSION_ID=$(echo "$BODY" | grep -o '"sessionId":"[^"]*"' | cut -d'"' -f4) +if [ -z "$SESSION_ID" ]; then + echo "ERROR: could not parse sessionId from response" >&2 + exit 1 +fi + +if [ "$LISTEN" = "0" ]; then + exit 0 +fi + +echo +echo "GET $INGRESS_URL/listen/$SESSION_ID (Ctrl-C to stop)" +exec curl -sS -N -H 'accept: text/event-stream' "$INGRESS_URL/listen/$SESSION_ID" diff --git a/services/agent-ingress/dev/revisions.json b/services/agent-ingress/dev/revisions.json new file mode 100644 index 000000000000..1559f7403a7a --- /dev/null +++ b/services/agent-ingress/dev/revisions.json @@ -0,0 +1,14 @@ +[ + { + "applicationId": "00000000-0000-4000-8000-000000000001", + "applicationSlug": "demo", + "teamId": 1, + "revisionId": "00000000-0000-4000-8000-000000000002", + "revisionState": "ready", + "bundleS3Key": "dev/local/demo.tar.gz", + "bundleSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "topLevelConfig": {}, + "parsedManifest": null, + "auth": { "mode": "public" } + } +] diff --git a/services/agent-ingress/src/index.ts b/services/agent-ingress/src/index.ts index b3abcdb1d154..22784250f9eb 100644 --- a/services/agent-ingress/src/index.ts +++ b/services/agent-ingress/src/index.ts @@ -1,7 +1,11 @@ +import { readFileSync } from 'node:fs' + import { InMemorySessionBus, InternalApiClient, RedisSessionBus, + ResolvedRevision, + ResolvedRevisionSchema, SessionBus, SessionQueueManager, logger, @@ -22,7 +26,12 @@ async function main(): Promise { sharedKey: config.internalApiSharedKey, }) - const resolver = new RevisionResolver({ client: apiClient, ttlMs: config.resolverTtlMs }) + const localRevisions = loadLocalRevisions(process.env.AGENT_DEV_REVISIONS_PATH, config.domainSuffix) + const resolver = new RevisionResolver({ + client: apiClient, + ttlMs: config.resolverTtlMs, + localRevisions, + }) const bus: SessionBus = config.redisUrl ? new RedisSessionBus({ url: config.redisUrl }) : new InMemorySessionBus() @@ -48,6 +57,29 @@ async function main(): Promise { process.on('SIGINT', () => void shutdown('SIGINT')) } +/** + * Dev-only fixture loader. Reads a JSON file shaped as `ResolvedRevision[]` and + * returns a Map keyed by `app:` and `domain:`. + * Lets the local stack run without a wired Django `resolve` endpoint. + */ +function loadLocalRevisions(path: string | undefined, domainSuffix: string): Map | undefined { + if (!path) { + return undefined + } + const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown + if (!Array.isArray(raw)) { + throw new Error(`AGENT_DEV_REVISIONS_PATH=${path} must contain a JSON array of ResolvedRevision`) + } + const map = new Map() + for (const entry of raw) { + const revision = ResolvedRevisionSchema.parse(entry) + map.set(`app:${revision.applicationId}`, revision) + map.set(`domain:${revision.applicationSlug}${domainSuffix}`, revision) + } + logger.warn('agent-ingress using AGENT_DEV_REVISIONS_PATH fixture — dev only', { path, count: map.size / 2 }) + return map +} + main().catch((err) => { logger.error('agent-ingress fatal', { error: String(err) }) process.exit(1) diff --git a/services/agent-ingress/src/resolver.test.ts b/services/agent-ingress/src/resolver.test.ts index 9acb4f5c6d3b..332312f6f6bc 100644 --- a/services/agent-ingress/src/resolver.test.ts +++ b/services/agent-ingress/src/resolver.test.ts @@ -90,10 +90,7 @@ describe('RevisionResolver', () => { await new Promise((resolve) => setTimeout(resolve, 10)) await resolver.resolveDomain('analytics-bot.agents.posthog.com') - expect(calls.domains).toEqual([ - 'analytics-bot.agents.posthog.com', - 'analytics-bot.agents.posthog.com', - ]) + expect(calls.domains).toEqual(['analytics-bot.agents.posthog.com', 'analytics-bot.agents.posthog.com']) }) it('invalidate() evicts the cached entry for a domain', async () => { @@ -104,10 +101,7 @@ describe('RevisionResolver', () => { resolver.invalidate({ domain: 'analytics-bot.agents.posthog.com' }) await resolver.resolveDomain('analytics-bot.agents.posthog.com') - expect(calls.domains).toEqual([ - 'analytics-bot.agents.posthog.com', - 'analytics-bot.agents.posthog.com', - ]) + expect(calls.domains).toEqual(['analytics-bot.agents.posthog.com', 'analytics-bot.agents.posthog.com']) }) it('invalidate() evicts only the requested key', async () => { @@ -122,11 +116,7 @@ describe('RevisionResolver', () => { await resolver.resolveDomain('b.agents.posthog.com') // a was invalidated and re-fetched; b stayed cached. - expect(calls.domains).toEqual([ - 'a.agents.posthog.com', - 'b.agents.posthog.com', - 'a.agents.posthog.com', - ]) + expect(calls.domains).toEqual(['a.agents.posthog.com', 'b.agents.posthog.com', 'a.agents.posthog.com']) }) it('propagates errors from the client and does not cache the failure', async () => { diff --git a/services/agent-ingress/src/resolver.ts b/services/agent-ingress/src/resolver.ts index 09c1b1f9477c..51edbdf4c8be 100644 --- a/services/agent-ingress/src/resolver.ts +++ b/services/agent-ingress/src/resolver.ts @@ -6,6 +6,13 @@ export interface ResolverOptions { client: InternalApiClient ttlMs: number maxEntries?: number + /** + * Dev-only escape hatch. When provided, lookups try this in-memory map first + * (keyed by `applicationId` AND by `${applicationSlug}${domainSuffix}`) before + * falling through to the Internal API. Lets the local stack run without a wired + * Django side. Loaded once from `AGENT_DEV_REVISIONS_PATH` in `index.ts`. + */ + localRevisions?: Map } /** @@ -26,10 +33,18 @@ export class RevisionResolver { } async resolveDomain(domain: string): Promise { + const local = this.options.localRevisions?.get(`domain:${domain}`) + if (local) { + return local + } return this.lookup(`domain:${domain}`, () => this.options.client.resolve({ domain })) } async resolveApplication(applicationId: string): Promise { + const local = this.options.localRevisions?.get(`app:${applicationId}`) + if (local) { + return local + } return this.lookup(`app:${applicationId}`, () => this.options.client.resolve({ applicationId })) } From 7325bd4d29a7840a376e8f2b230f648bfc0ec2c5 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:17:04 -0400 Subject: [PATCH 013/517] fix(hogli): restore hogli.yaml after auto-registration clobber + add run:agent The previous commit's pre-commit hook auto-registered run-agent but replaced the entire hogli.yaml with just the new entry. Restore the file from HEAD~1 and re-add both seed:agent:session and run:agent with proper descriptions. Co-Authored-By: Claude Opus 4.7 (1M context) --- hogli.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hogli.yaml b/hogli.yaml index 4b3a9a822553..fa1d5e1250e5 100644 --- a/hogli.yaml +++ b/hogli.yaml @@ -994,3 +994,6 @@ environment: seed:agent:session: bin_script: seed-agent-session description: 'Insert a sample available session into agent_runtime_queue.agent_sessions so the local agent-runner picks it up' + run:agent: + bin_script: run-agent + description: 'POST a demo /run to local agent-ingress and tail /listen SSE events — exercises the full ingress → queue → runner path' From 97cc200f93864cedc5629297262c553ccfa22b87 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:39:57 -0400 Subject: [PATCH 014/517] refactor(agents): move agent-runtime migrations to sqlx + shared rust image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouples queue schema changes from agent-core code releases — same model as cyclotron. The TS migration runner is gone; the schema is now applied via the shared rust sqlx-migrate image. rust/ - New rust/agent_runtime_queue_migrations/20260514120000_initial_schema.sql (the old SQL, minus the bespoke agent_runtime_migrations tracking table — sqlx tracks state in _sqlx_migrations). - New rust/bin/migrate-agent-runtime-queue mirroring rust/bin/migrate-cyclotron. - rust/bin/migrate-entry gains the agent-runtime-queue scope (and is included in `all`). - rust/Dockerfile.sqlx-migrate bakes in the new migrations dir + script. bin/migrate - --scope=agent_runtime now delegates to rust/bin/migrate-agent-runtime-queue (inside the rust block, alongside cyclotron etc.) instead of the TS runner. services/agent-core - Delete bin/migrate.ts, bin/migrate-agent-runtime, migrations/. - package.json drops the `migrate` script and the `tsx` devDep. - queue.test.ts loads migrations from rust/agent_runtime_queue_migrations/ in lexicographic order so the DB-gated suite doesn't need sqlx-cli. - README points at the new migrations location. Additional small fixes pulled into this commit: - services/agent-runner/src/index.ts loadSecrets stops calling Django's decrypt endpoint — the EchoExecutor never reaches tool dispatch, so the failed call was making every demo session fail. - services/agent-ingress/src/routes/run.ts encodes /run input as a full SessionState envelope (messages/pendingInputs/initialInput/turnCount) so the runner's deserializer accepts it. Previously the raw input fell back to the empty state and output.echo was always null. - jest testTimeout drops from 15s to 5s across all four services. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/migrate | 12 ++-- docs/internal/agent-platform.md | 5 +- rust/Dockerfile.sqlx-migrate | 5 +- .../20260514120000_initial_schema.sql | 8 +-- rust/bin/migrate-agent-runtime-queue | 12 ++++ rust/bin/migrate-entry | 40 +++++++++--- services/agent-core/README.md | 8 ++- services/agent-core/bin/migrate-agent-runtime | 19 ------ services/agent-core/bin/migrate.ts | 63 ------------------- services/agent-core/jest.config.js | 2 +- services/agent-core/package.json | 4 +- services/agent-core/src/queue/queue.test.ts | 17 +++-- services/agent-ingress/jest.config.js | 2 +- services/agent-ingress/src/routes/run.ts | 8 ++- services/agent-janitor/jest.config.js | 2 +- services/agent-runner/jest.config.js | 2 +- services/agent-runner/src/index.ts | 22 +++---- 17 files changed, 95 insertions(+), 136 deletions(-) rename services/agent-core/migrations/0001_initial_schema.sql => rust/agent_runtime_queue_migrations/20260514120000_initial_schema.sql (84%) create mode 100755 rust/bin/migrate-agent-runtime-queue delete mode 100755 services/agent-core/bin/migrate-agent-runtime delete mode 100644 services/agent-core/bin/migrate.ts diff --git a/bin/migrate b/bin/migrate index 1a83e3e0b0c0..571fbef5792d 100755 --- a/bin/migrate +++ b/bin/migrate @@ -56,13 +56,13 @@ if [ -d "$SCRIPT_DIR/../rust/bin" ] && [ "${DEPLOYMENT:-}" != "hobby" ]; then exit 1 fi fi -fi -if run_scope "agent_runtime"; then - bash $SCRIPT_DIR/../services/agent-core/bin/migrate-agent-runtime - if [ $? -ne 0 ]; then - echo "Error in services/agent-core/bin/migrate-agent-runtime, exiting." - exit 1 + if run_scope "agent_runtime"; then + bash $SCRIPT_DIR/../rust/bin/migrate-agent-runtime-queue + if [ $? -ne 0 ]; then + echo "Error in rust/bin/migrate-agent-runtime-queue, exiting." + exit 1 + fi fi fi diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index c9611b391a31..0a3134fc9472 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -8,7 +8,6 @@ Two things we own here: 1. **Management plane** — a new flag-gated product under `products/agent_stack/` (Django app + viewsets + frontend). 2. **Runtime** — four TypeScript services under `services/`, deployed as independent node processes. They share **no code** with `nodejs/` (the legacy plugin-server). Anything we want from `nodejs/` we copy and adapt in `services/agent-core/`. - - `services/agent-core/` — shared library, no process. - `services/agent-ingress/` — public-facing `*.agents.posthog.com` terminator. - `services/agent-runner/` — session executor (queue consumer + SDK). @@ -26,11 +25,11 @@ Working tracker for the runtime services only — the Django side is being built ### services/agent-core/ (milestone 5 — substantially done) -- [x] Queue schema + migration ([migrations/0001_initial_schema.sql](../../services/agent-core/migrations/0001_initial_schema.sql)): state enum, `lock_id`, `last_heartbeat`, `BYTEA` state, `transition_count`, `janitor_touch_count`, indexes for dequeue/stall/cleanup +- [x] Queue schema + migration ([rust/agent_runtime_queue_migrations/](../../rust/agent_runtime_queue_migrations/), sqlx-managed via the shared rust migrations image): state enum, `lock_id`, `last_heartbeat`, `BYTEA` state, `transition_count`, `janitor_touch_count`, indexes for dequeue/stall/cleanup - [x] Manager / enqueue with depth limit + 1 MiB state cap ([src/queue/manager.ts](../../services/agent-core/src/queue/manager.ts)) - [x] Worker / dequeue + `FOR UPDATE SKIP LOCKED` + heartbeat + ack/fail/reschedule/cancel ([src/queue/worker.ts](../../services/agent-core/src/queue/worker.ts)) - [x] Janitor / stall recovery + poison-pill + terminal cleanup + Prom metrics ([src/queue/janitor.ts](../../services/agent-core/src/queue/janitor.ts)) -- [x] Migrations runner ([bin/migrate.ts](../../services/agent-core/bin/migrate.ts)) +- [x] Migrations runner ([rust/bin/migrate-agent-runtime-queue](../../rust/bin/migrate-agent-runtime-queue), wired into the shared rust sqlx-migrate image) - [x] Pub-sub interface + Redis adapter + in-memory adapter ([src/pubsub/](../../services/agent-core/src/pubsub)) - [x] Internal-API client (`resolve`, `decrypt`) with optional shared-key header ([src/internal-api/client.ts](../../services/agent-core/src/internal-api/client.ts)) - [x] Built-ins registry — `posthog.events.capture`, `posthog.feature_flags.evaluate`, `http.fetch` ([src/builtins/index.ts](../../services/agent-core/src/builtins/index.ts)) diff --git a/rust/Dockerfile.sqlx-migrate b/rust/Dockerfile.sqlx-migrate index a8fd9c4edfa3..56a9e46f3ace 100644 --- a/rust/Dockerfile.sqlx-migrate +++ b/rust/Dockerfile.sqlx-migrate @@ -22,17 +22,20 @@ COPY ./persons_migrations ./persons_migrations COPY ./cyclotron-core/migrations ./cyclotron-core/migrations COPY ./behavioral_cohorts_migrations ./behavioral_cohorts_migrations COPY ./cyclotron-node-migrations ./cyclotron-node-migrations +COPY ./agent_runtime_queue_migrations ./agent_runtime_queue_migrations COPY ./bin/migrate-persons ./bin/migrate-persons COPY ./bin/migrate-cyclotron ./bin/migrate-cyclotron COPY ./bin/migrate-cyclotron-node ./bin/migrate-cyclotron-node COPY ./bin/migrate-behavioral-cohorts ./bin/migrate-behavioral-cohorts +COPY ./bin/migrate-agent-runtime-queue ./bin/migrate-agent-runtime-queue COPY ./bin/migrate-entry ./bin/migrate-entry RUN chmod +x /migrations/bin/migrate-entry && \ chmod +x /migrations/bin/migrate-persons && \ chmod +x /migrations/bin/migrate-cyclotron && \ chmod +x /migrations/bin/migrate-cyclotron-node && \ - chmod +x /migrations/bin/migrate-behavioral-cohorts + chmod +x /migrations/bin/migrate-behavioral-cohorts && \ + chmod +x /migrations/bin/migrate-agent-runtime-queue ENTRYPOINT ["/migrations/bin/migrate-entry"] diff --git a/services/agent-core/migrations/0001_initial_schema.sql b/rust/agent_runtime_queue_migrations/20260514120000_initial_schema.sql similarity index 84% rename from services/agent-core/migrations/0001_initial_schema.sql rename to rust/agent_runtime_queue_migrations/20260514120000_initial_schema.sql index 76544abc66f4..b3c62fc2b7fa 100644 --- a/services/agent-core/migrations/0001_initial_schema.sql +++ b/rust/agent_runtime_queue_migrations/20260514120000_initial_schema.sql @@ -1,6 +1,6 @@ -- agent_sessions: durable record of a single session execution. -- Lives in a dedicated Postgres DB (agent_runtime_queue). The team-scoped mirror row --- in main posthog Postgres (AgentSession) carries FKs to Team/AgentApplication/Revision. +-- in main posthog Postgres (AgentApplicationSession) carries FKs to Team / app / revision. CREATE TYPE AgentSessionStatus AS ENUM( 'available', @@ -46,9 +46,3 @@ CREATE INDEX idx_agent_sessions_terminal CREATE INDEX idx_agent_sessions_team_id ON agent_sessions(team_id); CREATE INDEX idx_agent_sessions_revision_id ON agent_sessions(revision_id); CREATE INDEX idx_agent_sessions_application_id ON agent_sessions(application_id); - --- Bookkeeping so we can apply migrations idempotently. -CREATE TABLE IF NOT EXISTS agent_runtime_migrations ( - id TEXT PRIMARY KEY, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/rust/bin/migrate-agent-runtime-queue b/rust/bin/migrate-agent-runtime-queue new file mode 100755 index 000000000000..3e3574ac9c0b --- /dev/null +++ b/rust/bin/migrate-agent-runtime-queue @@ -0,0 +1,12 @@ +#!/bin/sh +SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + +AGENT_RUNTIME_QUEUE_DATABASE_NAME=${AGENT_RUNTIME_QUEUE_DATABASE_NAME:-agent_runtime_queue} +AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/$AGENT_RUNTIME_QUEUE_DATABASE_NAME} + +echo "Performing agent-runtime queue migrations for $AGENT_RUNTIME_QUEUE_DATABASE_URL (DATABASE_NAME=$AGENT_RUNTIME_QUEUE_DATABASE_NAME)" + +cd $SCRIPT_DIR/.. + +sqlx database create -D "$AGENT_RUNTIME_QUEUE_DATABASE_URL" +sqlx migrate run -D "$AGENT_RUNTIME_QUEUE_DATABASE_URL" --source agent_runtime_queue_migrations/ diff --git a/rust/bin/migrate-entry b/rust/bin/migrate-entry index 68f65f812566..b74bfcc7e0c3 100755 --- a/rust/bin/migrate-entry +++ b/rust/bin/migrate-entry @@ -23,16 +23,17 @@ parse_args() { } show_usage() { - echo "Usage: $0 [--fresh]" - echo " all - Run all migrations" - echo " persons - Run only persons migrations" - echo " cyclotron - Run only cyclotron migrations" - echo " cyclotron-node - Run only cyclotron node migrations" - echo " behavioral-cohorts - Run only behavioral cohorts migrations" - echo " flags-read-store - Run only flags read store migrations" + echo "Usage: $0 [--fresh]" + echo " all - Run all migrations" + echo " persons - Run only persons migrations" + echo " cyclotron - Run only cyclotron migrations" + echo " cyclotron-node - Run only cyclotron node migrations" + echo " behavioral-cohorts - Run only behavioral cohorts migrations" + echo " flags-read-store - Run only flags read store migrations" + echo " agent-runtime-queue - Run only agent-runtime queue migrations" echo "" echo "Options:" - echo " --fresh - Drop and recreate database before migrating (for test environments)" + echo " --fresh - Drop and recreate database before migrating (for test environments)" } parse_args "$@" @@ -189,6 +190,25 @@ run_flags_read_store_migrations() { fi } +run_agent_runtime_queue_migrations() { + local db_type="agent-runtime-queue" + AGENT_RUNTIME_QUEUE_DATABASE_NAME=${AGENT_RUNTIME_QUEUE_DATABASE_NAME:-agent_runtime_queue} + AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/$AGENT_RUNTIME_QUEUE_DATABASE_NAME} + + log_json "info" "Starting migrations" "starting" "$db_type" "\"database_name\":\"$AGENT_RUNTIME_QUEUE_DATABASE_NAME\",\"fresh_mode\":$FRESH_MODE" + + if [ "$FRESH_MODE" = true ]; then + sqlx database reset -y -D "$AGENT_RUNTIME_QUEUE_DATABASE_URL" --source "$MIGRATIONS_BASE/agent_runtime_queue_migrations/" + log_json "info" "Database reset and migrations completed" "completed" "$db_type" + else + sqlx database create -D "$AGENT_RUNTIME_QUEUE_DATABASE_URL" + log_json "info" "Database created or already exists" "running" "$db_type" + + sqlx migrate run -D "$AGENT_RUNTIME_QUEUE_DATABASE_URL" --source "$MIGRATIONS_BASE/agent_runtime_queue_migrations/" + log_json "info" "Migrations completed successfully" "completed" "$db_type" + fi +} + case "$MIGRATION_TYPE" in persons) run_persons_migrations @@ -205,11 +225,15 @@ case "$MIGRATION_TYPE" in flags-read-store) run_flags_read_store_migrations ;; + agent-runtime-queue) + run_agent_runtime_queue_migrations + ;; all) run_persons_migrations run_cyclotron_migrations run_cyclotron_node_migrations run_behavioral_cohorts_migrations + run_agent_runtime_queue_migrations ;; *) log_json "error" "Invalid migration type specified" "failed" "$MIGRATION_TYPE" diff --git a/services/agent-core/README.md b/services/agent-core/README.md index ec829e814d8d..865ee25e8d10 100644 --- a/services/agent-core/README.md +++ b/services/agent-core/README.md @@ -17,10 +17,14 @@ See [`docs/internal/agent-platform.md`](../../docs/internal/agent-platform.md) f ## Database -The queue owns a dedicated Postgres DB (`agent_runtime_queue`). Migrations live in `migrations/` and are applied via `bin/migrate.ts`. +The queue owns a dedicated Postgres DB (`agent_runtime_queue`). Schema lives in [`rust/agent_runtime_queue_migrations/`](../../rust/agent_runtime_queue_migrations/) and is applied with sqlx via the shared rust migrations image — same pattern as cyclotron. ```bash -AGENT_RUNTIME_QUEUE_DATABASE_URL=postgres://... pnpm migrate +# locally via the top-level migrate script (preferred — also creates the DB) +bin/migrate --scope=agent_runtime + +# or directly +rust/bin/migrate-agent-runtime-queue ``` ## Hard rules diff --git a/services/agent-core/bin/migrate-agent-runtime b/services/agent-core/bin/migrate-agent-runtime deleted file mode 100755 index 22106fb9eac6..000000000000 --- a/services/agent-core/bin/migrate-agent-runtime +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Apply pending migrations to the agent-runtime queue DB. -# -# Mirrors rust/bin/migrate-cyclotron but for the TypeScript-managed agent_runtime_queue -# schema owned by @posthog/agent-core. Called from bin/migrate --scope=agent_runtime. - -set -e - -SCRIPT_DIR=$(dirname "$(readlink -f "$0")") - -AGENT_RUNTIME_QUEUE_DATABASE_NAME=${AGENT_RUNTIME_QUEUE_DATABASE_NAME:-agent_runtime_queue} -AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/$AGENT_RUNTIME_QUEUE_DATABASE_NAME} - -echo "Performing agent-runtime queue migrations for $AGENT_RUNTIME_QUEUE_DATABASE_URL" - -cd "$SCRIPT_DIR/.." - -AGENT_RUNTIME_QUEUE_DATABASE_URL="$AGENT_RUNTIME_QUEUE_DATABASE_URL" \ - pnpm exec tsx bin/migrate.ts diff --git a/services/agent-core/bin/migrate.ts b/services/agent-core/bin/migrate.ts deleted file mode 100644 index 2ae19e5e57d6..000000000000 --- a/services/agent-core/bin/migrate.ts +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env tsx -/** - * Apply pending migrations to the agent-runtime queue DB. - * - * Reads SQL files from services/agent-core/migrations/, applies them in lexicographic - * order, records each applied id in agent_runtime_migrations. - * - * Usage: - * AGENT_RUNTIME_QUEUE_DATABASE_URL=postgres://... pnpm migrate - */ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { Pool } from 'pg' - -async function main(): Promise { - const url = process.env.AGENT_RUNTIME_QUEUE_DATABASE_URL - if (!url) { - console.error('AGENT_RUNTIME_QUEUE_DATABASE_URL is required') - process.exit(1) - } - - const migrationsDir = join(__dirname, '..', 'migrations') - const files = readdirSync(migrationsDir) - .filter((f) => f.endsWith('.sql')) - .sort() - - const pool = new Pool({ connectionString: url }) - try { - await pool.query( - `CREATE TABLE IF NOT EXISTS agent_runtime_migrations ( - id TEXT PRIMARY KEY, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )` - ) - - for (const file of files) { - const id = file.replace(/\.sql$/, '') - const { rowCount } = await pool.query('SELECT 1 FROM agent_runtime_migrations WHERE id = $1', [id]) - if (rowCount && rowCount > 0) { - console.info(`[migrate] skip ${id}`) - continue - } - const sql = readFileSync(join(migrationsDir, file), 'utf8') - console.info(`[migrate] apply ${id}`) - await pool.query('BEGIN') - try { - await pool.query(sql) - await pool.query('INSERT INTO agent_runtime_migrations (id) VALUES ($1)', [id]) - await pool.query('COMMIT') - } catch (err) { - await pool.query('ROLLBACK') - throw err - } - } - } finally { - await pool.end() - } -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/services/agent-core/jest.config.js b/services/agent-core/jest.config.js index c53ebf19d35a..fdafc799cbd1 100644 --- a/services/agent-core/jest.config.js +++ b/services/agent-core/jest.config.js @@ -3,7 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], - testTimeout: 15_000, + testTimeout: 5_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, diff --git a/services/agent-core/package.json b/services/agent-core/package.json index 619f108f8f14..2c37adf70854 100644 --- a/services/agent-core/package.json +++ b/services/agent-core/package.json @@ -16,8 +16,7 @@ "lint:fix": "eslint --fix .", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "jest --runInBand --forceExit", - "migrate": "tsx bin/migrate.ts" + "test": "jest --runInBand --forceExit" }, "dependencies": { "ioredis": "^4.27.6", @@ -47,7 +46,6 @@ "jest": "catalog:", "prettier": "^3.6.2", "ts-jest": "^29.1.0", - "tsx": "^4.7.0", "typescript": "catalog:" }, "engines": { diff --git a/services/agent-core/src/queue/queue.test.ts b/services/agent-core/src/queue/queue.test.ts index 858198459ec6..1e57b1556f81 100644 --- a/services/agent-core/src/queue/queue.test.ts +++ b/services/agent-core/src/queue/queue.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { Pool } from 'pg' import { v7 as uuidv7 } from 'uuid' @@ -21,11 +21,20 @@ describeIfDb('agent-core queue (DB-gated)', () => { beforeAll(async () => { pool = new Pool({ connectionString: DB_URL }) - const schema = readFileSync(join(__dirname, '..', '..', 'migrations', '0001_initial_schema.sql'), 'utf8') await pool.query(`DROP TABLE IF EXISTS agent_sessions`) - await pool.query(`DROP TABLE IF EXISTS agent_runtime_migrations`) + await pool.query(`DROP TABLE IF EXISTS _sqlx_migrations`) await pool.query(`DROP TYPE IF EXISTS AgentSessionStatus`) - await pool.query(schema) + + // Canonical migrations live in rust/agent_runtime_queue_migrations/, applied via + // sqlx in production. For this DB-gated suite we replay them in lexicographic + // order directly so we don't need sqlx-cli on the test machine. + const migrationsDir = join(__dirname, '..', '..', '..', '..', 'rust', 'agent_runtime_queue_migrations') + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql')) + .sort() + for (const file of files) { + await pool.query(readFileSync(join(migrationsDir, file), 'utf8')) + } }) afterAll(async () => { diff --git a/services/agent-ingress/jest.config.js b/services/agent-ingress/jest.config.js index c53ebf19d35a..fdafc799cbd1 100644 --- a/services/agent-ingress/jest.config.js +++ b/services/agent-ingress/jest.config.js @@ -3,7 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], - testTimeout: 15_000, + testTimeout: 5_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, diff --git a/services/agent-ingress/src/routes/run.ts b/services/agent-ingress/src/routes/run.ts index edf41d7db05c..e03ca7543da3 100644 --- a/services/agent-ingress/src/routes/run.ts +++ b/services/agent-ingress/src/routes/run.ts @@ -52,12 +52,18 @@ export function registerRun(app: Express, deps: ServerDeps): void { } try { + const initialState = { + messages: [], + pendingInputs: [], + initialInput: body.input ?? null, + turnCount: 0, + } const sessionId = await deps.queue.createJob({ teamId: revision.teamId, applicationId: revision.applicationId, revisionId: revision.revisionId, queueName: 'default', - state: body.input ? Buffer.from(JSON.stringify(body.input)) : null, + state: Buffer.from(JSON.stringify(initialState), 'utf8'), }) return res.status(202).json({ sessionId }) } catch (err) { diff --git a/services/agent-janitor/jest.config.js b/services/agent-janitor/jest.config.js index c53ebf19d35a..fdafc799cbd1 100644 --- a/services/agent-janitor/jest.config.js +++ b/services/agent-janitor/jest.config.js @@ -3,7 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], - testTimeout: 15_000, + testTimeout: 5_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, diff --git a/services/agent-runner/jest.config.js b/services/agent-runner/jest.config.js index c53ebf19d35a..fdafc799cbd1 100644 --- a/services/agent-runner/jest.config.js +++ b/services/agent-runner/jest.config.js @@ -3,7 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], - testTimeout: 15_000, + testTimeout: 5_000, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], }, diff --git a/services/agent-runner/src/index.ts b/services/agent-runner/src/index.ts index 168f7bbc9eef..c6d5447b51cc 100644 --- a/services/agent-runner/src/index.ts +++ b/services/agent-runner/src/index.ts @@ -1,4 +1,4 @@ -import { InMemorySessionBus, InternalApiClient, RedisSessionBus, SessionBus, logger } from '@posthog/agent-core' +import { InMemorySessionBus, RedisSessionBus, SessionBus, logger } from '@posthog/agent-core' import { loadConfig } from './config' import { NotImplementedExecutor } from './executor-stub' @@ -7,11 +7,6 @@ import { RunnerWorker } from './worker' async function main(): Promise { const config = loadConfig() - const apiClient = new InternalApiClient({ - baseUrl: config.internalApiBaseUrl, - sharedKey: config.internalApiSharedKey, - }) - const bus: SessionBus = config.redisUrl ? new RedisSessionBus({ url: config.redisUrl }) : new InMemorySessionBus() if (!config.redisUrl) { @@ -23,15 +18,12 @@ async function main(): Promise { queueName: config.queueName, executor: new NotImplementedExecutor(), bus, - loadSecrets: async (applicationId) => { - if (!applicationId) { - return {} - } - // Real wiring: ask Django for the secrets declared on the manifest. For now, - // the placeholder executor never reaches the tool dispatch path, so an empty - // map is fine. - const { secrets } = await apiClient.decryptSecrets(applicationId, []) - return secrets + loadSecrets: () => { + // EchoExecutor (v1) never reaches tool dispatch, so we skip the Django + // decrypt call. When the real Claude Agent SDK executor lands we'll wire + // `InternalApiClient.decryptSecrets(applicationId, names)` here, scoped + // to the names declared on the manifest the turn is about to invoke. + return Promise.resolve({}) }, }) From 6e45ff9588ae5c7dfae9bce6f99f2c914165039b Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:40:58 -0400 Subject: [PATCH 015/517] docs(agents): TODO to rewrite the DB-backed queue test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four DB-gated tests (`enqueue → dequeue → ack`, `reschedule round-trips state`) hang at consumeOnce() — the polling worker isn't dequeuing what the manager inserted. Park them under a clear TODO so we come back and rewrite per-method, one step at a time. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/internal/agent-platform.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/internal/agent-platform.md b/docs/internal/agent-platform.md index 0a3134fc9472..561055aed018 100644 --- a/docs/internal/agent-platform.md +++ b/docs/internal/agent-platform.md @@ -36,8 +36,9 @@ Working tracker for the runtime services only — the Django side is being built - [x] Manifest reader + Zod schema + built-in id validation ([src/manifest/index.ts](../../services/agent-core/src/manifest/index.ts)) - [x] Logger (pino) + Prom metrics ([src/logger.ts](../../services/agent-core/src/logger.ts), [src/metrics.ts](../../services/agent-core/src/metrics.ts)) - [x] `SessionQuery` — read-only `findSession` / `listSessions` + targeted-write `cancelSession`, used by the janitor's HTTP surface ([src/queue/query.ts](../../services/agent-core/src/queue/query.ts)) -- [x] Tests: queue + SessionQuery (DB-gated), pubsub in-memory, manifest, builtins +- [x] Tests: pubsub in-memory, manifest, builtins - [x] Tests: internal-API client smoke — 200, 404, 5xx, shared-key header, timeout ([src/internal-api/client.test.ts](../../services/agent-core/src/internal-api/client.test.ts)) +- [ ] **Rewrite the DB-backed test suite from scratch, run step by step.** The current DB-gated tests in [src/queue/queue.test.ts](../../services/agent-core/src/queue/queue.test.ts) are flaky — `enqueue → dequeue → ack` and `reschedule round-trips state` hang at `consumeOnce()` against a real Postgres, suggesting the polling worker isn't dequeuing what the manager inserted. Plan: write per-method tests that exercise one operation at a time against a fresh DB (createJob → assert row, dequeue → assert lock, ack → assert status), drive the worker synchronously where possible, and don't share pools across tests. SessionQuery and janitor tests are fine; only the worker-polling tests need rebuilding. - [ ] Tests: Redis pubsub integration (needs Redis in CI) - [ ] Decide internal-API transport auth (mTLS vs shared key) — both supported in code, pick at infra time From 241e8a396cb16619c2a8aa23099ad4f8e3ef061e Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 14 May 2026 08:45:26 -0400 Subject: [PATCH 016/517] feat(agents): auto-rebuild agent-core in hogli (tsc -b --watch + tsx --include) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `agent-core-watch` mprocs entry that runs `tsc -b --watch` on @posthog/agent-core, and points each dependent service's `start:dev` at `../agent-core/dist/**` via `tsx watch --include` so a code change in agent-core re-emits dist and the three services hot-reload. - bin/mprocs.yaml — new agent-core-watch process (capability: agent_runtime). Dependent services now `until [ -f .../dist/index.js ]; do sleep 0.5; done` to wait for the watch's initial build instead of doing their own redundant `pnpm --filter @posthog/agent-core build`. - services/agent-{ingress,runner,janitor}/package.json — start:dev gains `--include '../agent-core/dist/**'` so the dependent's tsx watcher reloads when agent-core's dist changes (workspace symlinks under node_modules are otherwise outside tsx's default watch scope). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/mprocs.yaml | 14 +++++++++++--- services/agent-ingress/package.json | 2 +- services/agent-janitor/package.json | 2 +- services/agent-runner/package.json | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/bin/mprocs.yaml b/bin/mprocs.yaml index 3dacfcebcfd1..e61e5086f44c 100755 --- a/bin/mprocs.yaml +++ b/bin/mprocs.yaml @@ -215,10 +215,18 @@ procs: layer: Processing tech: Rust + agent-core-watch: + shell: 'pnpm --filter @posthog/agent-core exec tsc -b --watch --preserveWatchOutput' + capability: agent_runtime + ready_pattern: 'Watching for file changes' + groups: + layer: Infrastructure + tech: Node + agent-ingress: shell: |- bin/wait-for-docker && \ - pnpm --filter @posthog/agent-core build && \ + until [ -f services/agent-core/dist/index.js ]; do sleep 0.5; done && \ AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ INTERNAL_API_BASE_URL=${INTERNAL_API_BASE_URL:-http://localhost:8000} \ REDIS_URL=${REDIS_URL:-redis://localhost:6379/0} \ @@ -234,7 +242,7 @@ procs: agent-runner: shell: |- bin/wait-for-docker && \ - pnpm --filter @posthog/agent-core build && \ + until [ -f services/agent-core/dist/index.js ]; do sleep 0.5; done && \ AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ INTERNAL_API_BASE_URL=${INTERNAL_API_BASE_URL:-http://localhost:8000} \ REDIS_URL=${REDIS_URL:-redis://localhost:6379/0} \ @@ -248,7 +256,7 @@ procs: agent-janitor: shell: |- bin/wait-for-docker && \ - pnpm --filter @posthog/agent-core build && \ + until [ -f services/agent-core/dist/index.js ]; do sleep 0.5; done && \ AGENT_RUNTIME_QUEUE_DATABASE_URL=${AGENT_RUNTIME_QUEUE_DATABASE_URL:-postgres://posthog:posthog@localhost:5432/agent_runtime_queue} \ AGENT_INTERNAL_API_SHARED_KEY=${AGENT_INTERNAL_API_SHARED_KEY:-dev-shared-key} \ PORT=${AGENT_JANITOR_PORT:-3031} \ diff --git a/services/agent-ingress/package.json b/services/agent-ingress/package.json index 758f5d2e17d2..f1c9ffb7838d 100644 --- a/services/agent-ingress/package.json +++ b/services/agent-ingress/package.json @@ -17,7 +17,7 @@ "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "start": "node dist/index.js", - "start:dev": "tsx watch src/index.ts" + "start:dev": "tsx watch --include '../agent-core/dist/**' src/index.ts" }, "dependencies": { "@posthog/agent-core": "workspace:*", diff --git a/services/agent-janitor/package.json b/services/agent-janitor/package.json index b97e964ea642..b5a2fed7241d 100644 --- a/services/agent-janitor/package.json +++ b/services/agent-janitor/package.json @@ -17,7 +17,7 @@ "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "start": "node dist/index.js", - "start:dev": "tsx watch src/index.ts" + "start:dev": "tsx watch --include '../agent-core/dist/**' src/index.ts" }, "dependencies": { "@posthog/agent-core": "workspace:*", diff --git a/services/agent-runner/package.json b/services/agent-runner/package.json index 59965d4b5ce4..eadef6fe9d1e 100644 --- a/services/agent-runner/package.json +++ b/services/agent-runner/package.json @@ -17,7 +17,7 @@ "format:check": "prettier --check .", "test": "jest --runInBand --forceExit", "start": "node dist/index.js", - "start:dev": "tsx watch src/index.ts" + "start:dev": "tsx watch --include '../agent-core/dist/**' src/index.ts" }, "dependencies": { "@posthog/agent-core": "workspace:*", From 88bdb247b1c81e7d33b06e06dc7e2f4e9e267d11 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 14 May 2026 08:57:43 -0400 Subject: [PATCH 017/517] frontend --- frontend/src/products.tsx | 8 + pnpm-lock.yaml | 27 +- .../backend/management/__init__.py | 0 .../backend/management/commands/__init__.py | 0 .../commands/seed_agent_applications.py | 197 +++++++ .../frontend/AgentApplicationScene.tsx | 157 ++++++ .../frontend/AgentApplicationsScene.tsx | 173 ++++++ products/agent_stack/frontend/AgentStack.scss | 510 ++++++++++++++++++ .../frontend/agentApplicationLogic.ts | 179 ++++++ .../frontend/agentApplicationsLogic.ts | 29 + .../components/AgentApplicationOverview.tsx | 185 +++++++ .../components/AgentApplicationSettings.tsx | 131 +++++ .../frontend/generated/api.schemas.ts | 335 ++++++++++++ .../agent_stack/frontend/generated/api.ts | 416 ++++++++++++++ .../agent_stack/frontend/generated/api.zod.ts | 156 ++++++ products/agent_stack/manifest.tsx | 21 +- products/agent_stack/package.json | 12 + services/mcp/src/api/generated.ts | 328 +++++++++++ 18 files changed, 2857 insertions(+), 7 deletions(-) create mode 100644 products/agent_stack/backend/management/__init__.py create mode 100644 products/agent_stack/backend/management/commands/__init__.py create mode 100644 products/agent_stack/backend/management/commands/seed_agent_applications.py create mode 100644 products/agent_stack/frontend/AgentApplicationScene.tsx create mode 100644 products/agent_stack/frontend/AgentApplicationsScene.tsx create mode 100644 products/agent_stack/frontend/AgentStack.scss create mode 100644 products/agent_stack/frontend/agentApplicationLogic.ts create mode 100644 products/agent_stack/frontend/agentApplicationsLogic.ts create mode 100644 products/agent_stack/frontend/components/AgentApplicationOverview.tsx create mode 100644 products/agent_stack/frontend/components/AgentApplicationSettings.tsx create mode 100644 products/agent_stack/frontend/generated/api.schemas.ts create mode 100644 products/agent_stack/frontend/generated/api.ts create mode 100644 products/agent_stack/frontend/generated/api.zod.ts diff --git a/frontend/src/products.tsx b/frontend/src/products.tsx index 067bb57d84b0..fedea8d542f6 100644 --- a/frontend/src/products.tsx +++ b/frontend/src/products.tsx @@ -51,6 +51,8 @@ export const productScenes: Record Promise> = { Actions: () => import('../../products/actions/frontend/pages/Actions'), Action: () => import('../../products/actions/frontend/pages/Action'), NewAction: () => import('../../products/actions/frontend/pages/Action'), + AgentApplications: () => import('../../products/agent_stack/frontend/AgentApplicationsScene'), + AgentApplication: () => import('../../products/agent_stack/frontend/AgentApplicationScene'), BusinessKnowledge: () => import('../../products/business_knowledge/frontend/scenes/BusinessKnowledgeScene'), Transformations: () => import('../../frontend/src/scenes/data-pipelines/TransformationsScene'), SupportTickets: () => import('../../products/conversations/frontend/scenes/tickets/SupportTicketsScene'), @@ -143,6 +145,8 @@ export const productRoutes: Record = { '/data-management/actions/new': ['NewAction', 'actionNew'], '/data-management/actions/:id': ['Action', 'action'], '/data-management/actions/new/': ['NewAction', 'actionNew'], + '/agents': ['AgentApplications', 'agentApplications'], + '/agents/:slug': ['AgentApplication', 'agentApplication'], '/business-knowledge': ['BusinessKnowledge', 'businessKnowledge'], '/transformations': ['Transformations', 'transformations'], '/support/tickets': ['SupportTickets', 'supportTickets'], @@ -310,6 +314,8 @@ export const productConfiguration: Record = { }, Action: { name: 'Action', projectBased: true, activityScope: 'Action', iconType: 'action' }, NewAction: { name: 'New Action', projectBased: true, activityScope: 'Action', iconType: 'action' }, + AgentApplications: { name: 'Agent stack', projectBased: true }, + AgentApplication: { name: 'Agent application', projectBased: true }, BusinessKnowledge: { name: 'Business knowledge', projectBased: true, @@ -628,6 +634,8 @@ export const productUrls = { }, action: (id: string | number): string => `/data-management/actions/${id}`, actions: (): string => '/data-management/actions', + agentApplications: (): string => '/agents', + agentApplication: (slug: string): string => `/agents/${slug}`, businessKnowledge: (): string => '/business-knowledge', transformations: (): string => '/transformations', cohort: (id: string | number): string => `/cohorts/${id}`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c635f20f7a3..ac0bfc30fb4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2065,7 +2065,32 @@ importers: specifier: 'catalog:' version: 0.2.4(kea@4.0.0-pre.5(react@18.3.1)) - products/agent_stack: {} + products/agent_stack: + dependencies: + '@posthog/icons': + specifier: '*' + version: 0.36.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@posthog/react': + specifier: '*' + version: 1.9.0(@types/react@18.3.27)(posthog-js@1.373.4)(react@18.3.1) + '@types/react': + specifier: 18.3.27 + version: 18.3.27 + kea: + specifier: 'catalog:' + version: 4.0.0-pre.5(react@18.3.1) + kea-forms: + specifier: 'catalog:' + version: 3.2.0(kea@4.0.0-pre.5(react@18.3.1)) + kea-loaders: + specifier: 'catalog:' + version: 3.1.1(kea@4.0.0-pre.5(react@18.3.1)) + kea-router: + specifier: 'catalog:' + version: 3.4.1(kea@4.0.0-pre.5(react@18.3.1)) + react: + specifier: 18.3.1 + version: 18.3.1 products/alerts: {} diff --git a/products/agent_stack/backend/management/__init__.py b/products/agent_stack/backend/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/management/commands/__init__.py b/products/agent_stack/backend/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/agent_stack/backend/management/commands/seed_agent_applications.py b/products/agent_stack/backend/management/commands/seed_agent_applications.py new file mode 100644 index 000000000000..1bdd4fe112d0 --- /dev/null +++ b/products/agent_stack/backend/management/commands/seed_agent_applications.py @@ -0,0 +1,197 @@ +"""Seed fake agent applications, revisions, and sessions for local dev.""" + +from __future__ import annotations + +import random +import hashlib +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from posthog.models.team import Team + +from products.agent_stack.backend.enums import DeploymentStatus, RevisionState, SessionState +from products.agent_stack.backend.models import AgentApplication, AgentApplicationRevision, AgentApplicationSession + +# Believable app names so the UI doesn't look like lorem ipsum. +SAMPLE_APPS = [ + ("standup-bot", "Standup bot", "Collects daily standups from Slack and summarises into a digest."), + ("triage-bot", "Triage bot", "Watches the bug intake channel and routes new reports to the right team."), + ("deploy-monitor", "Deploy monitor", "Tails CI events, surfaces failures, kicks off rollbacks on regressions."), + ("inbox-zero", "Inbox zero", "Drafts replies to support tickets and posts them for human review."), + ("oncall-buddy", "Oncall buddy", "Handles first-line alerts overnight, escalates when uncertain."), +] + +SAMPLE_TRIGGER_TYPES = ["cron", "webhook", "slack", "api"] + + +def _hash(seed: str) -> str: + return hashlib.sha256(seed.encode()).hexdigest() + + +def _pick_revision_state() -> str: + return random.choices( + [ + RevisionState.READY, + RevisionState.READY, + RevisionState.READY, + RevisionState.UPLOADED, + RevisionState.FAILED, + RevisionState.PENDING_UPLOAD, + ], + k=1, + )[0] + + +def _pick_session_state() -> str: + return random.choices( + [ + SessionState.COMPLETED, + SessionState.COMPLETED, + SessionState.COMPLETED, + SessionState.RUNNING, + SessionState.FAILED, + SessionState.CANCELED, + SessionState.AVAILABLE, + ], + k=1, + )[0] + + +class Command(BaseCommand): + help = "Seed fake agent applications, revisions, and sessions for the given team." + + def add_arguments(self, parser): + parser.add_argument("--team-id", type=int, default=1, help="Team ID to seed (default: 1).") + parser.add_argument( + "--apps", + type=int, + default=len(SAMPLE_APPS), + help=f"Number of apps to create, capped at {len(SAMPLE_APPS)} (default: all).", + ) + parser.add_argument( + "--revisions-per-app", + type=int, + default=4, + help="Number of revisions per app (default: 4). One will be marked live.", + ) + parser.add_argument( + "--sessions-per-app", + type=int, + default=30, + help="Number of sessions per app spread over the last 7 days (default: 30).", + ) + parser.add_argument( + "--wipe", + action="store_true", + help="Delete any existing seeded data for the team before reseeding.", + ) + + def handle(self, *_args, **options) -> None: + team_id: int = options["team_id"] + try: + team = Team.objects.get(id=team_id) + except Team.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Team {team_id} not found")) + return + + if options["wipe"]: + deleted, _ = AgentApplication.objects.filter(team=team).delete() + self.stdout.write(self.style.WARNING(f"Wiped {deleted} agent_stack rows for team {team.id}")) + + n_apps = min(options["apps"], len(SAMPLE_APPS)) + for slug, name, description in SAMPLE_APPS[:n_apps]: + self._seed_app( + team=team, + slug=slug, + name=name, + description=description, + revisions_per_app=options["revisions_per_app"], + sessions_per_app=options["sessions_per_app"], + ) + + self.stdout.write(self.style.SUCCESS(f"Seeded {n_apps} agent applications for team {team.id}")) + + def _seed_app( + self, + *, + team: Team, + slug: str, + name: str, + description: str, + revisions_per_app: int, + sessions_per_app: int, + ) -> None: + app, created = AgentApplication.objects.get_or_create( + team=team, + slug=slug, + defaults={ + "name": name, + "description": description, + "encrypted_env": "ANTHROPIC_API_KEY=sk-fake\nSLACK_BOT_TOKEN=xoxb-fake\nDATABASE_URL=postgres://fake", + }, + ) + if not created: + self.stdout.write(f" {slug} already exists, refreshing revisions / sessions") + + revisions: list[AgentApplicationRevision] = [] + for i in range(revisions_per_app): + state = _pick_revision_state() if i < revisions_per_app - 1 else RevisionState.READY + rev = AgentApplicationRevision.objects.create( + team=team, + application=app, + state=state, + deployment_status=DeploymentStatus.DISABLED, + bundle_sha256=_hash(f"{slug}-{i}"), + bundle_size=random.randint(100_000, 5_000_000), + top_level_config={"version": "v1", "agent_name": name}, + ) + # Backdate so the "list ready revisions" sort is interesting. + AgentApplicationRevision.objects.filter(pk=rev.pk).update( + created_at=timezone.now() - timedelta(days=revisions_per_app - i, hours=random.randint(0, 12)), + ) + revisions.append(rev) + + ready = [r for r in revisions if r.state == RevisionState.READY] + if ready: + live = ready[-1] + live.deployment_status = DeploymentStatus.LIVE + live.save(update_fields=["deployment_status", "updated_at"]) + # Mark one earlier ready revision as a preview, just for visual variety. + if len(ready) >= 2: + preview = ready[0] + preview.deployment_status = DeploymentStatus.PREVIEW + preview.save(update_fields=["deployment_status", "updated_at"]) + + for k in range(sessions_per_app): + age_minutes = random.randint(1, 7 * 24 * 60) + state = _pick_session_state() + trigger = random.choice(SAMPLE_TRIGGER_TYPES) + session_revision = random.choice(ready) + session = AgentApplicationSession.objects.create( + team=team, + application=app, + revision=session_revision, + state=state, + trigger_type=trigger, + trigger_payload={"source": trigger, "session_index": k}, + input={"prompt": "What's the status of yesterday's standup?"} if trigger == "slack" else {}, + ) + started_at = timezone.now() - timedelta(minutes=age_minutes) + heartbeat_at = started_at + timedelta(seconds=random.randint(5, 600)) + completed_at = None + if state in (SessionState.COMPLETED, SessionState.FAILED, SessionState.CANCELED): + completed_at = heartbeat_at + timedelta(seconds=random.randint(10, 600)) + AgentApplicationSession.objects.filter(pk=session.pk).update( + created_at=started_at, + started_at=started_at, + last_heartbeat_at=heartbeat_at, + completed_at=completed_at, + ) + + self.stdout.write( + f" {slug}: {len(revisions)} revisions, " + f"live={'yes' if ready else 'no'}, " + f"{sessions_per_app if ready else 0} sessions" + ) diff --git a/products/agent_stack/frontend/AgentApplicationScene.tsx b/products/agent_stack/frontend/AgentApplicationScene.tsx new file mode 100644 index 000000000000..3c57457ee142 --- /dev/null +++ b/products/agent_stack/frontend/AgentApplicationScene.tsx @@ -0,0 +1,157 @@ +import './AgentStack.scss' + +import { useActions, useValues } from 'kea' + +import { IconCheckCircle, IconLock, IconPlay, IconWarning } from '@posthog/icons' +import { LemonSkeleton, Link } from '@posthog/lemon-ui' + +import { NotFound } from 'lib/components/NotFound' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { agentApplicationLogic, AgentApplicationLogicProps, AgentApplicationTab } from './agentApplicationLogic' +import { AgentApplicationOverview } from './components/AgentApplicationOverview' +import { AgentApplicationSettings } from './components/AgentApplicationSettings' + +export const scene: SceneExport = { + component: AgentApplicationScene, + logic: agentApplicationLogic, + paramsToProps: ({ params: { slug } }) => ({ slug }), +} + +function Telemetry(): JSX.Element { + const { application, liveRevision, sessionStats } = useValues(agentApplicationLogic) + const envCount = application?.env_redacted ? application.env_redacted.split('\n').filter(Boolean).length : 0 + + return ( +
+
+
// Live revision
+
+ {liveRevision ? ( + <> + + {liveRevision.id.slice(0, 8)} + + ) : ( + none + )} +
+
+ {liveRevision ? state: {liveRevision.state} : no live deployment} +
+
+ +
+
// Sessions
+
+ + + {sessionStats.running} + + + + {sessionStats.succeeded} + + + + {sessionStats.failed} + +
+
+ total: {sessionStats.total} +
+
+ +
+
// Secrets
+
+ + {envCount} +
+
+ encrypted · in-cluster decrypt only +
+
+ +
+
// Updated
+
+ {application ? new Date(application.updated_at).toISOString().slice(0, 19).replace('T', ' ') : ''} +
+
utc · last manifest change
+
+
+ ) +} + +export function AgentApplicationScene(): JSX.Element { + const { application, applicationLoading, applicationMissing, activeTab } = useValues(agentApplicationLogic) + const { setActiveTab } = useActions(agentApplicationLogic) + + if (applicationMissing) { + return + } + + if (applicationLoading && !application) { + return ( +
+ + +
+ ) + } + + if (!application) { + return + } + + return ( +
+
+
+ // agents + / {application.slug} +
+
+
+

{application.name}

+ + {application.slug} + .agents.posthog.com + +
+ + + Online + +
+
+ + + +
+
+ + +
+ + {activeTab === AgentApplicationTab.Overview ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/products/agent_stack/frontend/AgentApplicationsScene.tsx b/products/agent_stack/frontend/AgentApplicationsScene.tsx new file mode 100644 index 000000000000..a7c5d6bea46b --- /dev/null +++ b/products/agent_stack/frontend/AgentApplicationsScene.tsx @@ -0,0 +1,173 @@ +import './AgentStack.scss' + +import { useValues } from 'kea' +import { router } from 'kea-router' + +import { IconArrowRight, IconBolt } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { TZLabel } from 'lib/components/TZLabel' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { agentApplicationsLogic } from './agentApplicationsLogic' +import type { AgentApplicationApi } from './generated/api.schemas' + +export const scene: SceneExport = { + component: AgentApplicationsScene, + logic: agentApplicationsLogic, +} + +function countEnvKeys(envRedacted: string): number { + if (!envRedacted) { + return 0 + } + return envRedacted.split('\n').filter((line) => line.trim().length > 0).length +} + +function TelemetryHeader({ apps }: { apps: AgentApplicationApi[] }): JSX.Element { + const totalEnv = apps.reduce((acc, a) => acc + countEnvKeys(a.env_redacted), 0) + return ( +
+
+
// Agents online
+
+ + {apps.length.toString().padStart(2, '0')} +
+
tracking via posthog · realtime
+
+
+
// Secrets registered
+
{totalEnv}
+
across {apps.length} agents
+
+
+
// Console build
+
v0.1.0-alpha
+
operator: console
+
+
+ ) +} + +function ApplicationCard({ app, index }: { app: AgentApplicationApi; index: number }): JSX.Element { + const detailUrl = urls.agentApplication(app.slug) + const envCount = countEnvKeys(app.env_redacted) + + return ( +
router.actions.push(detailUrl)} + style={{ animationDelay: `${index * 40}ms` }} + > +
+
+
+ + // Live +
+

+ {app.name} +

+ + {app.slug} + .agents.posthog.com + +
+ +
+ +

+ {app.description || '// no description set'} +

+ +
+
+
+
+ + {envCount} + env + + · + + — + sessions + +
+ + + +
+
+
+ ) +} + +function EmptyState(): JSX.Element { + return ( +
+
// No deployments detected
+

+ The console is waiting. +

+

+ Scaffold an agent with ass new my-agent, then{' '} + ass deploy to bring it online. +

+ } + onClick={() => window.open('https://github.com/PostHog/agent-stack', '_blank')} + > + Open the docs + +
+ ) +} + +export function AgentApplicationsScene(): JSX.Element { + const { applications, applicationsLoading } = useValues(agentApplicationsLogic) + const shouldShowEmpty = applications.length === 0 && !applicationsLoading + + return ( +
+
+
// agents
+

Agent stack

+

+ operator console · monitoring deployed agents in real time +

+
+ + {!shouldShowEmpty && } + +
+
▌ Deployed agents
+
+ {applicationsLoading ? 'syncing…' : `${applications.length} active`} +
+
+ + {shouldShowEmpty ? ( + + ) : ( +
+ {applications.map((app, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/products/agent_stack/frontend/AgentStack.scss b/products/agent_stack/frontend/AgentStack.scss new file mode 100644 index 000000000000..60a77a35e010 --- /dev/null +++ b/products/agent_stack/frontend/AgentStack.scss @@ -0,0 +1,510 @@ +// Agent stack operator console. +// +// Distinctive look-and-feel for the agent management surface: monospace +// data, phosphor-green live indicators, hairline borders, low-noise +// background gradients. Always dark — operator consoles glow. + +@import 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap'; + +.agent-stack-console { + --as-surface: #0a0c10; + --as-surface-1: #11141a; + --as-surface-2: #161a21; + --as-surface-3: #1c2129; + --as-border: rgb(255 255 255 / 6%); + --as-border-strong: rgb(255 255 255 / 12%); + --as-border-accent: rgb(74 222 128 / 40%); + --as-text: #e8ecf2; + --as-text-muted: #7d8590; + --as-text-dim: #4a5058; + --as-text-bright: #fff; + --as-live: #4ade80; + --as-live-glow: rgb(74 222 128 / 45%); + --as-preview: #c084fc; + --as-preview-glow: rgb(192 132 252 / 35%); + --as-warning: #fbbf24; + --as-danger: #f87171; + --as-accent: #38bdf8; + --as-mono: 'JetBrains Mono', 'SF Mono', ui-monospace, menlo, consolas, monospace; + --as-display: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; + + position: relative; + padding: 24px; + overflow: hidden; + font-family: var(--as-display); + color: var(--as-text); + background: + radial-gradient(at 90% 0%, rgb(56 189 248 / 6%), transparent 45%), + radial-gradient(at 10% 100%, rgb(74 222 128 / 4%), transparent 45%), var(--as-surface); + border: 1px solid var(--as-border); + border-radius: 10px; + + // Faint scanline overlay — sells the operator-console vibe without being intrusive. + &::before { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + content: ''; + background-image: linear-gradient(rgb(255 255 255 / 1.5%) 1px, transparent 1px); + background-size: 100% 3px; + opacity: 0.7; + } + + // Top edge accent — like a server rack indicator strip. + &::after { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 1; + height: 1px; + content: ''; + background: linear-gradient( + 90deg, + transparent 0%, + var(--as-border-accent) 20%, + var(--as-border-accent) 30%, + transparent 50%, + transparent 100% + ); + } + + > * { + position: relative; + z-index: 2; + } + + // Mono utility class — apply to anything that's data, not prose. + .as-mono { + font-family: var(--as-mono); + font-variant-numeric: tabular-nums; + } + + // Section labels — // LIVE DEPLOYMENTS, ▌ TELEMETRY, etc. + .as-label { + font-family: var(--as-mono); + font-size: 10px; + font-weight: 500; + color: var(--as-text-muted); + text-transform: uppercase; + letter-spacing: 0.18em; + } + + .as-label-accent { + color: var(--as-live); + } + + // Hairline rule with a tick mark — operator console section divider. + .as-divider { + position: relative; + height: 1px; + margin: 8px 0; + background: var(--as-border); + + &::before { + position: absolute; + top: -2px; + left: 0; + width: 4px; + height: 5px; + content: ''; + background: var(--as-live); + } + } + + // Stat tile — a piece of telemetry. + .as-tile { + position: relative; + padding: 14px 16px; + overflow: hidden; + background: var(--as-surface-1); + border: 1px solid var(--as-border); + border-radius: 6px; + transition: + border-color 0.2s ease, + background 0.2s ease, + transform 0.2s ease; + + &.as-tile-hover { + cursor: pointer; + } + + &.as-tile-hover:hover { + background: var(--as-surface-2); + border-color: var(--as-border-strong); + transform: translateY(-1px); + } + } + + // The pulsing live dot. Phosphor green, animated. + .as-pulse { + display: inline-block; + flex-shrink: 0; + width: 8px; + height: 8px; + background: var(--as-live); + border-radius: 50%; + box-shadow: 0 0 0 0 var(--as-live-glow); + animation: AgentStack__Pulse 2.2s ease-out infinite; + } + + // Pill — terminal-style status pill (used instead of LemonTag here). + .as-pill { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 2px 8px; + font-family: var(--as-mono); + font-size: 10px; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; + border: 1px solid; + border-radius: 3px; + } + + .as-pill-live { + color: var(--as-live); + background: rgb(74 222 128 / 6%); + border-color: rgb(74 222 128 / 35%); + } + + .as-pill-preview { + color: var(--as-preview); + background: rgb(192 132 252 / 6%); + border-color: rgb(192 132 252 / 35%); + } + + .as-pill-warning { + color: var(--as-warning); + background: rgb(251 191 36 / 6%); + border-color: rgb(251 191 36 / 35%); + } + + .as-pill-danger { + color: var(--as-danger); + background: rgb(248 113 113 / 6%); + border-color: rgb(248 113 113 / 35%); + } + + .as-pill-muted { + color: var(--as-text-muted); + background: rgb(255 255 255 / 2%); + border-color: var(--as-border-strong); + } + + // Card — the app card on the list page. + .as-card { + position: relative; + display: flex; + flex-direction: column; + gap: 14px; + min-height: 180px; + padding: 18px 20px; + overflow: hidden; + cursor: pointer; + background: linear-gradient(180deg, var(--as-surface-1) 0%, var(--as-surface) 100%); + border: 1px solid var(--as-border); + border-radius: 8px; + transition: + border-color 0.2s ease, + transform 0.2s ease; + animation: AgentStack__CardIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards; + + &::before { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 100%; + content: ''; + background: var(--as-live); + opacity: 0; + transition: opacity 0.2s ease; + } + + &:hover { + border-color: var(--as-border-strong); + transform: translateY(-2px); + } + + &.as-card-live:hover::before { + opacity: 1; + } + } + + // Hero header + .as-hero { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 4px; + } + + .as-hero-title { + margin: 0; + font-family: var(--as-display); + font-size: 30px; + font-weight: 600; + line-height: 1.1; + color: var(--as-text-bright); + letter-spacing: -0.02em; + } + + .as-hero-subdomain { + font-family: var(--as-mono); + font-size: 13px; + color: var(--as-text-muted); + } + + // Path breadcrumb — "// agents / standup-bot" + .as-breadcrumb { + font-family: var(--as-mono); + font-size: 11px; + color: var(--as-text-dim); + letter-spacing: 0.05em; + + a { + color: var(--as-text-muted); + text-decoration: none; + + &:hover { + color: var(--as-text); + } + } + } + + // Empty state + .as-empty { + padding: 60px 24px; + text-align: center; + background: var(--as-surface-1); + border: 1px dashed var(--as-border-strong); + border-radius: 8px; + } + + // Form inputs — override Lemon for the env textarea + slug input look. + .as-input, + .as-textarea { + width: 100%; + padding: 10px 12px; + font-family: var(--as-mono); + font-size: 13px; + color: var(--as-text); + background: var(--as-surface); + border: 1px solid var(--as-border-strong); + border-radius: 4px; + outline: none; + transition: + border-color 0.15s ease, + background 0.15s ease; + + &:focus { + background: var(--as-surface-1); + border-color: var(--as-accent); + } + + &::placeholder { + color: var(--as-text-dim); + } + } + + .as-textarea { + min-height: 200px; + line-height: 1.5; + resize: vertical; + } + + // The redacted env block — looks like a terminal output. + .as-env-readout { + padding: 12px 14px; + overflow-x: auto; + font-family: var(--as-mono); + font-size: 12.5px; + line-height: 1.6; + color: var(--as-text); + white-space: pre; + background: var(--as-surface); + border: 1px solid var(--as-border-strong); + border-left: 2px solid var(--as-live); + border-radius: 4px; + } + + // Sessions table — override LemonTable defaults inside the console. + .as-sessions { + overflow: hidden; + background: var(--as-surface-1); + border: 1px solid var(--as-border); + border-radius: 6px; + + .LemonTable { + background: transparent; + } + + .LemonTable__cell, + .LemonTable th { + font-family: var(--as-mono); + font-size: 12.5px; + color: var(--as-text) !important; + background: transparent !important; + border-color: var(--as-border) !important; + } + + .LemonTable th { + font-size: 10px; + font-weight: 500; + color: var(--as-text-muted) !important; + text-transform: uppercase; + letter-spacing: 0.15em; + } + } + + // Tabs row — operator-console feel. + .as-tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + border-bottom: 1px solid var(--as-border); + } + + .as-tab { + position: relative; + padding: 10px 18px; + font-family: var(--as-mono); + font-size: 11px; + font-weight: 500; + color: var(--as-text-muted); + text-transform: uppercase; + letter-spacing: 0.18em; + cursor: pointer; + background: transparent; + border: none; + transition: color 0.15s ease; + + &:hover { + color: var(--as-text); + } + + &.as-tab-active { + color: var(--as-live); + + &::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 1px; + content: ''; + background: var(--as-live); + } + } + } + + // Telemetry strip — small stats row across the top of detail page. + .as-telemetry { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1px; + overflow: hidden; + background: var(--as-border); + border: 1px solid var(--as-border); + border-radius: 6px; + + .as-telemetry-cell { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + background: var(--as-surface-1); + + .as-telemetry-label { + font-family: var(--as-mono); + font-size: 10px; + color: var(--as-text-muted); + text-transform: uppercase; + letter-spacing: 0.18em; + } + + .as-telemetry-value { + display: flex; + gap: 8px; + align-items: center; + font-family: var(--as-mono); + font-size: 22px; + font-weight: 500; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--as-text-bright); + } + + .as-telemetry-meta { + display: flex; + gap: 10px; + font-family: var(--as-mono); + font-size: 11px; + color: var(--as-text-muted); + } + } + } + + // Revision chip — short-hash + pill cluster on the overview. + .as-revision { + display: inline-flex; + gap: 8px; + align-items: center; + padding: 6px 10px; + font-family: var(--as-mono); + font-size: 11px; + color: var(--as-text-muted); + background: var(--as-surface-1); + border: 1px solid var(--as-border); + border-radius: 4px; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--as-border-strong); + } + + .as-revision-hash { + font-weight: 500; + color: var(--as-text); + } + } + + // Soft separator dot + .as-dot { + color: var(--as-text-dim); + } + + @keyframes AgentStack__Pulse { + 0% { + box-shadow: 0 0 0 0 var(--as-live-glow); + opacity: 1; + } + + 70% { + box-shadow: 0 0 0 12px transparent; + opacity: 0.92; + } + + 100% { + box-shadow: 0 0 0 0 transparent; + opacity: 1; + } + } + + @keyframes AgentStack__CardIn { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } +} diff --git a/products/agent_stack/frontend/agentApplicationLogic.ts b/products/agent_stack/frontend/agentApplicationLogic.ts new file mode 100644 index 000000000000..8eb731c815ca --- /dev/null +++ b/products/agent_stack/frontend/agentApplicationLogic.ts @@ -0,0 +1,179 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' + +import { lemonToast } from '@posthog/lemon-ui' + +import { teamLogic } from 'scenes/teamLogic' + +import type { agentApplicationLogicType } from './agentApplicationLogicType' +import { + agentApplicationsEnvUpdate, + agentApplicationsPartialUpdate, + agentApplicationsRetrieve, + agentApplicationsRevisionsList, + agentApplicationsSessionsList, +} from './generated/api' +import type { + AgentApplicationApi, + AgentApplicationRevisionApi, + AgentApplicationSessionApi, +} from './generated/api.schemas' + +export enum AgentApplicationTab { + Overview = 'overview', + Settings = 'settings', +} + +export interface AgentApplicationLogicProps { + slug: string +} + +export interface SettingsFormValues { + name: string + description: string + env: string +} + +export const agentApplicationLogic = kea([ + path(['products', 'agent_stack', 'frontend', 'agentApplicationLogic']), + props({} as AgentApplicationLogicProps), + key(({ slug }) => slug), + + connect(() => ({ + values: [teamLogic, ['currentProjectId']], + })), + + actions({ + setActiveTab: (tab: AgentApplicationTab) => ({ tab }), + setApplicationMissing: true, + }), + + reducers({ + activeTab: [ + AgentApplicationTab.Overview as AgentApplicationTab, + { + setActiveTab: (_, { tab }) => tab, + }, + ], + applicationMissing: [ + false, + { + setApplicationMissing: () => true, + }, + ], + }), + + loaders(({ props, values, actions }) => ({ + application: [ + null as AgentApplicationApi | null, + { + loadApplication: async () => { + try { + return await agentApplicationsRetrieve(String(values.currentProjectId), props.slug) + } catch { + actions.setApplicationMissing() + return null + } + }, + }, + ], + revisions: [ + [] as AgentApplicationRevisionApi[], + { + loadRevisions: async () => { + const response = await agentApplicationsRevisionsList( + String(values.currentProjectId), + props.slug, + {} + ) + return response.results + }, + }, + ], + sessions: [ + [] as AgentApplicationSessionApi[], + { + loadSessions: async () => { + const response = await agentApplicationsSessionsList( + String(values.currentProjectId), + props.slug, + {} + ) + return response.results + }, + }, + ], + })), + + selectors({ + liveRevision: [ + (s) => [s.revisions], + (revisions: AgentApplicationRevisionApi[]) => revisions.find((r) => r.deployment_status === 'live') ?? null, + ], + previewRevisions: [ + (s) => [s.revisions], + (revisions: AgentApplicationRevisionApi[]) => revisions.filter((r) => r.deployment_status === 'preview'), + ], + sessionStats: [ + (s) => [s.sessions], + (sessions: AgentApplicationSessionApi[]) => { + const stats = { total: sessions.length, running: 0, succeeded: 0, failed: 0 } + for (const session of sessions) { + if (session.state === 'running' || session.state === 'available') { + stats.running += 1 + } else if (session.state === 'completed') { + stats.succeeded += 1 + } else if (session.state === 'failed') { + stats.failed += 1 + } + } + return stats + }, + ], + }), + + forms(({ values, props, actions }) => ({ + settings: { + defaults: { name: '', description: '', env: '' } as SettingsFormValues, + errors: ({ name }) => ({ + name: !name?.trim() ? 'Name is required' : undefined, + }), + submit: async (payload, breakpoint) => { + const projectId = String(values.currentProjectId) + await agentApplicationsPartialUpdate(projectId, props.slug, { + name: payload.name, + description: payload.description, + }) + // Env upload is replace-only and write-only — the redacted display is + // never the source of truth, so we only PUT when the user typed something + // new into the replace textarea. + if (payload.env.trim().length > 0) { + await agentApplicationsEnvUpdate(projectId, props.slug, { env: payload.env }) + } + await breakpoint(1) + lemonToast.success('Settings saved') + actions.loadApplication() + actions.setSettingsValue('env', '') + }, + }, + })), + + listeners(({ actions }) => ({ + loadApplicationSuccess: ({ application }) => { + if (application) { + actions.setSettingsValues({ + name: application.name, + description: application.description || '', + env: '', + }) + } + }, + })), + + afterMount(({ actions }) => { + actions.loadApplication() + actions.loadRevisions() + actions.loadSessions() + }), +]) diff --git a/products/agent_stack/frontend/agentApplicationsLogic.ts b/products/agent_stack/frontend/agentApplicationsLogic.ts new file mode 100644 index 000000000000..aa0736d53aab --- /dev/null +++ b/products/agent_stack/frontend/agentApplicationsLogic.ts @@ -0,0 +1,29 @@ +import { afterMount, connect, kea, path } from 'kea' +import { loaders } from 'kea-loaders' + +import { teamLogic } from 'scenes/teamLogic' + +import type { agentApplicationsLogicType } from './agentApplicationsLogicType' +import { agentApplicationsList } from './generated/api' +import type { AgentApplicationApi } from './generated/api.schemas' + +export const agentApplicationsLogic = kea([ + path(['products', 'agent_stack', 'frontend', 'agentApplicationsLogic']), + connect(() => ({ + values: [teamLogic, ['currentProjectId']], + })), + loaders(({ values }) => ({ + applications: [ + [] as AgentApplicationApi[], + { + loadApplications: async () => { + const response = await agentApplicationsList(String(values.currentProjectId)) + return response.results + }, + }, + ], + })), + afterMount(({ actions }) => { + actions.loadApplications() + }), +]) diff --git a/products/agent_stack/frontend/components/AgentApplicationOverview.tsx b/products/agent_stack/frontend/components/AgentApplicationOverview.tsx new file mode 100644 index 000000000000..b55426556981 --- /dev/null +++ b/products/agent_stack/frontend/components/AgentApplicationOverview.tsx @@ -0,0 +1,185 @@ +import { useValues } from 'kea' + +import { IconCheckCircle, IconPlay, IconWarning } from '@posthog/icons' +import { LemonTable } from '@posthog/lemon-ui' + +import { TZLabel } from 'lib/components/TZLabel' + +import { agentApplicationLogic } from '../agentApplicationLogic' +import type { + AgentApplicationRevisionApi, + AgentApplicationSessionApi, + AgentApplicationSessionStateEnumApi, +} from '../generated/api.schemas' + +const REVISION_STATE_CLASS: Record = { + pending_upload: 'as-pill as-pill-muted', + uploaded: 'as-pill as-pill-muted', + validating: 'as-pill as-pill-warning', + ready: 'as-pill as-pill-live', + failed: 'as-pill as-pill-danger', +} + +const DEPLOYMENT_CLASS: Record = { + live: 'as-pill as-pill-live', + preview: 'as-pill as-pill-preview', + disabled: 'as-pill as-pill-muted', +} + +const SESSION_CLASS: Record = { + available: 'as-pill as-pill-muted', + running: 'as-pill as-pill-warning', + completed: 'as-pill as-pill-live', + failed: 'as-pill as-pill-danger', + canceled: 'as-pill as-pill-muted', +} + +const SESSION_ICON: Record = { + available: null, + running: , + completed: , + failed: , + canceled: null, +} + +function RevisionsStrip(): JSX.Element { + const { revisions, revisionsLoading } = useValues(agentApplicationLogic) + + if (revisionsLoading && revisions.length === 0) { + return ( +
+ syncing revisions… +
+ ) + } + + if (revisions.length === 0) { + return ( +
+ // no revisions deployed yet +
+ ) + } + + return ( +
+
+
▌ Recent revisions
+
+ showing {Math.min(revisions.length, 8)} of {revisions.length} +
+
+
+ {revisions.slice(0, 8).map((rev: AgentApplicationRevisionApi) => ( +
+ {rev.id.slice(0, 7)} + {rev.state} + {rev.deployment_status !== 'disabled' && ( + {rev.deployment_status} + )} + + + +
+ ))} +
+
+ ) +} + +function SessionsTable(): JSX.Element { + const { sessions, sessionsLoading } = useValues(agentApplicationLogic) + + return ( +
+
+
▌ Session activity
+ {!sessionsLoading && ( +
+ {sessions.length} {sessions.length === 1 ? 'event' : 'events'} +
+ )} +
+
+ + // no session activity yet +
+ } + columns={[ + { + title: 'State', + width: 140, + render: (_, session: AgentApplicationSessionApi) => ( + + {SESSION_ICON[session.state]} + {session.state} + + ), + }, + { + title: 'Trigger', + render: (_, session: AgentApplicationSessionApi) => + session.trigger_type ? ( + + {session.trigger_type} + + ) : ( + – + ), + }, + { + title: 'Revision', + render: (_, session: AgentApplicationSessionApi) => ( + + {session.revision.slice(0, 7)} + + ), + }, + { + title: 'Heartbeat', + render: (_, session: AgentApplicationSessionApi) => + session.last_heartbeat_at ? ( + + + + ) : ( + – + ), + }, + { + title: 'Started', + render: (_, session: AgentApplicationSessionApi) => ( + + + + ), + }, + ]} + /> +
+
+ ) +} + +export function AgentApplicationOverview(): JSX.Element { + const { application } = useValues(agentApplicationLogic) + + return ( +
+ {application?.description && ( +

+ {application.description} +

+ )} + + +
+ // session detail view shipping next · use ass logs --follow meanwhile +
+
+ ) +} diff --git a/products/agent_stack/frontend/components/AgentApplicationSettings.tsx b/products/agent_stack/frontend/components/AgentApplicationSettings.tsx new file mode 100644 index 000000000000..4d948d111e1f --- /dev/null +++ b/products/agent_stack/frontend/components/AgentApplicationSettings.tsx @@ -0,0 +1,131 @@ +import { useActions, useValues } from 'kea' +import { Form } from 'kea-forms' + +import { IconLock, IconShieldLock } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { LemonField } from 'lib/lemon-ui/LemonField' + +import { agentApplicationLogic } from '../agentApplicationLogic' + +export function AgentApplicationSettings(): JSX.Element { + const { application, isSettingsSubmitting } = useValues(agentApplicationLogic) + const { resetSettings } = useActions(agentApplicationLogic) + + if (!application) { + return ( +
+ // loading… +
+ ) + } + + return ( +
+ {/* Left column — app metadata */} +
+
+
▌ Manifest
+

+ // display fields are mutable · slug is permanent +

+
+ + // Name}> + {({ value, onChange }) => ( + onChange(e.target.value)} + placeholder="standup-bot" + /> + )} + + + // Description}> + {({ value, onChange }) => ( +