From 32a16052af7e0f2fc8789c85b9ca31260f8f3e86 Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Wed, 19 Aug 2026 22:41:15 +0800 Subject: [PATCH 1/5] docs(product): define harness marketplace experiment --- docs/SPEC.md | 6 + docs/architecture.md | 6 + docs/prd/README.md | 1 + docs/prd/harness-marketplace-experiment.md | 167 +++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 docs/prd/harness-marketplace-experiment.md diff --git a/docs/SPEC.md b/docs/SPEC.md index bb4074ef..c9ae79d9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2,6 +2,12 @@ Status: canonical product contract for the current mosoo Alpha direction. +> **Experiment branch:** this branch evaluates a Run-first Harness Marketplace +> boundary. Its scoped contract, historical evidence, falsification criteria, +> and source impact map live in +> [Harness Marketplace Experiment](./prd/harness-marketplace-experiment.md). +> The experiment is not a production migration and must not be merged as-is. + This document defines what mosoo is building and the boundaries it guarantees. It supersedes older App-hosting, Deployable Repo, Release, Workspace, and Organization-governance language whenever they disagree. Current product notes and code remain the authority for what is shipped today. This Spec is deliberately narrower than a general-purpose application platform. Exact API schemas, manifest fields, quotas, and internal topology belong in implementation contracts. diff --git a/docs/architecture.md b/docs/architecture.md index fc550bf7..d7b2ffbf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,11 @@ # Managed Agent Runtime Architecture +> **Experiment branch:** the public boundary in this branch is Workspace- and +> Run-first. See the [Harness Marketplace Experiment](./prd/harness-marketplace-experiment.md) +> for the target topology, semantic delete/keep map, and the explicit Driver +> protocol compatibility boundary. Internal App and Agent-version names may +> remain while the branch tests the product thesis. + ## 1. Vision And Principles mosoo provides an App-scoped control plane for configuring, publishing, running, and observing coding Agents through the console and Public Thread API. diff --git a/docs/prd/README.md b/docs/prd/README.md index 47a54516..64388c39 100644 --- a/docs/prd/README.md +++ b/docs/prd/README.md @@ -11,6 +11,7 @@ their machine-readable contracts, code, or [Architecture](../architecture.md). ## Runtime and API +- [Harness Marketplace Experiment](./harness-marketplace-experiment.md) - [Agent API Endpoint](./agent-endpoint-mvp.md) - [Public Thread API](./public-thread-api-surface.md) - [Runtime Sessions](./runtime-session-kernel.md) diff --git a/docs/prd/harness-marketplace-experiment.md b/docs/prd/harness-marketplace-experiment.md new file mode 100644 index 00000000..72244735 --- /dev/null +++ b/docs/prd/harness-marketplace-experiment.md @@ -0,0 +1,167 @@ +# Harness Marketplace Experiment + +Status: branch-only product experiment for [GitHub issue #546](https://github.com/langgenius/mosoo/issues/546). Do not merge this experiment as a production migration. + +## Product hypothesis + +> One Workspace API key runs any supported agent harness inside a reproducible cloud environment. + +The first successful Run must not require an Agent, repository, Deployment, or framework. A saved, versioned Agent remains an optional reusable Run configuration. The public contract is Run-first: + +```ts +await mosoo.run({ + harness: "claude-code", + input: "Analyze these files", + environment: "mosoo/general", // optional; resolves to the Workspace default +}); + +await mosoo.run({ + agent: "ghfind/project-evaluator", + input: { repository: "langgenius/dify" }, +}); +``` + +`harness` and `agent` are an exclusive union. Supplying both or neither fails before a runtime resource is created. Both paths use the same Workspace-scoped API key and the same Run lifecycle. + +This experiment deliberately excludes billing, balances, exchange rates, routing, presets, guardrails, classifiers, framework packages, third-party Marketplace publishing, and advanced observability. + +## Historical evidence: what OpenRouter shipped in 2023 + +### Finding + +High confidence: the first public OpenRouter surface did not have an official OpenRouter SDK. It offered a familiar request shape through existing libraries, while OpenRouter performed the provider adaptation behind that boundary. + +- The official examples repository was created on [2023-07-14](https://github.com/OpenRouterTeam/openrouter-examples). Its first commit, [`621f197`](https://github.com/OpenRouterTeam/openrouter-examples/commit/621f1977c2a85348db7f6ccbd61c1d1dbf57a609), is titled `init with langchain script hitting openrouter`. That revision contains a LangChain.js `ChatOpenAI` example and switches models with provider-qualified names for OpenAI, Anthropic, Google, and Falcon. The repository has no 2023 tags or releases. +- The base URL was corrected minutes later in [`00a4eb4`](https://github.com/OpenRouterTeam/openrouter-examples/commit/00a4eb42b33b36fbd871dcfd01390dcb42aa11ea). On [2023-08-17](https://github.com/OpenRouterTeam/openrouter-examples/commit/d628fc09a227fe2f154cd211638e92cbdcc9c5b9), the official example added the `openai` npm package, configured `https://openrouter.ai/api/v1` as `baseURL`, selected a provider-qualified model, and demonstrated both Chat Completions and streaming. +- The official [TypeScript SDK](https://github.com/OpenRouterTeam/typescript-sdk) repository was created 2025-08-21, the [Python SDK](https://github.com/OpenRouterTeam/python-sdk) repository on 2025-08-22, and the [Go SDK](https://github.com/OpenRouterTeam/go-sdk) repository on 2025-11-13. Repository creation dates cannot prove that no private or deleted SDK existed, but together with the official 2023 examples and absence of tags/releases they are strong evidence that an OpenRouter-specific SDK was not the launch path. + +The precise answer is therefore: users could call native HTTP, but the earliest surviving official example used LangChain's OpenAI client abstraction; within one month the official examples explicitly used the OpenAI npm SDK with a changed base URL. `curl` was possible, but it is not the first surviving official example. + +### Why OpenAI compatibility reduced friction + +High confidence: the OpenAI-shaped boundary was important because it reused client APIs, streaming conventions, and developer knowledge that already existed. It did not mean the underlying providers were natively compatible. + +- OpenRouter's July example sent OpenAI-, Anthropic-, Google-, and Falcon-named models through one LangChain `ChatOpenAI` interface. That is direct evidence of OpenRouter-side normalization, not evidence that Anthropic or Google exposed OpenAI-native endpoints. +- The broader ecosystem was already copying the shape. LocalAI described itself as a “drop-in OpenAI API” in its [2023-04-19 rename commit](https://github.com/mudler/LocalAI/commit/80f50e6), and vLLM added an OpenAI-style Chat Completions endpoint on [2023-07-03](https://github.com/vllm-project/vllm/commit/49b26e2cec8c56594668905e853fe4af34336b05). Those are independent serving or aggregation layers, not native model-vendor protocols. + +The product lesson is not to clone Chat Completions. A stateful harness Run has approvals, files, side effects, environment state, and cancellation semantics that a stateless model response does not. The reusable lesson is to make one small, familiar client contract absorb adapter differences. + +## Current protocol comparison + +Evidence below was checked on 2026-08-19 against first-party documentation and repository contracts. Product surfaces can change; links identify the reviewed contract. + +| Concern | Claude Managed Agents | Claude Code / Agent SDK | OpenAI Codex CLI / app-server | OpenAI Codex cloud | Current mosoo harness path | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Create execution | A [Session](https://platform.claude.com/docs/en/managed-agents/sessions) requires an Agent and Environment | `claude -p` or SDK `query()` starts a local process/query | app-server `thread/start` then `turn/start`; the TS SDK starts a thread and spawns the CLI | A product task starts in a repository-preloaded cloud sandbox ([launch description](https://openai.com/index/introducing-codex/)) | `createAgentSession(agentId)` then enqueue a Session Run | +| Continue input | Add session events | Resume/continue a session ID or continue the SDK message iterator | `turn/start` on a thread, `turn/steer`, or resume thread | Product UI/integrations; no general managed-task API was found in the reviewed public docs | Send AG-UI events to the Session | +| Event stream | SSE session events, including per-subagent thread streams | `stream-json` or async SDK messages | Bidirectional JSON-RPC notifications over stdio, WebSocket, or Unix socket | Product task progress | AG-UI WebSocket fed by normalized Driver events | +| Approval | Confirmation events and responses | Permission modes, hooks, and permission callbacks | Server-initiated approval requests with client responses | Product approval UX | Normalized permission request/response events | +| Files/artifacts | Resources plus files written to the session outputs directory | Host working directory and local files; host decides persistence | Host working directory; file-change/diff items and optional output schema | Task sandbox and repository changes | Session attachments and recorded Session artifacts | +| Secrets | Vault IDs and managed environment inputs | Host process environment/configuration | Host process environment/configuration | Product-managed environment/repository configuration | Workspace Vault; short-lived, model-bound proxy grants keep provider keys out of Sandboxes | +| Environment | Managed Environment is required | Host owns cwd, packages, network, and process environment | Host owns cwd, sandbox mode, approval policy, and environment | Per-task cloud sandbox | Frozen Environment revision, package artifact, setup, env, and network policy | +| Cancel/recover | Interrupt and persistent Session operations | Host abort/interrupt plus session resume | `turn/interrupt`; thread resume/fork/archive | Product task controls | Interrupt event; control-plane Session history; Cattle Sandbox is recycled | +| Subagents | Explicit child-thread event streams | Runtime-native subagent messages are exposed by current SDKs | Collaboration/subagent items are runtime-specific events | Product-specific | No stable public subagent contract; events may be preserved as harness-specific detail | + +Primary implementation references: Claude's [Managed Agents overview](https://platform.claude.com/docs/en/managed-agents/overview), [events and streaming](https://platform.claude.com/docs/en/managed-agents/events-and-streaming), and [session operations](https://platform.claude.com/docs/en/managed-agents/session-operations); the [Claude Code CLI reference](https://docs.anthropic.com/en/docs/claude-code/cli-usage); Codex [app-server protocol](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) and [TypeScript SDK](https://github.com/openai/codex/blob/main/sdk/typescript/README.md); and mosoo's own Runtime Catalog and Session contracts. + +Confidence is high for the linked local/managed contracts. Confidence is medium for the negative claim about a general Codex cloud API: the reviewed official product and developer documentation exposed cloud tasks through product integrations, while the public SDK wraps the local CLI/app-server. An unpublished, private, or newly introduced API would falsify that claim. + +## Fragmentation: evidence, counterevidence, and boundary + +### Before + +An integrator selects a harness, learns its execution noun, builds its transport, maps its stream, implements its approval callback, decides how local files become durable artifacts, injects secrets in a harness-specific way, and separately models interrupt/resume. Switching harnesses changes application code and often changes infrastructure. + +The repository already demonstrates the adapter cost: OpenAI runtime uses app-server/SDK semantics, Claude uses the Agent SDK interface, and OpenCode uses ACP. The Driver normalizes them only after an Agent-bound Mosoo Session has been created. + +### After + +The caller keeps one Workspace key, one endpoint, one exclusive `harness | agent` source selector, and one Run state machine: + +```text +queued -> provisioning -> running -> waiting_for_approval -> terminal +``` + +Streaming events, approval responses, cancel, result, usage, and artifacts keep stable envelopes. The frozen Run source records the Harness/version, Environment revision, model, and either inline Run configuration or Agent-version provenance. + +### What can and cannot be unified + +The control plane can unify admission, identity, Workspace isolation, source snapshots, environment materialization, lifecycle states, event envelopes, approval response transport, cancellation intent, result/artifact references, and usage dimensions. + +It must not pretend that all native semantics are equal. Harness-native resume tokens, subagent topology, detailed tool events, checkpoint behavior, vendor permission modes, and filesystem side effects remain adapter-owned. The common envelope may carry typed extensions, but portable callers cannot depend on an extension. Cattle isolation means a follow-up Run receives platform history and declared resources, not an old process or hidden local machine state. + +### Counterevidence + +- Many users standardize on one harness and mainly want reliable remote execution. For them, a Marketplace selector adds discovery value but little switching value. +- Harnesses are differentiated by behavior and local ecosystem, not just protocol. A normalized envelope can hide capabilities users need. +- A model can often be swapped inside one harness. That may solve cost or quality choice without switching harness protocols. +- Cross-harness continuation is unsafe: native state and side effects are not portable even when text history is. + +The Marketplace thesis is therefore not accepted by implementation alone. It has to prove that users value selecting or switching executable harnesses, not merely “Claude Code in the cloud.” + +## Falsifiable experiment + +Recruit five target builders who already automate coding-agent work. Give each a new Workspace, BYOK instructions, one Workspace key, and the same Run client. Do not teach Agents during first use. + +The experiment succeeds only if all contract gates and the demand gate pass: + +1. At least four of five builders complete an agentless Run within ten minutes of opening the Workspace, without creating an Agent or supplying a repository. +2. At least four of five run the same task through both `claude-code` and `openai-codex` by changing only `harness`; their stream/result/approval handling code remains unchanged. +3. At least three of five voluntarily choose a second harness on a task of their own and identify a concrete reason such as capability, fallback, or comparison. This is the demand gate. One or fewer doing so falsifies the Marketplace wedge even if remote execution is useful. +4. Contract tests show identical lifecycle handling for success, provider failure, approval, cancel, and artifact publication across both harnesses; no Workspace can retrieve another Workspace's key, Run, credential, Agent, or Environment. +5. Every terminal Run exposes one frozen source snapshot and a basic usage record. An agentless Run creates no Agent row. Invalid `agent + harness` input allocates no runtime resource. + +If setup succeeds but the demand gate fails, narrow the product to a managed single-harness remote runtime. If users need adapter-specific events in their main path, shrink the common contract instead of silently discarding behavior. + +## Product and ownership model + +| Noun | Owns | Does not own | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Workspace | API keys, credentials, Agents, Environments, Connections, Runs, resources, usage | A running Sandbox | +| Harness version | Adapter identity, executable/backend version, capability declaration, supported models, credential and Environment requirements | User prompt, Workspace secret values, mutable Session state | +| Environment revision | Packages, setup, env names, network policy, reproducible artifact | Prompt, Skills, MCP, tools, output schema | +| Agent version | Optional reusable Harness/model defaults, instructions, Skills, MCP, tool permission policy, output schema, default Environment | Pet state or an always-online process | +| Run source snapshot | Harness version, model, Environment revision, source kind, Agent-version provenance or inline configuration | A mutable pointer that changes after admission | +| Run | Input, lifecycle, events, approvals, result, artifacts, usage, caller provenance | Cross-harness native-state portability | + +## Source impact map + +This map is intentionally semantic. Names containing `deployment`, `channel`, or `agent` are not bulk-deletion criteria. + +### Remove from the experiment product + +- App Deployment UI, GraphQL/API operations, build/publish executor, queues, Cloudflare Pages/Workers provisioning, deployment secrets, active schema exports, and current product tests. Historical migrations remain append-only evidence; the experiment does not rewrite them. +- Slack, Lark, Telegram, Discord, and WeChat Channel UI, GraphQL/API adapters, connection Durable Objects, delivery jobs/queues, credential adapters, active schema exports, and current product tests. Internal Session/Driver event delivery remains. +- Agent Type selection, Pet Sandbox choice, owner Terminal, reset/restart/hibernate controls, Agent online-state messaging, and per-Agent Channel settings. +- Public navigation and copy that present App, Deploy, Deployment URL, Channels, or long-lived Agent runtime state as product concepts. + +### Keep + +- Existing Agent records and immutable published configuration snapshots. A snapshot may still have an internal `deploymentVersionId` during the experiment; it is an Agent version, not App Deployment. +- Cattle's Session-scoped Sandbox subject, fresh provisioning, platform history restoration, current-message resources, artifact capture, Vault/BYOK proxying, usage ledger, and Run event/approval/cancel flow. +- Environments and immutable Environment revisions, Skills, MCP connections, provider credentials, file records/resources, Runs/Sessions, and `ghfind`-style Agent calls. +- Internal `appId`, App tables, and GraphQL ownership fields where renaming would be mechanical risk. The user-visible noun is Workspace. + +### Add or rename at the public boundary + +- Curated Harness Marketplace with stable slug, version, status, capabilities, supported models, required credentials, Environment requirements, and Quickstart. +- Workspace-scoped API keys. A key resolves its Workspace before input validation; Run requests do not repeat `workspaceId`. +- Exclusive `HarnessRunSource | AgentRunSource`, a frozen source snapshot, and a Run-first endpoint/client. +- Console information architecture: Marketplace globally; Home, API Keys, Runs, Agents, Environments, Connections, Usage, and Workspace Settings inside a Workspace. +- User-visible App copy becomes Workspace. Internal compatibility names remain documented rather than mechanically migrated. + +### Compatibility boundary + +The main repository can admit and freeze an agentless Run source, but the pinned Driver protocol currently requires `source.kind = "agent"` and an Agent ID. The protocol change is tracked in [mosoo-agent-driver #118](https://github.com/langgenius/mosoo-agent-driver/issues/118). This experiment must not copy harness startup logic into the API. Until a versioned Driver union lands, real agentless execution must fail explicitly at the Driver-admission boundary or use a clearly isolated compatibility adapter; it must never fabricate a user-visible Agent. + +## Preview acceptance boundary + +The branch is an executable product experiment, not a production migration. It may retain internal App-named storage and legacy migrations, but its public preview must: + +- open on “Choose a Harness and run,” +- allow an empty Workspace to issue a Workspace key and form an agentless Run, +- demonstrate two existing harness catalog entries behind the same request shape, +- keep saved Agents as optional reusable configuration, +- expose no Deployment, Channel, Pet Terminal, reset, or long-lived online-state surface, +- show frozen source, Environment revision, usage, results, and artifacts on a Run, +- identify the Driver protocol gate honestly wherever a real provider-backed completion cannot yet cross it. From 072aa372f92cb612570cfcdd3dff95e4dbebfc6f Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Wed, 19 Aug 2026 22:53:06 +0800 Subject: [PATCH 2/5] feat(harness): add workspace keys and catalog --- apps/api/src/adapters/http/create-http-app.ts | 2 + .../http/routes/workspace-api-key-route.ts | 95 + .../application/workspace-api-key.service.ts | 224 + apps/api/src/platform/db/drizzle.ts | 2 + .../helpers/public-api-http-core-schema.sql | 12 + apps/api/tests/workspace-api-key.test.ts | 103 + pkgs/contracts/package.json | 1 + pkgs/contracts/src/auth/auth.contract.ts | 24 +- .../contracts/src/harness/harness.contract.ts | 98 + pkgs/contracts/src/id/id.contract.ts | 1 + pkgs/contracts/src/index.ts | 1 + pkgs/db/drizzle/0012_workspace-api-keys.sql | 15 + pkgs/db/drizzle/meta/0012_snapshot.json | 7660 +++++++++++++++++ pkgs/db/drizzle/meta/_journal.json | 7 + pkgs/db/src/schema/auth.schema.ts | 30 +- pkgs/id/src/index.ts | 1 + pkgs/runtime-catalog/src/harness-catalog.ts | 99 + pkgs/runtime-catalog/src/index.ts | 1 + .../tests/harness-catalog.test.ts | 26 + 19 files changed, 8400 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/adapters/http/routes/workspace-api-key-route.ts create mode 100644 apps/api/src/modules/auth/application/workspace-api-key.service.ts create mode 100644 apps/api/tests/workspace-api-key.test.ts create mode 100644 pkgs/contracts/src/harness/harness.contract.ts create mode 100644 pkgs/db/drizzle/0012_workspace-api-keys.sql create mode 100644 pkgs/db/drizzle/meta/0012_snapshot.json create mode 100644 pkgs/runtime-catalog/src/harness-catalog.ts create mode 100644 pkgs/runtime-catalog/tests/harness-catalog.test.ts diff --git a/apps/api/src/adapters/http/create-http-app.ts b/apps/api/src/adapters/http/create-http-app.ts index 6c0d5631..9ecd581c 100644 --- a/apps/api/src/adapters/http/create-http-app.ts +++ b/apps/api/src/adapters/http/create-http-app.ts @@ -25,6 +25,7 @@ import { registerRootRoute } from "./routes/root-route"; import { registerSkillRoute } from "./routes/skill-route"; import { registerSlackEventsRoute } from "./routes/slack-events-route"; import { registerTelegramEventsRoute } from "./routes/telegram-events-route"; +import { registerWorkspaceApiKeyRoute } from "./routes/workspace-api-key-route"; export function createHttpApp() { const app = new Hono(); @@ -42,6 +43,7 @@ export function createHttpApp() { registerRootRoute(app); registerHealthRoute(publicApi); registerAccessTokenRoute(publicApi); + registerWorkspaceApiKeyRoute(publicApi); registerAuthRoute(publicApi); registerFileRoute(publicApi); registerMcpRoute(publicApi); diff --git a/apps/api/src/adapters/http/routes/workspace-api-key-route.ts b/apps/api/src/adapters/http/routes/workspace-api-key-route.ts new file mode 100644 index 00000000..6882963c --- /dev/null +++ b/apps/api/src/adapters/http/routes/workspace-api-key-route.ts @@ -0,0 +1,95 @@ +import type { CreateWorkspaceApiKeyRequest } from "@mosoo/contracts/auth"; +import { parsePlatformId } from "@mosoo/id"; +import type { AppId, WorkspaceApiKeyId } from "@mosoo/id"; +import type { Hono } from "hono"; + +import { getAuthenticatedViewerFromRequest } from "../../../modules/auth/application/viewer-auth.service"; +import { + createWorkspaceApiKey, + listWorkspaceApiKeys, + revokeWorkspaceApiKey, +} from "../../../modules/auth/application/workspace-api-key.service"; +import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; +import { toApiErrorResponseDetails, validationError } from "../../../platform/errors"; + +function errorResponse(error: unknown): Response { + const details = toApiErrorResponseDetails(error, { + message: "Workspace API key request failed.", + }); + return Response.json({ error: details.message }, { status: details.status }); +} + +function parseWorkspaceId(value: string): AppId { + return parsePlatformId(value, "Workspace ID"); +} + +async function readCreateRequest(request: Request): Promise { + const body: unknown = await request.json().catch(() => { + throw validationError("Request body must be valid JSON."); + }); + + if ( + typeof body !== "object" || + body === null || + !("label" in body) || + typeof body.label !== "string" + ) { + throw validationError("API key label is required."); + } + + return { label: body.label }; +} + +export function registerWorkspaceApiKeyRoute(app: Hono): void { + app.get("/workspaces/:workspaceId/api-keys", async (c) => { + try { + const viewer = await getAuthenticatedViewerFromRequest(c.env, c.req.raw); + if (viewer === null) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + return Response.json( + await listWorkspaceApiKeys(c.env.DB, viewer, parseWorkspaceId(c.req.param("workspaceId"))), + ); + } catch (error) { + return errorResponse(error); + } + }); + + app.post("/workspaces/:workspaceId/api-keys", async (c) => { + try { + const viewer = await getAuthenticatedViewerFromRequest(c.env, c.req.raw); + if (viewer === null) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await readCreateRequest(c.req.raw); + return Response.json( + await createWorkspaceApiKey(c.env.DB, viewer, { + label: body.label, + workspaceId: parseWorkspaceId(c.req.param("workspaceId")), + }), + { status: 201 }, + ); + } catch (error) { + return errorResponse(error); + } + }); + + app.delete("/workspaces/:workspaceId/api-keys/:keyId", async (c) => { + try { + const viewer = await getAuthenticatedViewerFromRequest(c.env, c.req.raw); + if (viewer === null) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + await revokeWorkspaceApiKey(c.env.DB, viewer, { + keyId: parsePlatformId(c.req.param("keyId"), "Workspace API key ID"), + workspaceId: parseWorkspaceId(c.req.param("workspaceId")), + }); + return Response.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } + }); +} diff --git a/apps/api/src/modules/auth/application/workspace-api-key.service.ts b/apps/api/src/modules/auth/application/workspace-api-key.service.ts new file mode 100644 index 00000000..4ff20e7e --- /dev/null +++ b/apps/api/src/modules/auth/application/workspace-api-key.service.ts @@ -0,0 +1,224 @@ +import type { + CreateWorkspaceApiKeyResponse, + WorkspaceApiKeyListResponse, + WorkspaceApiKeySummary, +} from "@mosoo/contracts/auth"; +import { accountsTable, workspaceApiKeysTable } from "@mosoo/db"; +import { createPlatformId } from "@mosoo/id"; +import type { AccountId, AppId, WorkspaceApiKeyId } from "@mosoo/id"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; + +import { getAppDatabase } from "../../../platform/db/drizzle"; +import { toBase64Url } from "../../../shared/bytes"; +import { currentTimestampMs, toIsoString } from "../../../time"; +import { ensureAppOwnership } from "../../apps/application/app.service"; +import { hashTokenValue, readBearerToken } from "./personal-access-token.service"; +import type { AuthenticatedViewer } from "./viewer-auth.service"; + +const WORKSPACE_API_KEY_PREFIX = "msk_"; +const TOKEN_SECRET_BYTE_LENGTH = 32; +const MAX_LABEL_LENGTH = 80; + +interface WorkspaceApiKeyListRow { + created_at: number; + id: WorkspaceApiKeyId; + label: string; + last_used_at: number | null; + revoked_at: number | null; + workspace_id: AppId; +} + +export interface WorkspaceApiKeyCaller { + keyId: WorkspaceApiKeyId; + keyLabel: string; + viewer: AuthenticatedViewer; + workspaceId: AppId; +} + +function normalizeLabel(label: string): string { + const normalized = label.trim(); + + if (normalized.length === 0) { + throw new Error("API key label is required."); + } + + if (normalized.length > MAX_LABEL_LENGTH) { + throw new Error(`API key label must be ${MAX_LABEL_LENGTH} characters or fewer.`); + } + + return normalized; +} + +function createKeyValue(): string { + const bytes = new Uint8Array(TOKEN_SECRET_BYTE_LENGTH); + crypto.getRandomValues(bytes); + return `${WORKSPACE_API_KEY_PREFIX}${toBase64Url(bytes)}`; +} + +function toSummary(row: WorkspaceApiKeyListRow): WorkspaceApiKeySummary { + return { + createdAt: toIsoString(row.created_at), + id: row.id, + label: row.label, + lastUsedAt: row.last_used_at === null ? null : toIsoString(row.last_used_at), + revokedAt: row.revoked_at === null ? null : toIsoString(row.revoked_at), + workspaceId: row.workspace_id, + }; +} + +export function isWorkspaceApiKeyValue(value: string): boolean { + return value.startsWith(WORKSPACE_API_KEY_PREFIX); +} + +export async function listWorkspaceApiKeys( + database: D1Database, + viewer: AuthenticatedViewer, + workspaceId: AppId, +): Promise { + await ensureAppOwnership(database, viewer.id, workspaceId); + const rows = await getAppDatabase(database) + .select({ + created_at: sql`${workspaceApiKeysTable.createdAt}`, + id: workspaceApiKeysTable.id, + label: workspaceApiKeysTable.label, + last_used_at: sql`${workspaceApiKeysTable.lastUsedAt}`, + revoked_at: sql`${workspaceApiKeysTable.revokedAt}`, + workspace_id: workspaceApiKeysTable.appId, + }) + .from(workspaceApiKeysTable) + .where( + and( + eq(workspaceApiKeysTable.accountId, viewer.id), + eq(workspaceApiKeysTable.appId, workspaceId), + isNull(workspaceApiKeysTable.revokedAt), + ), + ) + .orderBy(desc(workspaceApiKeysTable.createdAt)) + .all(); + + return { keys: rows.map(toSummary) }; +} + +export async function createWorkspaceApiKey( + database: D1Database, + viewer: AuthenticatedViewer, + input: { label: string; workspaceId: AppId }, +): Promise { + await ensureAppOwnership(database, viewer.id, input.workspaceId); + const label = normalizeLabel(input.label); + const value = createKeyValue(); + const tokenHash = await hashTokenValue(value); + const timestampMs = currentTimestampMs(); + const keyId = createPlatformId(); + + await getAppDatabase(database) + .insert(workspaceApiKeysTable) + .values({ + accountId: viewer.id, + createdAt: sql`${timestampMs}`, + id: keyId, + label, + lastUsedAt: null, + revokedAt: null, + appId: input.workspaceId, + tokenHash, + updatedAt: sql`${timestampMs}`, + }) + .run(); + + return { + key: toSummary({ + created_at: timestampMs, + id: keyId, + label, + last_used_at: null, + revoked_at: null, + workspace_id: input.workspaceId, + }), + value, + }; +} + +export async function revokeWorkspaceApiKey( + database: D1Database, + viewer: AuthenticatedViewer, + input: { keyId: WorkspaceApiKeyId; workspaceId: AppId }, +): Promise { + await ensureAppOwnership(database, viewer.id, input.workspaceId); + const timestampMs = currentTimestampMs(); + await getAppDatabase(database) + .update(workspaceApiKeysTable) + .set({ + revokedAt: sql`COALESCE(${workspaceApiKeysTable.revokedAt}, ${timestampMs})`, + updatedAt: sql`${timestampMs}`, + }) + .where( + and( + eq(workspaceApiKeysTable.id, input.keyId), + eq(workspaceApiKeysTable.accountId, viewer.id), + eq(workspaceApiKeysTable.appId, input.workspaceId), + ), + ) + .run(); +} + +export async function authenticateWorkspaceApiKey( + database: D1Database, + value: string, +): Promise { + if (!isWorkspaceApiKeyValue(value)) { + return null; + } + + const tokenHash = await hashTokenValue(value); + const row = + (await getAppDatabase(database) + .select({ + account_email: accountsTable.email, + account_email_verified: sql`${accountsTable.emailVerified}`, + account_id: sql`${accountsTable.id}`.as("account_id"), + account_image_url: accountsTable.image, + account_name: accountsTable.name, + id: workspaceApiKeysTable.id, + label: workspaceApiKeysTable.label, + workspace_id: workspaceApiKeysTable.appId, + }) + .from(workspaceApiKeysTable) + .innerJoin(accountsTable, eq(accountsTable.id, workspaceApiKeysTable.accountId)) + .where( + and( + eq(workspaceApiKeysTable.tokenHash, tokenHash), + isNull(workspaceApiKeysTable.revokedAt), + ), + ) + .limit(1) + .get()) ?? null; + + if (row === null) { + return null; + } + + await getAppDatabase(database) + .update(workspaceApiKeysTable) + .set({ lastUsedAt: sql`${currentTimestampMs()}` }) + .where(eq(workspaceApiKeysTable.id, row.id)) + .run(); + + return { + keyId: row.id, + keyLabel: row.label, + viewer: { + email: row.account_email, + emailVerified: row.account_email_verified === 1, + id: row.account_id, + imageUrl: row.account_image_url, + name: row.account_name, + }, + workspaceId: row.workspace_id, + }; +} + +export function readWorkspaceApiKey(request: Request): string | null { + const value = readBearerToken(request); + return value !== null && isWorkspaceApiKeyValue(value) ? value : null; +} diff --git a/apps/api/src/platform/db/drizzle.ts b/apps/api/src/platform/db/drizzle.ts index 6b38cc7f..4b8590bc 100644 --- a/apps/api/src/platform/db/drizzle.ts +++ b/apps/api/src/platform/db/drizzle.ts @@ -31,6 +31,7 @@ import { nativeResumeRefsTable, organizationsTable, personalAccessTokensTable, + workspaceApiKeysTable, appsTable, publicApiIdempotencyKeysTable, publicApiRateLimitWindowsTable, @@ -94,6 +95,7 @@ const schema = { nativeResumeRefsTable, organizationsTable, personalAccessTokensTable, + workspaceApiKeysTable, appsTable, publicApiIdempotencyKeysTable, publicApiRateLimitWindowsTable, diff --git a/apps/api/tests/helpers/public-api-http-core-schema.sql b/apps/api/tests/helpers/public-api-http-core-schema.sql index 5859906b..e57a0930 100644 --- a/apps/api/tests/helpers/public-api-http-core-schema.sql +++ b/apps/api/tests/helpers/public-api-http-core-schema.sql @@ -21,6 +21,18 @@ CREATE TABLE personal_access_token ( revoked_at integer ); +CREATE TABLE workspace_api_key ( + id text PRIMARY KEY NOT NULL, + account_id text NOT NULL, + app_id text NOT NULL, + label text NOT NULL, + token_hash text NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + last_used_at integer, + revoked_at integer +); + CREATE TABLE organization ( id text PRIMARY KEY NOT NULL, name text NOT NULL, diff --git a/apps/api/tests/workspace-api-key.test.ts b/apps/api/tests/workspace-api-key.test.ts new file mode 100644 index 00000000..e22f8b27 --- /dev/null +++ b/apps/api/tests/workspace-api-key.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; + +import { PUBLIC_API_PREFIX } from "@mosoo/contracts/public-api"; +import type { WorkspaceApiKeyId } from "@mosoo/id"; + +import { createHttpApp } from "../src/adapters/http/create-http-app"; +import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; +import { + authenticateWorkspaceApiKey, + createWorkspaceApiKey, + listWorkspaceApiKeys, + revokeWorkspaceApiKey, +} from "../src/modules/auth/application/workspace-api-key.service"; +import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; +import { + createPublicHttpContractDatabase, + createPublicHttpTestBindings, + PUBLIC_API_TEST_IDS, + TOKENS, +} from "./helpers/public-api-http-test-fixture"; + +const OWNER: AuthenticatedViewer = { + email: "owner@example.com", + emailVerified: true, + id: PUBLIC_API_TEST_IDS.ownerAccount, + imageUrl: null, + name: "Owner", +}; + +describe("Workspace API keys", () => { + test("creates a key through the active Workspace route", async () => { + const database = await createPublicHttpContractDatabase(); + const response = await createHttpApp().request( + `${PUBLIC_API_PREFIX}/workspaces/${PUBLIC_API_TEST_IDS.app}/api-keys`, + { + body: JSON.stringify({ label: "Production Run key" }), + headers: { + authorization: `Bearer ${TOKENS.owner}`, + "content-type": "application/json", + }, + method: "POST", + }, + createPublicHttpTestBindings(database) as ApiBindings, + ); + + expect(response.status).toBe(201); + const body = await response.json<{ + key: { label: string; workspaceId: string }; + value: string; + }>(); + expect(body.key).toMatchObject({ + label: "Production Run key", + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + expect(body.value).toStartWith("msk_"); + + const caller = await authenticateWorkspaceApiKey(database, body.value); + expect(caller).toMatchObject({ + viewer: { id: PUBLIC_API_TEST_IDS.ownerAccount }, + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + }); + + test("isolates list and revoke operations to the bound Workspace", async () => { + const database = await createPublicHttpContractDatabase(); + const created = await createWorkspaceApiKey(database, OWNER, { + label: "Experiment", + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + + expect(await listWorkspaceApiKeys(database, OWNER, PUBLIC_API_TEST_IDS.app)).toMatchObject({ + keys: [{ id: created.key.id, workspaceId: PUBLIC_API_TEST_IDS.app }], + }); + + await revokeWorkspaceApiKey(database, OWNER, { + keyId: created.key.id as WorkspaceApiKeyId, + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + + expect(await authenticateWorkspaceApiKey(database, created.value)).toBeNull(); + expect(await listWorkspaceApiKeys(database, OWNER, PUBLIC_API_TEST_IDS.app)).toEqual({ + keys: [], + }); + }); + + test("does not let another account mint a key for the Workspace", async () => { + const database = await createPublicHttpContractDatabase(); + const response = await createHttpApp().request( + `${PUBLIC_API_PREFIX}/workspaces/${PUBLIC_API_TEST_IDS.app}/api-keys`, + { + body: JSON.stringify({ label: "Cross-tenant key" }), + headers: { + authorization: `Bearer ${TOKENS.nonOwner}`, + "content-type": "application/json", + }, + method: "POST", + }, + createPublicHttpTestBindings(database) as ApiBindings, + ); + + expect(response.status).toBe(403); + }); +}); diff --git a/pkgs/contracts/package.json b/pkgs/contracts/package.json index bc10eb15..8e46ca21 100644 --- a/pkgs/contracts/package.json +++ b/pkgs/contracts/package.json @@ -17,6 +17,7 @@ "./external-tool-effect": "./src/runtime/external-tool-effect.contract.ts", "./environment": "./src/environment/environment.contract.ts", "./file": "./src/file/file.contract.ts", + "./harness": "./src/harness/harness.contract.ts", "./mcp": "./src/mcp/mcp.contract.ts", "./models": "./models/index.ts", "./operation-result": "./src/transport/operation-result.contract.ts", diff --git a/pkgs/contracts/src/auth/auth.contract.ts b/pkgs/contracts/src/auth/auth.contract.ts index 00180691..ffcaa6e2 100644 --- a/pkgs/contracts/src/auth/auth.contract.ts +++ b/pkgs/contracts/src/auth/auth.contract.ts @@ -1,4 +1,4 @@ -import type { PersonalAccessTokenId } from "../id/id.contract"; +import type { PersonalAccessTokenId, AppId, WorkspaceApiKeyId } from "../id/id.contract"; export type AuthMethod = "email_otp" | "google_oauth"; @@ -25,6 +25,28 @@ export interface PersonalAccessTokenListResponse { tokens: PersonalAccessTokenSummary[]; } +export interface WorkspaceApiKeySummary { + createdAt: string; + id: WorkspaceApiKeyId; + label: string; + lastUsedAt: string | null; + revokedAt: string | null; + workspaceId: AppId; +} + +export interface CreateWorkspaceApiKeyRequest { + label: string; +} + +export interface CreateWorkspaceApiKeyResponse { + key: WorkspaceApiKeySummary; + value: string; +} + +export interface WorkspaceApiKeyListResponse { + keys: WorkspaceApiKeySummary[]; +} + export type CliOAuthDeviceStatus = "pending" | "authorized" | "consumed" | "denied" | "expired"; export interface CliOAuthDeviceStartRequest { diff --git a/pkgs/contracts/src/harness/harness.contract.ts b/pkgs/contracts/src/harness/harness.contract.ts new file mode 100644 index 00000000..d49c674c --- /dev/null +++ b/pkgs/contracts/src/harness/harness.contract.ts @@ -0,0 +1,98 @@ +import type { AppId } from "../id/id.contract"; +import type { JsonObject, JsonValue } from "../validation/primitives.contract"; + +export const HARNESS_SLUGS = ["claude-code", "openai-codex", "opencode"] as const; +export type HarnessSlug = (typeof HARNESS_SLUGS)[number]; + +export type HarnessAvailability = "available" | "unavailable"; +export type HarnessCapabilityStatus = "native" | "normalized" | "unsupported"; + +export interface HarnessCapabilitySet { + approve: HarnessCapabilityStatus; + artifacts: HarnessCapabilityStatus; + cancel: HarnessCapabilityStatus; + resume: HarnessCapabilityStatus; + stream: HarnessCapabilityStatus; + subagents: HarnessCapabilityStatus; +} + +export interface HarnessCatalogEntry { + capabilities: HarnessCapabilitySet; + defaultModel: string; + description: string; + environment: { + default: "workspace"; + repositoryRequired: false; + }; + label: string; + quickstart: string; + requiredCredentials: readonly string[]; + runtimeId: string; + slug: HarnessSlug; + status: HarnessAvailability; + supportedModels: readonly string[]; + version: string; +} + +export interface HarnessRunSourceInput { + agent?: never; + environment?: string; + harness: HarnessSlug; + model?: string; +} + +export interface AgentRunSourceInput { + agent: string; + environment?: never; + harness?: never; + model?: never; +} + +export type RunSourceInput = AgentRunSourceInput | HarnessRunSourceInput; + +export type RunInput = JsonObject | JsonValue[] | boolean | number | string | null; + +export type CreateWorkspaceRunRequest = RunSourceInput & { + input: RunInput; +}; + +export interface AgentRunSourceSnapshot { + agentId: string; + agentVersionId: string | null; + agentVersionNumber: number | null; + kind: "agent"; +} + +export interface HarnessRunSourceSnapshot { + harness: HarnessSlug; + kind: "harness"; + version: string; +} + +export type RunSourceSnapshot = AgentRunSourceSnapshot | HarnessRunSourceSnapshot; + +export interface WorkspaceRunEnvironmentSnapshot { + id: string; + name: string; + revisionId: string; +} + +export interface WorkspaceRunLinks { + approve: string; + artifacts: string; + cancel: string; + events: string; + result: string; + stream: string; +} + +export interface WorkspaceRunResponse { + environment: WorkspaceRunEnvironmentSnapshot; + id: string; + links: WorkspaceRunLinks; + model: string; + source: RunSourceSnapshot; + status: string; + threadId: string; + workspaceId: AppId; +} diff --git a/pkgs/contracts/src/id/id.contract.ts b/pkgs/contracts/src/id/id.contract.ts index d2d5f067..df0f33ab 100644 --- a/pkgs/contracts/src/id/id.contract.ts +++ b/pkgs/contracts/src/id/id.contract.ts @@ -35,4 +35,5 @@ export type { SkillSnapshotId, UploadId, VendorCredentialId, + WorkspaceApiKeyId, } from "@mosoo/id"; diff --git a/pkgs/contracts/src/index.ts b/pkgs/contracts/src/index.ts index 9bc6b653..30bcc12f 100644 --- a/pkgs/contracts/src/index.ts +++ b/pkgs/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./agent/agent-manifest-serializer.contract"; export type * from "./auth/auth.contract"; export * from "./channel/channel.contract"; export type * from "./environment/environment.contract"; +export * from "./harness/harness.contract"; export * from "./file/file.contract"; export type * from "./mcp/mcp.contract"; export type * from "./metadata/app-info.contract"; diff --git a/pkgs/db/drizzle/0012_workspace-api-keys.sql b/pkgs/db/drizzle/0012_workspace-api-keys.sql new file mode 100644 index 00000000..f804509c --- /dev/null +++ b/pkgs/db/drizzle/0012_workspace-api-keys.sql @@ -0,0 +1,15 @@ +CREATE TABLE `workspace_api_key` ( + `account_id` text CHECK ("account_id" = upper("account_id") AND length("account_id") = 26 AND substr("account_id", 1, 1) GLOB '[0-7]' AND "account_id" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*') NOT NULL, + `created_at` integer NOT NULL, + `id` text CHECK ("id" = upper("id") AND length("id") = 26 AND substr("id", 1, 1) GLOB '[0-7]' AND "id" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*') PRIMARY KEY NOT NULL, + `label` text NOT NULL, + `last_used_at` integer, + `revoked_at` integer, + `app_id` text CHECK ("app_id" = upper("app_id") AND length("app_id") = 26 AND substr("app_id", 1, 1) GLOB '[0-7]' AND "app_id" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*') NOT NULL, + `token_hash` text NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `workspace_api_key_app_created_idx` ON `workspace_api_key` (`app_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `workspace_api_key_account_app_idx` ON `workspace_api_key` (`account_id`,`app_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `workspace_api_key_hash_idx` ON `workspace_api_key` (`token_hash`); diff --git a/pkgs/db/drizzle/meta/0012_snapshot.json b/pkgs/db/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..dfe0916e --- /dev/null +++ b/pkgs/db/drizzle/meta/0012_snapshot.json @@ -0,0 +1,7660 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "102e9c63-4d72-4381-82a6-a433095a7137", + "prevId": "c62b2b37-8e6b-4ae6-bd80-186a6e314043", + "tables": { + "agent_deployment_version": { + "name": "agent_deployment_version", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text CHECK (\"environment_id\" = upper(\"environment_id\") AND length(\"environment_id\") = 26 AND substr(\"environment_id\", 1, 1) GLOB '[0-7]' AND \"environment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mcp_bindings_json": { + "name": "mcp_bindings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skills_json": { + "name": "skills_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_deployment_version_agent_number_idx": { + "name": "agent_deployment_version_agent_number_idx", + "columns": ["agent_id", "version_number"], + "isUnique": true + }, + "agent_deployment_version_agent_created_idx": { + "name": "agent_deployment_version_agent_created_idx", + "columns": ["agent_id", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_mcp_binding": { + "name": "agent_mcp_binding", + "columns": { + "agent_credential_id": { + "name": "agent_credential_id", + "type": "text CHECK (\"agent_credential_id\" = upper(\"agent_credential_id\") AND length(\"agent_credential_id\") = 26 AND substr(\"agent_credential_id\", 1, 1) GLOB '[0-7]' AND \"agent_credential_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_mode": { + "name": "credential_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'runtime_resolved'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text CHECK (\"server_id\" = upper(\"server_id\") AND length(\"server_id\") = 26 AND substr(\"server_id\", 1, 1) GLOB '[0-7]' AND \"server_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_mcp_binding_agent_sort_idx": { + "name": "agent_mcp_binding_agent_sort_idx", + "columns": ["agent_id", "sort_order"], + "isUnique": true + }, + "agent_mcp_binding_server_idx": { + "name": "agent_mcp_binding_server_idx", + "columns": ["server_id"], + "isUnique": false + }, + "agent_mcp_binding_profile_server_idx": { + "name": "agent_mcp_binding_profile_server_idx", + "columns": ["agent_id", "server_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "agent_mcp_binding_agent_credential_shape_check": { + "name": "agent_mcp_binding_agent_credential_shape_check", + "value": "\n (\"agent_mcp_binding\".\"credential_mode\" = 'agent_bound' AND \"agent_mcp_binding\".\"agent_credential_id\" IS NOT NULL)\n OR (\"agent_mcp_binding\".\"credential_mode\" = 'runtime_resolved' AND \"agent_mcp_binding\".\"agent_credential_id\" IS NULL)\n " + } + } + }, + "agent_skill": { + "name": "agent_skill", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text CHECK (\"skill_id\" = upper(\"skill_id\") AND length(\"skill_id\") = 26 AND substr(\"skill_id\", 1, 1) GLOB '[0-7]' AND \"skill_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_skill_agent_sort_idx": { + "name": "agent_skill_agent_sort_idx", + "columns": ["agent_id", "sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_skill_agent_id_skill_id_pk": { + "columns": ["agent_id", "skill_id"], + "name": "agent_skill_agent_id_skill_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent": { + "name": "agent", + "columns": { + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text CHECK (\"environment_id\" = upper(\"environment_id\") AND length(\"environment_id\") = 26 AND substr(\"environment_id\", 1, 1) GLOB '[0-7]' AND \"environment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pet'" + }, + "live_deployment_version_id": { + "name": "live_deployment_version_id", + "type": "text CHECK (\"live_deployment_version_id\" = upper(\"live_deployment_version_id\") AND length(\"live_deployment_version_id\") = 26 AND substr(\"live_deployment_version_id\", 1, 1) GLOB '[0-7]' AND \"live_deployment_version_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + } + }, + "indexes": { + "agent_app_owner_account_idx": { + "name": "agent_app_owner_account_idx", + "columns": ["app_id", "owner_account_id"], + "isUnique": false + }, + "agent_app_status_idx": { + "name": "agent_app_status_idx", + "columns": ["app_id", "status"], + "isUnique": false + }, + "agent_environment_idx": { + "name": "agent_environment_idx", + "columns": ["environment_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "agent_published_live_deployment_version_check": { + "name": "agent_published_live_deployment_version_check", + "value": "\"agent\".\"status\" <> 'published' OR \"agent\".\"live_deployment_version_id\" IS NOT NULL" + } + } + }, + "api_command": { + "name": "api_command", + "columns": { + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_command_dedupe_idx": { + "name": "api_command_dedupe_idx", + "columns": ["dedupe_key"], + "isUnique": true + }, + "api_command_status_updated_idx": { + "name": "api_command_status_updated_idx", + "columns": ["status", "updated_at"], + "isUnique": false + }, + "api_command_claim_idx": { + "name": "api_command_claim_idx", + "columns": ["status", "claim_expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_account": { + "name": "auth_account", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_account_provider_account_idx": { + "name": "auth_account_provider_account_idx", + "columns": ["provider_id", "provider_account_id"], + "isUnique": true + }, + "auth_account_account_id_idx": { + "name": "auth_account_account_id_idx", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_session": { + "name": "auth_session", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_session_expires_at_idx": { + "name": "auth_session_expires_at_idx", + "columns": ["expires_at"], + "isUnique": false + }, + "auth_session_token_idx": { + "name": "auth_session_token_idx", + "columns": ["token"], + "isUnique": true + }, + "auth_session_account_id_idx": { + "name": "auth_session_account_id_idx", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_verification": { + "name": "auth_verification", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_verification_expires_at_idx": { + "name": "auth_verification_expires_at_idx", + "columns": ["expires_at"], + "isUnique": false + }, + "auth_verification_identifier_idx": { + "name": "auth_verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cli_oauth_flow": { + "name": "cli_oauth_flow", + "columns": { + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorized_at": { + "name": "authorized_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cli_oauth_flow_status_expires_idx": { + "name": "cli_oauth_flow_status_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "cli_oauth_flow_device_code_hash_idx": { + "name": "cli_oauth_flow_device_code_hash_idx", + "columns": ["device_code_hash"], + "isUnique": true + }, + "cli_oauth_flow_user_code_idx": { + "name": "cli_oauth_flow_user_code_idx", + "columns": ["user_code"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_access_token": { + "name": "personal_access_token", + "columns": { + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "personal_access_token_account_created_idx": { + "name": "personal_access_token_account_created_idx", + "columns": ["account_id", "created_at"], + "isUnique": false + }, + "personal_access_token_hash_idx": { + "name": "personal_access_token_hash_idx", + "columns": ["token_hash"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_api_key": { + "name": "workspace_api_key", + "columns": { + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_api_key_app_created_idx": { + "name": "workspace_api_key_app_created_idx", + "columns": ["app_id", "created_at"], + "isUnique": false + }, + "workspace_api_key_account_app_idx": { + "name": "workspace_api_key_account_app_idx", + "columns": ["account_id", "app_id"], + "isUnique": false + }, + "workspace_api_key_hash_idx": { + "name": "workspace_api_key_hash_idx", + "columns": ["token_hash"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_channel_binding": { + "name": "agent_channel_binding", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_metadata_json": { + "name": "display_metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "encrypted_creds_secret_id": { + "name": "encrypted_creds_secret_id", + "type": "text CHECK (\"encrypted_creds_secret_id\" = upper(\"encrypted_creds_secret_id\") AND length(\"encrypted_creds_secret_id\") = 26 AND substr(\"encrypted_creds_secret_id\", 1, 1) GLOB '[0-7]' AND \"encrypted_creds_secret_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_bot_id": { + "name": "external_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_tenant_id": { + "name": "external_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_channel_binding_agent_provider_idx": { + "name": "agent_channel_binding_agent_provider_idx", + "columns": ["agent_id", "provider"], + "isUnique": true + }, + "agent_channel_binding_provider_tenant_bot_idx": { + "name": "agent_channel_binding_provider_tenant_bot_idx", + "columns": ["provider", "external_tenant_id", "external_bot_id"], + "isUnique": true + }, + "agent_channel_binding_agent_status_idx": { + "name": "agent_channel_binding_agent_status_idx", + "columns": ["agent_id", "status"], + "isUnique": false + }, + "agent_channel_binding_app_status_idx": { + "name": "agent_channel_binding_app_status_idx", + "columns": ["app_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_channel_binding_agent_id_agent_id_fk": { + "name": "agent_channel_binding_agent_id_agent_id_fk", + "tableFrom": "agent_channel_binding", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_channel_binding_encrypted_creds_secret_id_vault_secret_id_fk": { + "name": "agent_channel_binding_encrypted_creds_secret_id_vault_secret_id_fk", + "tableFrom": "agent_channel_binding", + "tableTo": "vault_secret", + "columnsFrom": ["encrypted_creds_secret_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agent_channel_binding_app_id_app_id_fk": { + "name": "agent_channel_binding_app_id_app_id_fk", + "tableFrom": "agent_channel_binding", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_runtime_state": { + "name": "channel_runtime_state", + "columns": { + "binding_id": { + "name": "binding_id", + "type": "text CHECK (\"binding_id\" = upper(\"binding_id\") AND length(\"binding_id\") = 26 AND substr(\"binding_id\", 1, 1) GLOB '[0-7]' AND \"binding_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_inbound_at": { + "name": "last_inbound_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_poll_at": { + "name": "last_poll_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner_id": { + "name": "lease_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_account_id": { + "name": "runtime_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runtime_state_json": { + "name": "runtime_state_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "channel_runtime_state_provider_binding_account_idx": { + "name": "channel_runtime_state_provider_binding_account_idx", + "columns": ["provider", "binding_id", "runtime_account_id"], + "isUnique": true + }, + "channel_runtime_state_status_lease_idx": { + "name": "channel_runtime_state_status_lease_idx", + "columns": ["status", "lease_expires_at"], + "isUnique": false + }, + "channel_runtime_state_binding_updated_idx": { + "name": "channel_runtime_state_binding_updated_idx", + "columns": ["binding_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "channel_runtime_state_binding_id_agent_channel_binding_id_fk": { + "name": "channel_runtime_state_binding_id_agent_channel_binding_id_fk", + "tableFrom": "channel_runtime_state", + "tableTo": "agent_channel_binding", + "columnsFrom": ["binding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_event_receipt": { + "name": "channel_event_receipt", + "columns": { + "binding_id": { + "name": "binding_id", + "type": "text CHECK (\"binding_id\" = upper(\"binding_id\") AND length(\"binding_id\") = 26 AND substr(\"binding_id\", 1, 1) GLOB '[0-7]' AND \"binding_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_tenant_id": { + "name": "external_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "channel_event_receipt_provider_tenant_event_idx": { + "name": "channel_event_receipt_provider_tenant_event_idx", + "columns": ["provider", "external_tenant_id", "external_event_id"], + "isUnique": true + }, + "channel_event_receipt_binding_updated_idx": { + "name": "channel_event_receipt_binding_updated_idx", + "columns": ["binding_id", "updated_at"], + "isUnique": false + }, + "channel_event_receipt_expires_idx": { + "name": "channel_event_receipt_expires_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "channel_event_receipt_binding_id_agent_channel_binding_id_fk": { + "name": "channel_event_receipt_binding_id_agent_channel_binding_id_fk", + "tableFrom": "channel_event_receipt", + "tableTo": "agent_channel_binding", + "columnsFrom": ["binding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_final_delivery_job": { + "name": "channel_final_delivery_job", + "columns": { + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "binding_id": { + "name": "binding_id", + "type": "text CHECK (\"binding_id\" = upper(\"binding_id\") AND length(\"binding_id\") = 26 AND substr(\"binding_id\", 1, 1) GLOB '[0-7]' AND \"binding_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text CHECK (\"run_id\" = upper(\"run_id\") AND length(\"run_id\") = 26 AND substr(\"run_id\", 1, 1) GLOB '[0-7]' AND \"run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "channel_final_delivery_provider_binding_event_idx": { + "name": "channel_final_delivery_provider_binding_event_idx", + "columns": ["provider", "binding_id", "external_event_id"], + "isUnique": true + }, + "channel_final_delivery_session_idx": { + "name": "channel_final_delivery_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "channel_final_delivery_run_idx": { + "name": "channel_final_delivery_run_idx", + "columns": ["run_id"], + "isUnique": false + } + }, + "foreignKeys": { + "channel_final_delivery_job_binding_id_agent_channel_binding_id_fk": { + "name": "channel_final_delivery_job_binding_id_agent_channel_binding_id_fk", + "tableFrom": "channel_final_delivery_job", + "tableTo": "agent_channel_binding", + "columnsFrom": ["binding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_final_delivery_job_run_id_session_run_id_fk": { + "name": "channel_final_delivery_job_run_id_session_run_id_fk", + "tableFrom": "channel_final_delivery_job", + "tableTo": "session_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_final_delivery_job_session_id_session_id_fk": { + "name": "channel_final_delivery_job_session_id_session_id_fk", + "tableFrom": "channel_final_delivery_job", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_thread_session": { + "name": "channel_thread_session", + "columns": { + "binding_id": { + "name": "binding_id", + "type": "text CHECK (\"binding_id\" = upper(\"binding_id\") AND length(\"binding_id\") = 26 AND substr(\"binding_id\", 1, 1) GLOB '[0-7]' AND \"binding_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_thread_id": { + "name": "external_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "channel_thread_session_provider_binding_thread_idx": { + "name": "channel_thread_session_provider_binding_thread_idx", + "columns": ["provider", "binding_id", "external_thread_id"], + "isUnique": true + }, + "channel_thread_session_session_idx": { + "name": "channel_thread_session_session_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "channel_thread_session_binding_id_agent_channel_binding_id_fk": { + "name": "channel_thread_session_binding_id_agent_channel_binding_id_fk", + "tableFrom": "channel_thread_session", + "tableTo": "agent_channel_binding", + "columnsFrom": ["binding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_thread_session_session_id_session_id_fk": { + "name": "channel_thread_session_session_id_session_id_fk", + "tableFrom": "channel_thread_session", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_channel_account": { + "name": "wechat_channel_account", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_creds_secret_id": { + "name": "encrypted_creds_secret_id", + "type": "text CHECK (\"encrypted_creds_secret_id\" = upper(\"encrypted_creds_secret_id\") AND length(\"encrypted_creds_secret_id\") = 26 AND substr(\"encrypted_creds_secret_id\", 1, 1) GLOB '[0-7]' AND \"encrypted_creds_secret_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_bot_id": { + "name": "external_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_inbound_at": { + "name": "last_inbound_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_poll_at": { + "name": "last_poll_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_state_json": { + "name": "runtime_state_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wechat_channel_account_agent_idx": { + "name": "wechat_channel_account_agent_idx", + "columns": ["agent_id"], + "isUnique": true + }, + "wechat_channel_account_external_idx": { + "name": "wechat_channel_account_external_idx", + "columns": ["external_account_id", "external_bot_id"], + "isUnique": true + }, + "wechat_channel_account_status_idx": { + "name": "wechat_channel_account_status_idx", + "columns": ["status", "updated_at"], + "isUnique": false + }, + "wechat_channel_account_app_status_idx": { + "name": "wechat_channel_account_app_status_idx", + "columns": ["app_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_channel_account_agent_id_agent_id_fk": { + "name": "wechat_channel_account_agent_id_agent_id_fk", + "tableFrom": "wechat_channel_account", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_channel_account_encrypted_creds_secret_id_vault_secret_id_fk": { + "name": "wechat_channel_account_encrypted_creds_secret_id_vault_secret_id_fk", + "tableFrom": "wechat_channel_account", + "tableTo": "vault_secret", + "columnsFrom": ["encrypted_creds_secret_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "wechat_channel_account_owner_account_id_account_id_fk": { + "name": "wechat_channel_account_owner_account_id_account_id_fk", + "tableFrom": "wechat_channel_account", + "tableTo": "account", + "columnsFrom": ["owner_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_channel_account_app_id_app_id_fk": { + "name": "wechat_channel_account_app_id_app_id_fk", + "tableFrom": "wechat_channel_account", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_channel_pairing": { + "name": "wechat_channel_pairing", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qr_token_hash": { + "name": "qr_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wechat_channel_pairing_qr_token_hash_idx": { + "name": "wechat_channel_pairing_qr_token_hash_idx", + "columns": ["qr_token_hash"], + "isUnique": true + }, + "wechat_channel_pairing_agent_creator_idx": { + "name": "wechat_channel_pairing_agent_creator_idx", + "columns": ["agent_id", "created_by_account_id", "consumed_at"], + "isUnique": false + }, + "wechat_channel_pairing_app_creator_idx": { + "name": "wechat_channel_pairing_app_creator_idx", + "columns": ["app_id", "created_by_account_id", "consumed_at"], + "isUnique": false + }, + "wechat_channel_pairing_expires_idx": { + "name": "wechat_channel_pairing_expires_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_channel_pairing_agent_id_agent_id_fk": { + "name": "wechat_channel_pairing_agent_id_agent_id_fk", + "tableFrom": "wechat_channel_pairing", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_channel_pairing_created_by_account_id_account_id_fk": { + "name": "wechat_channel_pairing_created_by_account_id_account_id_fk", + "tableFrom": "wechat_channel_pairing", + "tableTo": "account", + "columnsFrom": ["created_by_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_channel_pairing_app_id_app_id_fk": { + "name": "wechat_channel_pairing_app_id_app_id_fk", + "tableFrom": "wechat_channel_pairing", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_context_token": { + "name": "wechat_context_token", + "columns": { + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_token_key": { + "name": "context_token_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_context_token_secret_id": { + "name": "encrypted_context_token_secret_id", + "type": "text CHECK (\"encrypted_context_token_secret_id\" = upper(\"encrypted_context_token_secret_id\") AND length(\"encrypted_context_token_secret_id\") = 26 AND substr(\"encrypted_context_token_secret_id\", 1, 1) GLOB '[0-7]' AND \"encrypted_context_token_secret_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wechat_context_token_key_idx": { + "name": "wechat_context_token_key_idx", + "columns": ["context_token_key"], + "isUnique": true + }, + "wechat_context_token_account_peer_idx": { + "name": "wechat_context_token_account_peer_idx", + "columns": ["account_id", "external_account_id", "peer_id"], + "isUnique": true + }, + "wechat_context_token_account_updated_idx": { + "name": "wechat_context_token_account_updated_idx", + "columns": ["account_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_context_token_account_id_wechat_channel_account_id_fk": { + "name": "wechat_context_token_account_id_wechat_channel_account_id_fk", + "tableFrom": "wechat_context_token", + "tableTo": "wechat_channel_account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_context_token_encrypted_context_token_secret_id_vault_secret_id_fk": { + "name": "wechat_context_token_encrypted_context_token_secret_id_vault_secret_id_fk", + "tableFrom": "wechat_context_token", + "tableTo": "vault_secret", + "columnsFrom": ["encrypted_context_token_secret_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_log": { + "name": "email_log", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_domain": { + "name": "recipient_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_masked": { + "name": "recipient_masked", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_log_created_at_idx": { + "name": "email_log_created_at_idx", + "columns": ["created_at"], + "isUnique": false + }, + "email_log_type_status_idx": { + "name": "email_log_type_status_idx", + "columns": ["type", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_revision": { + "name": "environment_revision", + "columns": { + "allow_mcp_servers": { + "name": "allow_mcp_servers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow_package_managers": { + "name": "allow_package_managers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_hosts_json": { + "name": "allowed_hosts_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_vars_json": { + "name": "env_vars_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text CHECK (\"environment_id\" = upper(\"environment_id\") AND length(\"environment_id\") = 26 AND substr(\"environment_id\", 1, 1) GLOB '[0-7]' AND \"environment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "network_policy": { + "name": "network_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "packages_json": { + "name": "packages_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_revision_environment_created_at_idx": { + "name": "environment_revision_environment_created_at_idx", + "columns": ["environment_id", "created_at"], + "isUnique": false + }, + "environment_revision_app_created_at_idx": { + "name": "environment_revision_app_created_at_idx", + "columns": ["app_id", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "environment_revision_network_policy_check": { + "name": "environment_revision_network_policy_check", + "value": "\"environment_revision\".\"network_policy\" IN ('full', 'limited')" + } + } + }, + "environment": { + "name": "environment", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_revision_id": { + "name": "current_revision_id", + "type": "text CHECK (\"current_revision_id\" = upper(\"current_revision_id\") AND length(\"current_revision_id\") = 26 AND substr(\"current_revision_id\", 1, 1) GLOB '[0-7]' AND \"current_revision_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forked_from_environment_id": { + "name": "forked_from_environment_id", + "type": "text CHECK (\"forked_from_environment_id\" = upper(\"forked_from_environment_id\") AND length(\"forked_from_environment_id\") = 26 AND substr(\"forked_from_environment_id\", 1, 1) GLOB '[0-7]' AND \"forked_from_environment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_from_environment_name": { + "name": "forked_from_environment_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_from_owner_name": { + "name": "forked_from_owner_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_app_updated_at_idx": { + "name": "environment_app_updated_at_idx", + "columns": ["app_id", "updated_at"], + "isUnique": false + }, + "environment_owner_updated_at_idx": { + "name": "environment_owner_updated_at_idx", + "columns": ["owner_account_id", "updated_at"], + "isUnique": false + }, + "environment_owner_name_idx": { + "name": "environment_owner_name_idx", + "columns": ["app_id", "owner_account_id", "name"], + "isUnique": true, + "where": "\"environment\".\"owner_account_id\" IS NOT NULL" + }, + "environment_system_default_idx": { + "name": "environment_system_default_idx", + "columns": ["app_id"], + "isUnique": true, + "where": "\"environment\".\"owner_account_id\" IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_record": { + "name": "file_record", + "columns": { + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text CHECK (\"owner_id\" = upper(\"owner_id\") AND length(\"owner_id\") = 26 AND substr(\"owner_id\", 1, 1) GLOB '[0-7]' AND \"owner_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_path": { + "name": "parent_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text CHECK (\"scope_id\" = upper(\"scope_id\") AND length(\"scope_id\") = 26 AND substr(\"scope_id\", 1, 1) GLOB '[0-7]' AND \"scope_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_kind": { + "name": "session_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_record_object_key_idx": { + "name": "file_record_object_key_idx", + "columns": ["object_key"], + "isUnique": true + }, + "file_record_unscoped_parent_path_name_status_idx": { + "name": "file_record_unscoped_parent_path_name_status_idx", + "columns": ["scope_kind", "parent_path", "name", "status"], + "isUnique": true, + "where": "\"file_record\".\"scope_id\" IS NULL" + }, + "file_record_scoped_parent_path_name_status_idx": { + "name": "file_record_scoped_parent_path_name_status_idx", + "columns": ["scope_kind", "scope_id", "parent_path", "name", "status"], + "isUnique": true + }, + "file_record_unscoped_pending_path_idx": { + "name": "file_record_unscoped_pending_path_idx", + "columns": ["scope_kind", "path"], + "isUnique": true, + "where": "\"file_record\".\"status\" = 'pending' AND \"file_record\".\"scope_id\" IS NULL" + }, + "file_record_scoped_pending_path_idx": { + "name": "file_record_scoped_pending_path_idx", + "columns": ["scope_kind", "scope_id", "path"], + "isUnique": true, + "where": "\"file_record\".\"status\" = 'pending' AND \"file_record\".\"scope_id\" IS NOT NULL" + }, + "file_record_unscoped_ready_path_idx": { + "name": "file_record_unscoped_ready_path_idx", + "columns": ["scope_kind", "path"], + "isUnique": true, + "where": "\"file_record\".\"status\" = 'ready' AND \"file_record\".\"scope_id\" IS NULL" + }, + "file_record_scoped_ready_path_idx": { + "name": "file_record_scoped_ready_path_idx", + "columns": ["scope_kind", "scope_id", "path"], + "isUnique": true, + "where": "\"file_record\".\"status\" = 'ready' AND \"file_record\".\"scope_id\" IS NOT NULL" + }, + "file_record_governance_idx": { + "name": "file_record_governance_idx", + "columns": ["purpose", "owner_kind", "owner_id", "status", "expires_at"], + "isUnique": false + }, + "file_record_listing_idx": { + "name": "file_record_listing_idx", + "columns": ["scope_kind", "scope_id", "parent_path", "status", "lower(\"name\")"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_upload": { + "name": "file_upload", + "columns": { + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_size": { + "name": "expected_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text CHECK (\"file_id\" = upper(\"file_id\") AND length(\"file_id\") = 26 AND substr(\"file_id\", 1, 1) GLOB '[0-7]' AND \"file_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "if_match_etag": { + "name": "if_match_etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overwrite": { + "name": "overwrite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text CHECK (\"scope_id\" = upper(\"scope_id\") AND length(\"scope_id\") = 26 AND substr(\"scope_id\", 1, 1) GLOB '[0-7]' AND \"scope_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_upload_file_id_idx": { + "name": "file_upload_file_id_idx", + "columns": ["file_id"], + "isUnique": true + }, + "file_upload_status_expires_idx": { + "name": "file_upload_status_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_version": { + "name": "file_version", + "columns": { + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "committed_at": { + "name": "committed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_id": { + "name": "file_id", + "type": "text CHECK (\"file_id\" = upper(\"file_id\") AND length(\"file_id\") = 26 AND substr(\"file_id\", 1, 1) GLOB '[0-7]' AND \"file_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text CHECK (\"scope_id\" = upper(\"scope_id\") AND length(\"scope_id\") = 26 AND substr(\"scope_id\", 1, 1) GLOB '[0-7]' AND \"scope_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_etag": { + "name": "source_etag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_object_key": { + "name": "source_object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_version_object_key_idx": { + "name": "file_version_object_key_idx", + "columns": ["object_key"], + "isUnique": true + }, + "file_version_scope_path_created_idx": { + "name": "file_version_scope_path_created_idx", + "columns": ["scope_kind", "scope_id", "path", "created_at"], + "isUnique": false + }, + "file_version_file_created_idx": { + "name": "file_version_file_created_idx", + "columns": ["file_id", "created_at"], + "isUnique": false + }, + "file_version_pending_idx": { + "name": "file_version_pending_idx", + "columns": ["committed", "created_at"], + "isUnique": false, + "where": "\"file_version\".\"committed\" = 0" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_credential": { + "name": "mcp_credential", + "columns": { + "account_id": { + "name": "account_id", + "type": "text CHECK (\"account_id\" = upper(\"account_id\") AND length(\"account_id\") = 26 AND substr(\"account_id\", 1, 1) GLOB '[0-7]' AND \"account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_secret_secret_id": { + "name": "oauth_client_secret_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_values_json": { + "name": "scope_values_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text CHECK (\"server_id\" = upper(\"server_id\") AND length(\"server_id\") = 26 AND substr(\"server_id\", 1, 1) GLOB '[0-7]' AND \"server_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_label": { + "name": "subject_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_credential_server_scope_status_idx": { + "name": "mcp_credential_server_scope_status_idx", + "columns": ["server_id", "scope", "status"], + "isUnique": false + }, + "mcp_credential_app_scope_status_idx": { + "name": "mcp_credential_app_scope_status_idx", + "columns": ["app_id", "scope", "status"], + "isUnique": false + }, + "mcp_credential_app_scope_idx": { + "name": "mcp_credential_app_scope_idx", + "columns": ["server_id", "scope"], + "isUnique": true, + "where": "\"mcp_credential\".\"scope\" = 'app'" + }, + "mcp_credential_agent_scope_idx": { + "name": "mcp_credential_agent_scope_idx", + "columns": ["server_id", "agent_id", "scope"], + "isUnique": true, + "where": "\"mcp_credential\".\"scope\" = 'agent' AND \"mcp_credential\".\"agent_id\" IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "mcp_credential_scope_shape_check": { + "name": "mcp_credential_scope_shape_check", + "value": "\n (\"mcp_credential\".\"scope\" = 'app' AND \"mcp_credential\".\"account_id\" IS NULL AND \"mcp_credential\".\"agent_id\" IS NULL)\n OR (\"mcp_credential\".\"scope\" = 'agent' AND \"mcp_credential\".\"account_id\" IS NULL AND \"mcp_credential\".\"agent_id\" IS NOT NULL)\n " + }, + "mcp_credential_scope_values_json_check": { + "name": "mcp_credential_scope_values_json_check", + "value": "\n \"mcp_credential\".\"scope_values_json\" IS NULL\n OR (json_valid(\"mcp_credential\".\"scope_values_json\") AND json_type(\"mcp_credential\".\"scope_values_json\") = 'array')\n " + }, + "mcp_credential_bearer_shape_check": { + "name": "mcp_credential_bearer_shape_check", + "value": "\n \"mcp_credential\".\"auth_type\" != 'bearer'\n OR (\n \"mcp_credential\".\"oauth_client_id\" IS NULL\n AND \"mcp_credential\".\"oauth_client_secret_secret_id\" IS NULL\n AND \"mcp_credential\".\"refresh_secret_id\" IS NULL\n )\n " + } + } + }, + "mcp_oauth_flow": { + "name": "mcp_oauth_flow", + "columns": { + "authorization_endpoint": { + "name": "authorization_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_after": { + "name": "cleanup_after", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiator_account_id": { + "name": "initiator_account_id", + "type": "text CHECK (\"initiator_account_id\" = upper(\"initiator_account_id\") AND length(\"initiator_account_id\") = 26 AND substr(\"initiator_account_id\", 1, 1) GLOB '[0-7]' AND \"initiator_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_client_secret_secret_id": { + "name": "oauth_client_secret_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_endpoint": { + "name": "registration_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_values_json": { + "name": "scope_values_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text CHECK (\"server_id\" = upper(\"server_id\") AND length(\"server_id\") = 26 AND substr(\"server_id\", 1, 1) GLOB '[0-7]' AND \"server_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_label": { + "name": "subject_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint": { + "name": "token_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_oauth_flow_status_cleanup_after_idx": { + "name": "mcp_oauth_flow_status_cleanup_after_idx", + "columns": ["status", "cleanup_after"], + "isUnique": false + }, + "mcp_oauth_flow_expires_at_idx": { + "name": "mcp_oauth_flow_expires_at_idx", + "columns": ["expires_at"], + "isUnique": false + }, + "mcp_oauth_flow_server_account_idx": { + "name": "mcp_oauth_flow_server_account_idx", + "columns": ["server_id", "initiator_account_id"], + "isUnique": false + }, + "mcp_oauth_flow_app_server_account_idx": { + "name": "mcp_oauth_flow_app_server_account_idx", + "columns": ["app_id", "server_id", "initiator_account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "mcp_oauth_flow_scope_values_json_check": { + "name": "mcp_oauth_flow_scope_values_json_check", + "value": "\n \"mcp_oauth_flow\".\"scope_values_json\" IS NULL\n OR (json_valid(\"mcp_oauth_flow\".\"scope_values_json\") AND json_type(\"mcp_oauth_flow\".\"scope_values_json\") = 'array')\n " + } + } + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byo_client_id": { + "name": "byo_client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byo_client_secret_secret_id": { + "name": "byo_client_secret_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_scope": { + "name": "credential_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_metadata_json": { + "name": "oauth_metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_app_enabled_idx": { + "name": "mcp_server_app_enabled_idx", + "columns": ["app_id", "enabled"], + "isUnique": false + }, + "mcp_server_owner_app_idx": { + "name": "mcp_server_owner_app_idx", + "columns": ["owner_account_id", "app_id"], + "isUnique": false + }, + "mcp_server_app_url_idx": { + "name": "mcp_server_app_url_idx", + "columns": ["app_id", "url"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "mcp_server_source_scope_check": { + "name": "mcp_server_source_scope_check", + "value": "\"mcp_server\".\"source\" = 'app' AND \"mcp_server\".\"credential_scope\" = 'app'" + } + } + }, + "vault_secret": { + "name": "vault_secret", + "columns": { + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'AES-GCM'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext_iv": { + "name": "ciphertext_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wrapped_dek": { + "name": "wrapped_dek", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wrapped_dek_iv": { + "name": "wrapped_dek_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "vault_secret_kind_created_at_idx": { + "name": "vault_secret_kind_created_at_idx", + "columns": ["kind", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_account_id": { + "name": "creator_account_id", + "type": "text CHECK (\"creator_account_id\" = upper(\"creator_account_id\") AND length(\"creator_account_id\") = 26 AND substr(\"creator_account_id\", 1, 1) GLOB '[0-7]' AND \"creator_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_creator_account_idx": { + "name": "organization_creator_account_idx", + "columns": ["creator_account_id"], + "isUnique": true, + "where": "\"organization\".\"creator_account_id\" IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_deployment_run": { + "name": "app_deployment_run", + "columns": { + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text CHECK (\"deployment_id\" = upper(\"deployment_id\") AND length(\"deployment_id\") = 26 AND substr(\"deployment_id\", 1, 1) GLOB '[0-7]' AND \"deployment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_deployment_id": { + "name": "external_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_version_id": { + "name": "external_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generated_wrangler_config_json": { + "name": "generated_wrangler_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mosoo_config_json": { + "name": "mosoo_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan_json": { + "name": "plan_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_commit_sha": { + "name": "source_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_project_name": { + "name": "target_project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_script_name": { + "name": "target_script_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "app_deployment_run_app_id_idx": { + "name": "app_deployment_run_app_id_idx", + "columns": ["app_id", "id"], + "isUnique": false + }, + "app_deployment_run_deployment_id_idx": { + "name": "app_deployment_run_deployment_id_idx", + "columns": ["deployment_id", "id"], + "isUnique": false + }, + "app_deployment_run_active_app_idx": { + "name": "app_deployment_run_active_app_idx", + "columns": ["app_id"], + "isUnique": true, + "where": "\"app_deployment_run\".\"status\" IN ('queued', 'preparing', 'building', 'submitting', 'submitted', 'activating')" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "app_deployment_run_status_check": { + "name": "app_deployment_run_status_check", + "value": "\"app_deployment_run\".\"status\" IN ('queued', 'preparing', 'building', 'submitting', 'submitted', 'activating', 'success', 'failed')" + }, + "app_deployment_run_target_kind_check": { + "name": "app_deployment_run_target_kind_check", + "value": "\"app_deployment_run\".\"target_kind\" IS NULL OR \"app_deployment_run\".\"target_kind\" IN ('cloudflare_pages', 'cloudflare_worker')" + } + } + }, + "app_deployment_secret": { + "name": "app_deployment_secret", + "columns": { + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_secret_id": { + "name": "vault_secret_id", + "type": "text CHECK (\"vault_secret_id\" = upper(\"vault_secret_id\") AND length(\"vault_secret_id\") = 26 AND substr(\"vault_secret_id\", 1, 1) GLOB '[0-7]' AND \"vault_secret_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "app_deployment_secret_app_name_idx": { + "name": "app_deployment_secret_app_name_idx", + "columns": ["app_id", "name"], + "isUnique": true + }, + "app_deployment_secret_vault_secret_idx": { + "name": "app_deployment_secret_vault_secret_idx", + "columns": ["vault_secret_id"], + "isUnique": true + } + }, + "foreignKeys": { + "app_deployment_secret_vault_secret_id_vault_secret_id_fk": { + "name": "app_deployment_secret_vault_secret_id_vault_secret_id_fk", + "tableFrom": "app_deployment_secret", + "tableTo": "vault_secret", + "columnsFrom": ["vault_secret_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_deployment": { + "name": "app_deployment", + "columns": { + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_successful_url": { + "name": "last_successful_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_run_id": { + "name": "latest_run_id", + "type": "text CHECK (\"latest_run_id\" = upper(\"latest_run_id\") AND length(\"latest_run_id\") = 26 AND substr(\"latest_run_id\", 1, 1) GLOB '[0-7]' AND \"latest_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mosoo_subdomain": { + "name": "mosoo_subdomain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "app_deployment_active_app_idx": { + "name": "app_deployment_active_app_idx", + "columns": ["app_id"], + "isUnique": true, + "where": "\"app_deployment\".\"deleted_at\" IS NULL" + }, + "app_deployment_active_subdomain_idx": { + "name": "app_deployment_active_subdomain_idx", + "columns": ["mosoo_subdomain"], + "isUnique": true, + "where": "\"app_deployment\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "app_deployment_source_kind_check": { + "name": "app_deployment_source_kind_check", + "value": "\"app_deployment\".\"source_kind\" IN ('github_public')" + } + } + }, + "app": { + "name": "app", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_environment_id": { + "name": "default_environment_id", + "type": "text CHECK (\"default_environment_id\" = upper(\"default_environment_id\") AND length(\"default_environment_id\") = 26 AND substr(\"default_environment_id\", 1, 1) GLOB '[0-7]' AND \"default_environment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text CHECK (\"organization_id\" = upper(\"organization_id\") AND length(\"organization_id\") = 26 AND substr(\"organization_id\", 1, 1) GLOB '[0-7]' AND \"organization_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bound_agent_call_idempotency_key": { + "name": "bound_agent_call_idempotency_key", + "columns": { + "body_hash": { + "name": "body_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text CHECK (\"run_id\" = upper(\"run_id\") AND length(\"run_id\") = 26 AND substr(\"run_id\", 1, 1) GLOB '[0-7]' AND \"run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bound_agent_call_idempotency_subject_key_idx": { + "name": "bound_agent_call_idempotency_subject_key_idx", + "columns": ["subject_hash", "idempotency_key"], + "isUnique": true + }, + "bound_agent_call_idempotency_updated_idx": { + "name": "bound_agent_call_idempotency_updated_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public_api_idempotency_key": { + "name": "public_api_idempotency_key", + "columns": { + "body_hash": { + "name": "body_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "text CHECK (\"token_id\" = upper(\"token_id\") AND length(\"token_id\") = 26 AND substr(\"token_id\", 1, 1) GLOB '[0-7]' AND \"token_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "public_api_idempotency_token_key_idx": { + "name": "public_api_idempotency_token_key_idx", + "columns": ["token_id", "idempotency_key"], + "isUnique": true + }, + "public_api_idempotency_updated_idx": { + "name": "public_api_idempotency_updated_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public_api_rate_limit_window": { + "name": "public_api_rate_limit_window", + "columns": { + "bucket_key": { + "name": "bucket_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "shard": { + "name": "shard", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_start": { + "name": "window_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "public_api_rate_limit_window_updated_idx": { + "name": "public_api_rate_limit_window_updated_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "public_api_rate_limit_window_bucket_key_window_start_shard_pk": { + "columns": ["bucket_key", "window_start", "shard"], + "name": "public_api_rate_limit_window_bucket_key_window_start_shard_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "driver_command": { + "name": "driver_command", + "columns": { + "acked_at": { + "name": "acked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivery_connection_id": { + "name": "delivery_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_json": { + "name": "error_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "driver_command_instance_seq_idx": { + "name": "driver_command_instance_seq_idx", + "columns": ["driver_instance_id", "seq"], + "isUnique": true + }, + "driver_command_instance_status_idx": { + "name": "driver_command_instance_status_idx", + "columns": ["driver_instance_id", "status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "driver_command_driver_instance_id_driver_instance_id_fk": { + "name": "driver_command_driver_instance_id_driver_instance_id_fk", + "tableFrom": "driver_command", + "tableTo": "driver_instance", + "columnsFrom": ["driver_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "driver_instance_mcp_grant": { + "name": "driver_instance_mcp_grant", + "columns": { + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_state": { + "name": "authorization_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "can_invalidate": { + "name": "can_invalidate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text CHECK (\"credential_id\" = upper(\"credential_id\") AND length(\"credential_id\") = 26 AND substr(\"credential_id\", 1, 1) GLOB '[0-7]' AND \"credential_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text CHECK (\"server_id\" = upper(\"server_id\") AND length(\"server_id\") = 26 AND substr(\"server_id\", 1, 1) GLOB '[0-7]' AND \"server_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "driver_instance_mcp_grant_instance_server_idx": { + "name": "driver_instance_mcp_grant_instance_server_idx", + "columns": ["driver_instance_id", "server_id"], + "isUnique": true + }, + "driver_instance_mcp_grant_instance_credential_idx": { + "name": "driver_instance_mcp_grant_instance_credential_idx", + "columns": ["driver_instance_id", "credential_id"], + "isUnique": false + } + }, + "foreignKeys": { + "driver_instance_mcp_grant_driver_instance_id_driver_instance_id_fk": { + "name": "driver_instance_mcp_grant_driver_instance_id_driver_instance_id_fk", + "tableFrom": "driver_instance_mcp_grant", + "tableTo": "driver_instance", + "columnsFrom": ["driver_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "driver_instance": { + "name": "driver_instance", + "columns": { + "boot_token_expires_at": { + "name": "boot_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "boot_token_hash": { + "name": "boot_token_hash", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "boot_token_used_at": { + "name": "boot_token_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_code": { + "name": "close_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_seq_cursor": { + "name": "command_seq_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "driver_pid": { + "name": "driver_pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_started_at": { + "name": "driver_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_version": { + "name": "driver_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_count": { + "name": "heartbeat_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "process_id": { + "name": "process_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restart_count": { + "name": "restart_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text CHECK (\"sandbox_id\" = upper(\"sandbox_id\") AND length(\"sandbox_id\") = 26 AND substr(\"sandbox_id\", 1, 1) GLOB '[0-7]' AND \"sandbox_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_session_id": { + "name": "sandbox_session_id", + "type": "text CHECK (\"sandbox_session_id\" = upper(\"sandbox_session_id\") AND length(\"sandbox_session_id\") = 26 AND substr(\"sandbox_session_id\", 1, 1) GLOB '[0-7]' AND \"sandbox_session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_event": { + "name": "status_event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'driver.provision'" + }, + "status_operation_id": { + "name": "status_operation_id", + "type": "text CHECK (\"status_operation_id\" = upper(\"status_operation_id\") AND length(\"status_operation_id\") = 26 AND substr(\"status_operation_id\", 1, 1) GLOB '[0-7]' AND \"status_operation_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_seq": { + "name": "status_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_source": { + "name": "status_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "driver_instance_completed_idx": { + "name": "driver_instance_completed_idx", + "columns": ["expires_at", "status"], + "isUnique": false + }, + "driver_instance_connection_idx": { + "name": "driver_instance_connection_idx", + "columns": ["connection_id"], + "isUnique": true, + "where": "\"driver_instance\".\"connection_id\" IS NOT NULL" + }, + "driver_instance_boot_token_expiry_idx": { + "name": "driver_instance_boot_token_expiry_idx", + "columns": ["status", "boot_token_expires_at"], + "isUnique": false, + "where": "\"driver_instance\".\"boot_token_used_at\" IS NULL" + }, + "driver_instance_boot_token_hash_idx": { + "name": "driver_instance_boot_token_hash_idx", + "columns": ["boot_token_hash"], + "isUnique": true + }, + "driver_instance_sandbox_session_idx": { + "name": "driver_instance_sandbox_session_idx", + "columns": ["sandbox_id", "sandbox_session_id", "status", "updated_at"], + "isUnique": false + }, + "driver_instance_live_sandbox_session_idx": { + "name": "driver_instance_live_sandbox_session_idx", + "columns": ["sandbox_id", "sandbox_session_id"], + "isUnique": true, + "where": "\"driver_instance\".\"status\" IN ('provisioning', 'connecting', 'ready', 'stopping')" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "driver_instance_status_check": { + "name": "driver_instance_status_check", + "value": "\"driver_instance\".\"status\" IN ('provisioning', 'connecting', 'ready', 'stopping', 'stopped', 'failed')" + }, + "driver_instance_status_seq_check": { + "name": "driver_instance_status_seq_check", + "value": "\"driver_instance\".\"status_seq\" >= 0" + } + } + }, + "external_tool_effect_attempt": { + "name": "external_tool_effect_attempt", + "columns": { + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effect_id": { + "name": "effect_id", + "type": "text CHECK (\"effect_id\" = upper(\"effect_id\") AND length(\"effect_id\") = 26 AND substr(\"effect_id\", 1, 1) GLOB '[0-7]' AND \"effect_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_receipt_json": { + "name": "provider_receipt_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_tool_effect_attempt_status_idx": { + "name": "external_tool_effect_attempt_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "external_tool_effect_attempt_effect_id_external_tool_effect_id_fk": { + "name": "external_tool_effect_attempt_effect_id_external_tool_effect_id_fk", + "tableFrom": "external_tool_effect_attempt", + "tableTo": "external_tool_effect", + "columnsFrom": ["effect_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "external_tool_effect_attempt_effect_id_attempt_pk": { + "columns": ["effect_id", "attempt"], + "name": "external_tool_effect_attempt_effect_id_attempt_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "external_tool_effect_attempt_status_check": { + "name": "external_tool_effect_attempt_status_check", + "value": "\"external_tool_effect_attempt\".\"status\" IN ('executing', 'succeeded', 'unknown')" + } + } + }, + "external_tool_effect": { + "name": "external_tool_effect", + "columns": { + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_id": { + "name": "command_id", + "type": "text CHECK (\"command_id\" = upper(\"command_id\") AND length(\"command_id\") = 26 AND substr(\"command_id\", 1, 1) GLOB '[0-7]' AND \"command_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_receipt_json": { + "name": "provider_receipt_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text CHECK (\"server_id\" = upper(\"server_id\") AND length(\"server_id\") = 26 AND substr(\"server_id\", 1, 1) GLOB '[0-7]' AND \"server_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_tool_effect_command_idx": { + "name": "external_tool_effect_command_idx", + "columns": ["command_id"], + "isUnique": true + }, + "external_tool_effect_idempotency_key_idx": { + "name": "external_tool_effect_idempotency_key_idx", + "columns": ["idempotency_key"], + "isUnique": true + }, + "external_tool_effect_run_status_idx": { + "name": "external_tool_effect_run_status_idx", + "columns": ["session_run_id", "status", "id"], + "isUnique": false + }, + "external_tool_effect_driver_status_idx": { + "name": "external_tool_effect_driver_status_idx", + "columns": ["driver_instance_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "external_tool_effect_command_id_driver_command_id_fk": { + "name": "external_tool_effect_command_id_driver_command_id_fk", + "tableFrom": "external_tool_effect", + "tableTo": "driver_command", + "columnsFrom": ["command_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_tool_effect_driver_instance_id_driver_instance_id_fk": { + "name": "external_tool_effect_driver_instance_id_driver_instance_id_fk", + "tableFrom": "external_tool_effect", + "tableTo": "driver_instance", + "columnsFrom": ["driver_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_tool_effect_session_run_id_session_run_id_fk": { + "name": "external_tool_effect_session_run_id_session_run_id_fk", + "tableFrom": "external_tool_effect", + "tableTo": "session_run", + "columnsFrom": ["session_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "external_tool_effect_status_check": { + "name": "external_tool_effect_status_check", + "value": "\"external_tool_effect\".\"status\" IN ('intent', 'executing', 'succeeded', 'unknown')" + } + } + }, + "native_resume_ref": { + "name": "native_resume_ref", + "columns": { + "committed_session_run_id": { + "name": "committed_session_run_id", + "type": "text CHECK (\"committed_session_run_id\" = upper(\"committed_session_run_id\") AND length(\"committed_session_run_id\") = 26 AND substr(\"committed_session_run_id\", 1, 1) GLOB '[0-7]' AND \"committed_session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "committed_value": { + "name": "committed_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_driver_instance_id": { + "name": "observed_driver_instance_id", + "type": "text CHECK (\"observed_driver_instance_id\" = upper(\"observed_driver_instance_id\") AND length(\"observed_driver_instance_id\") = 26 AND substr(\"observed_driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"observed_driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "observed_session_run_id": { + "name": "observed_session_run_id", + "type": "text CHECK (\"observed_session_run_id\" = upper(\"observed_session_run_id\") AND length(\"observed_session_run_id\") = 26 AND substr(\"observed_session_run_id\", 1, 1) GLOB '[0-7]' AND \"observed_session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "native_resume_ref_runtime_updated_idx": { + "name": "native_resume_ref_runtime_updated_idx", + "columns": ["runtime_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "native_resume_ref_session_id_session_id_fk": { + "name": "native_resume_ref_session_id_session_id_fk", + "tableFrom": "native_resume_ref", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sandbox_backup": { + "name": "sandbox_backup", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dir": { + "name": "dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "keep": { + "name": "keep", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text CHECK (\"sandbox_id\" = upper(\"sandbox_id\") AND length(\"sandbox_id\") = 26 AND substr(\"sandbox_id\", 1, 1) GLOB '[0-7]' AND \"sandbox_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl_seconds": { + "name": "ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sandbox_backup_sandbox_status_created_idx": { + "name": "sandbox_backup_sandbox_status_created_idx", + "columns": ["sandbox_id", "status", "created_at"], + "isUnique": false + }, + "sandbox_backup_terminal_checkpoint_idx": { + "name": "sandbox_backup_terminal_checkpoint_idx", + "columns": ["sandbox_id", "dir", "session_run_id"], + "isUnique": true, + "where": "\"sandbox_backup\".\"session_run_id\" IS NOT NULL AND \"sandbox_backup\".\"status\" = 'ready'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sandbox_session": { + "name": "sandbox_session", + "columns": { + "cloudflare_session_id": { + "name": "cloudflare_session_id", + "type": "text CHECK (\"cloudflare_session_id\" = upper(\"cloudflare_session_id\") AND length(\"cloudflare_session_id\") = 26 AND substr(\"cloudflare_session_id\", 1, 1) GLOB '[0-7]' AND \"cloudflare_session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_json": { + "name": "origin_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text CHECK (\"sandbox_id\" = upper(\"sandbox_id\") AND length(\"sandbox_id\") = 26 AND substr(\"sandbox_id\", 1, 1) GLOB '[0-7]' AND \"sandbox_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sandbox_session_sandbox_status_idx": { + "name": "sandbox_session_sandbox_status_idx", + "columns": ["sandbox_id", "status", "updated_at"], + "isUnique": false + }, + "sandbox_session_cloudflare_session_idx": { + "name": "sandbox_session_cloudflare_session_idx", + "columns": ["cloudflare_session_id"], + "isUnique": true + } + }, + "foreignKeys": { + "sandbox_session_session_id_session_id_fk": { + "name": "sandbox_session_session_id_session_id_fk", + "tableFrom": "sandbox_session", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sandbox": { + "name": "sandbox", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bind_mount_ready": { + "name": "bind_mount_ready", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_mounts_json": { + "name": "global_mounts_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inactive_deadline_at": { + "name": "inactive_deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_backup_id": { + "name": "last_backup_id", + "type": "text CHECK (\"last_backup_id\" = upper(\"last_backup_id\") AND length(\"last_backup_id\") = 26 AND substr(\"last_backup_id\", 1, 1) GLOB '[0-7]' AND \"last_backup_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_restore_backup_id": { + "name": "last_restore_backup_id", + "type": "text CHECK (\"last_restore_backup_id\" = upper(\"last_restore_backup_id\") AND length(\"last_restore_backup_id\") = 26 AND substr(\"last_restore_backup_id\", 1, 1) GLOB '[0-7]' AND \"last_restore_backup_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_event": { + "name": "status_event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'runtime_subject.cold'" + }, + "status_operation_id": { + "name": "status_operation_id", + "type": "text CHECK (\"status_operation_id\" = upper(\"status_operation_id\") AND length(\"status_operation_id\") = 26 AND substr(\"status_operation_id\", 1, 1) GLOB '[0-7]' AND \"status_operation_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_seq": { + "name": "status_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_source": { + "name": "status_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "subject_id": { + "name": "subject_id", + "type": "text CHECK (\"subject_id\" = upper(\"subject_id\") AND length(\"subject_id\") = 26 AND substr(\"subject_id\", 1, 1) GLOB '[0-7]' AND \"subject_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sandbox_subject_idx": { + "name": "sandbox_subject_idx", + "columns": ["kind", "subject_kind", "subject_id"], + "isUnique": true + }, + "sandbox_status_deadline_idx": { + "name": "sandbox_status_deadline_idx", + "columns": ["status", "inactive_deadline_at", "updated_at"], + "isUnique": false + }, + "sandbox_claim_idx": { + "name": "sandbox_claim_idx", + "columns": ["claim_expires_at", "claim_owner"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "sandbox_status_check": { + "name": "sandbox_status_check", + "value": "\"sandbox\".\"status\" IN ('cold', 'restoring', 'active', 'backing_up', 'destroying', 'error')" + }, + "sandbox_status_seq_check": { + "name": "sandbox_status_seq_check", + "value": "\"sandbox\".\"status_seq\" >= 0" + } + } + }, + "session_message": { + "name": "session_message", + "columns": { + "content_text": { + "name": "content_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan_json": { + "name": "plan_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segments_json": { + "name": "segments_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_message_session_seq_idx": { + "name": "session_message_session_seq_idx", + "columns": ["session_id", "seq"], + "isUnique": true + }, + "session_message_run_idx": { + "name": "session_message_run_idx", + "columns": ["session_run_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_message_session_id_session_id_fk": { + "name": "session_message_session_id_session_id_fk", + "tableFrom": "session_message", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_user_id": { + "name": "end_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attributed_user_id": { + "name": "attributed_user_id", + "type": "text CHECK (\"attributed_user_id\" = upper(\"attributed_user_id\") AND length(\"attributed_user_id\") = 26 AND substr(\"attributed_user_id\", 1, 1) GLOB '[0-7]' AND \"attributed_user_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_account_id": { + "name": "creator_account_id", + "type": "text CHECK (\"creator_account_id\" = upper(\"creator_account_id\") AND length(\"creator_account_id\") = 26 AND substr(\"creator_account_id\", 1, 1) GLOB '[0-7]' AND \"creator_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text CHECK (\"deployment_version_id\" = upper(\"deployment_version_id\") AND length(\"deployment_version_id\") = 26 AND substr(\"deployment_version_id\", 1, 1) GLOB '[0-7]' AND \"deployment_version_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deployment_version_number": { + "name": "deployment_version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "text CHECK (\"last_run_id\" = upper(\"last_run_id\") AND length(\"last_run_id\") = 26 AND substr(\"last_run_id\", 1, 1) GLOB '[0-7]' AND \"last_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_seq_cursor": { + "name": "message_seq_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "renamed": { + "name": "renamed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_operation_id": { + "name": "status_operation_id", + "type": "text CHECK (\"status_operation_id\" = upper(\"status_operation_id\") AND length(\"status_operation_id\") = 26 AND substr(\"status_operation_id\", 1, 1) GLOB '[0-7]' AND \"status_operation_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_seq": { + "name": "status_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runtime_event_seq_cursor": { + "name": "runtime_event_seq_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preview'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_checkpoint_required": { + "name": "workspace_checkpoint_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "session_agent_updated_idx": { + "name": "session_agent_updated_idx", + "columns": ["agent_id", "updated_at", "id"], + "isUnique": false + }, + "session_app_creator_archived_updated_idx": { + "name": "session_app_creator_archived_updated_idx", + "columns": ["app_id", "creator_account_id", "archived_at", "updated_at", "id"], + "isUnique": false + }, + "session_app_attributed_archived_updated_idx": { + "name": "session_app_attributed_archived_updated_idx", + "columns": ["app_id", "attributed_user_id", "archived_at", "updated_at", "id"], + "isUnique": false + }, + "session_app_creator_type_archived_updated_idx": { + "name": "session_app_creator_type_archived_updated_idx", + "columns": ["app_id", "creator_account_id", "type", "archived_at", "updated_at", "id"], + "isUnique": false + }, + "session_app_attributed_type_archived_updated_idx": { + "name": "session_app_attributed_type_archived_updated_idx", + "columns": ["app_id", "attributed_user_id", "type", "archived_at", "updated_at", "id"], + "isUnique": false + }, + "session_status_operation_updated_idx": { + "name": "session_status_operation_updated_idx", + "columns": ["status", "status_operation_id", "updated_at"], + "isUnique": false + }, + "session_status_updated_idx": { + "name": "session_status_updated_idx", + "columns": ["status", "updated_at", "id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "session_status_check": { + "name": "session_status_check", + "value": "\"session\".\"status\" IN ('IDLE', 'RUNNING', 'RESCHEDULING', 'TERMINATED')" + }, + "session_status_seq_check": { + "name": "session_status_seq_check", + "value": "\"session\".\"status_seq\" >= 0" + } + } + }, + "session_execution_snapshot": { + "name": "session_execution_snapshot", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_json": { + "name": "plan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_execution_snapshot_session_id_session_id_fk": { + "name": "session_execution_snapshot_session_id_session_id_fk", + "tableFrom": "session_execution_snapshot", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_run_skill": { + "name": "session_run_skill", + "columns": { + "blob_sha256": { + "name": "blob_sha256", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialization_status": { + "name": "materialization_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution_mode": { + "name": "resolution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text CHECK (\"skill_id\" = upper(\"skill_id\") AND length(\"skill_id\") = 26 AND substr(\"skill_id\", 1, 1) GLOB '[0-7]' AND \"skill_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text CHECK (\"snapshot_id\" = upper(\"snapshot_id\") AND length(\"snapshot_id\") = 26 AND substr(\"snapshot_id\", 1, 1) GLOB '[0-7]' AND \"snapshot_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warning_code": { + "name": "warning_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_run_skill_run_resolution_idx": { + "name": "session_run_skill_run_resolution_idx", + "columns": ["session_run_id", "resolution_mode"], + "isUnique": false + } + }, + "foreignKeys": { + "session_run_skill_session_run_id_session_run_id_fk": { + "name": "session_run_skill_session_run_id_session_run_id_fk", + "tableFrom": "session_run_skill", + "tableTo": "session_run", + "columnsFrom": ["session_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_run_skill_session_run_id_skill_id_pk": { + "columns": ["session_run_id", "skill_id"], + "name": "session_run_skill_session_run_id_skill_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_run": { + "name": "session_run", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bound_capability_agent_id": { + "name": "bound_capability_agent_id", + "type": "text CHECK (\"bound_capability_agent_id\" = upper(\"bound_capability_agent_id\") AND length(\"bound_capability_agent_id\") = 26 AND substr(\"bound_capability_agent_id\", 1, 1) GLOB '[0-7]' AND \"bound_capability_agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_capability_app_id": { + "name": "bound_capability_app_id", + "type": "text CHECK (\"bound_capability_app_id\" = upper(\"bound_capability_app_id\") AND length(\"bound_capability_app_id\") = 26 AND substr(\"bound_capability_app_id\", 1, 1) GLOB '[0-7]' AND \"bound_capability_app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_capability_binding_env": { + "name": "bound_capability_binding_env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_capability_binding_name": { + "name": "bound_capability_binding_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_capability_deployment_id": { + "name": "bound_capability_deployment_id", + "type": "text CHECK (\"bound_capability_deployment_id\" = upper(\"bound_capability_deployment_id\") AND length(\"bound_capability_deployment_id\") = 26 AND substr(\"bound_capability_deployment_id\", 1, 1) GLOB '[0-7]' AND \"bound_capability_deployment_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_capability_deployment_run_id": { + "name": "bound_capability_deployment_run_id", + "type": "text CHECK (\"bound_capability_deployment_run_id\" = upper(\"bound_capability_deployment_run_id\") AND length(\"bound_capability_deployment_run_id\") = 26 AND substr(\"bound_capability_deployment_run_id\", 1, 1) GLOB '[0-7]' AND \"bound_capability_deployment_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "text CHECK (\"created_by_account_id\" = upper(\"created_by_account_id\") AND length(\"created_by_account_id\") = 26 AND substr(\"created_by_account_id\", 1, 1) GLOB '[0-7]' AND \"created_by_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text CHECK (\"deployment_version_id\" = upper(\"deployment_version_id\") AND length(\"deployment_version_id\") = 26 AND substr(\"deployment_version_id\", 1, 1) GLOB '[0-7]' AND \"deployment_version_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deployment_version_number": { + "name": "deployment_version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_details_json": { + "name": "error_details_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_event": { + "name": "status_event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'run.queue'" + }, + "status_operation_id": { + "name": "status_operation_id", + "type": "text CHECK (\"status_operation_id\" = upper(\"status_operation_id\") AND length(\"status_operation_id\") = 26 AND substr(\"status_operation_id\", 1, 1) GLOB '[0-7]' AND \"status_operation_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_seq": { + "name": "status_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_source": { + "name": "status_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_run_driver_instance_idx": { + "name": "session_run_driver_instance_idx", + "columns": ["driver_instance_id", "created_at"], + "isUnique": false + }, + "session_run_active_driver_lease_idx": { + "name": "session_run_active_driver_lease_idx", + "columns": ["driver_instance_id"], + "isUnique": true, + "where": "\"session_run\".\"driver_instance_id\" IS NOT NULL AND \"session_run\".\"status\" IN ('queued', 'booting', 'running', 'waiting_input')" + }, + "session_run_session_created_at_idx": { + "name": "session_run_session_created_at_idx", + "columns": ["session_id", "created_at"], + "isUnique": false + }, + "session_run_session_status_idx": { + "name": "session_run_session_status_idx", + "columns": ["session_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "session_run_session_id_session_id_fk": { + "name": "session_run_session_id_session_id_fk", + "tableFrom": "session_run", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "session_run_status_check": { + "name": "session_run_status_check", + "value": "\"session_run\".\"status\" IN ('queued', 'booting', 'running', 'waiting_input', 'completed', 'failed', 'cancelled', 'expired')" + }, + "session_run_status_seq_check": { + "name": "session_run_status_seq_check", + "value": "\"session_run\".\"status_seq\" >= 0" + } + } + }, + "session_event": { + "name": "session_event", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_text": { + "name": "content_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "process_status": { + "name": "process_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "process_type": { + "name": "process_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text CHECK (\"run_id\" = upper(\"run_id\") AND length(\"run_id\") = 26 AND substr(\"run_id\", 1, 1) GLOB '[0-7]' AND \"run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_input_json": { + "name": "tool_input_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_event_agent_family_created_idx": { + "name": "session_event_agent_family_created_idx", + "columns": ["agent_id", "family", "created_at", "id"], + "isUnique": false + }, + "session_event_agent_visibility_created_idx": { + "name": "session_event_agent_visibility_created_idx", + "columns": ["agent_id", "visibility", "created_at", "id"], + "isUnique": false + }, + "session_event_agent_created_idx": { + "name": "session_event_agent_created_idx", + "columns": ["agent_id", "created_at", "id"], + "isUnique": false + }, + "session_event_session_visibility_seq_idx": { + "name": "session_event_session_visibility_seq_idx", + "columns": ["session_id", "visibility", "seq"], + "isUnique": false + }, + "session_event_run_event_type_idx": { + "name": "session_event_run_event_type_idx", + "columns": ["run_id", "event_type"], + "isUnique": false + }, + "session_event_session_seq_idx": { + "name": "session_event_session_seq_idx", + "columns": ["session_id", "seq"], + "isUnique": true + }, + "session_event_session_source_idx": { + "name": "session_event_session_source_idx", + "columns": ["session_id", "source_event_id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_event_session_id_session_id_fk": { + "name": "session_event_session_id_session_id_fk", + "tableFrom": "session_event", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_model_call": { + "name": "session_model_call", + "columns": { + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "call_key": { + "name": "call_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "native_call_id": { + "name": "native_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_cost_usd_micros": { + "name": "total_cost_usd_micros", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_model_call_run_created_idx": { + "name": "session_model_call_run_created_idx", + "columns": ["session_run_id", "created_at"], + "isUnique": false + }, + "session_model_call_session_created_idx": { + "name": "session_model_call_session_created_idx", + "columns": ["session_id", "created_at"], + "isUnique": false + }, + "session_model_call_run_key_idx": { + "name": "session_model_call_run_key_idx", + "columns": ["session_run_id", "call_key"], + "isUnique": true + }, + "session_model_call_native_idx": { + "name": "session_model_call_native_idx", + "columns": ["driver_instance_id", "native_call_id"], + "isUnique": true + } + }, + "foreignKeys": { + "session_model_call_session_id_session_id_fk": { + "name": "session_model_call_session_id_session_id_fk", + "tableFrom": "session_model_call", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_model_call_session_run_id_session_run_id_fk": { + "name": "session_model_call_session_run_id_session_run_id_fk", + "tableFrom": "session_model_call", + "tableTo": "session_run", + "columnsFrom": ["session_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_permission_request": { + "name": "session_permission_request", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_instance_id": { + "name": "driver_instance_id", + "type": "text CHECK (\"driver_instance_id\" = upper(\"driver_instance_id\") AND length(\"driver_instance_id\") = 26 AND substr(\"driver_instance_id\", 1, 1) GLOB '[0-7]' AND \"driver_instance_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_input": { + "name": "raw_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text CHECK (\"run_id\" = upper(\"run_id\") AND length(\"run_id\") = 26 AND substr(\"run_id\", 1, 1) GLOB '[0-7]' AND \"run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_kind": { + "name": "tool_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_permission_request_run_idx": { + "name": "session_permission_request_run_idx", + "columns": ["session_id", "run_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_permission_request_session_id_session_id_fk": { + "name": "session_permission_request_session_id_session_id_fk", + "tableFrom": "session_permission_request", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_permission_request_session_id_request_id_pk": { + "columns": ["session_id", "request_id"], + "name": "session_permission_request_session_id_request_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_readiness_snapshot": { + "name": "session_readiness_snapshot", + "columns": { + "readiness_json": { + "name": "readiness_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_readiness_snapshot_session_id_session_id_fk": { + "name": "session_readiness_snapshot_session_id_session_id_fk", + "tableFrom": "session_readiness_snapshot", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_snapshot_entry": { + "name": "skill_snapshot_entry", + "columns": { + "entry_kind": { + "name": "entry_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_executable": { + "name": "is_executable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text CHECK (\"snapshot_id\" = upper(\"snapshot_id\") AND length(\"snapshot_id\") = 26 AND substr(\"snapshot_id\", 1, 1) GLOB '[0-7]' AND \"snapshot_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_snapshot_entry_snapshot_id_path_pk": { + "columns": ["snapshot_id", "path"], + "name": "skill_snapshot_entry_snapshot_id_path_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_snapshot": { + "name": "skill_snapshot", + "columns": { + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_key": { + "name": "blob_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_sha256": { + "name": "blob_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_size": { + "name": "blob_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_markdown_path": { + "name": "skill_markdown_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uncompressed_size": { + "name": "uncompressed_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "skill_snapshot_app_created_at_idx": { + "name": "skill_snapshot_app_created_at_idx", + "columns": ["app_id", "created_at"], + "isUnique": false + }, + "skill_snapshot_blob_sha256_idx": { + "name": "skill_snapshot_blob_sha256_idx", + "columns": ["app_id", "blob_sha256"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill": { + "name": "skill", + "columns": { + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_snapshot_id": { + "name": "current_snapshot_id", + "type": "text CHECK (\"current_snapshot_id\" = upper(\"current_snapshot_id\") AND length(\"current_snapshot_id\") = 26 AND substr(\"current_snapshot_id\", 1, 1) GLOB '[0-7]' AND \"current_snapshot_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forked_from_owner_name": { + "name": "forked_from_owner_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_from_skill_id": { + "name": "forked_from_skill_id", + "type": "text CHECK (\"forked_from_skill_id\" = upper(\"forked_from_skill_id\") AND length(\"forked_from_skill_id\") = 26 AND substr(\"forked_from_skill_id\", 1, 1) GLOB '[0-7]' AND \"forked_from_skill_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_from_skill_name": { + "name": "forked_from_skill_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text CHECK (\"owner_account_id\" = upper(\"owner_account_id\") AND length(\"owner_account_id\") = 26 AND substr(\"owner_account_id\", 1, 1) GLOB '[0-7]' AND \"owner_account_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "skill_app_updated_at_idx": { + "name": "skill_app_updated_at_idx", + "columns": ["app_id", "updated_at"], + "isUnique": false + }, + "skill_owner_account_updated_at_idx": { + "name": "skill_owner_account_updated_at_idx", + "columns": ["owner_account_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_organization_id": { + "name": "last_active_organization_id", + "type": "text CHECK (\"last_active_organization_id\" = upper(\"last_active_organization_id\") AND length(\"last_active_organization_id\") = 26 AND substr(\"last_active_organization_id\", 1, 1) GLOB '[0-7]' AND \"last_active_organization_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_agent_model": { + "name": "system_agent_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_email_idx": { + "name": "account_email_idx", + "columns": ["email"], + "isUnique": true + }, + "account_last_active_organization_idx": { + "name": "account_last_active_organization_idx", + "columns": ["last_active_organization_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_daily_rollup": { + "name": "usage_daily_rollup", + "columns": { + "actor_user_id": { + "name": "actor_user_id", + "type": "text CHECK (\"actor_user_id\" = upper(\"actor_user_id\") AND length(\"actor_user_id\") = 26 AND substr(\"actor_user_id\", 1, 1) GLOB '[0-7]' AND \"actor_user_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_owner_user_id": { + "name": "agent_owner_user_id", + "type": "text CHECK (\"agent_owner_user_id\" = upper(\"agent_owner_user_id\") AND length(\"agent_owner_user_id\") = 26 AND substr(\"agent_owner_user_id\", 1, 1) GLOB '[0-7]' AND \"agent_owner_user_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_publication_state_at_run": { + "name": "agent_publication_state_at_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text CHECK (\"organization_id\" = upper(\"organization_id\") AND length(\"organization_id\") = 26 AND substr(\"organization_id\", 1, 1) GLOB '[0-7]' AND \"organization_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_purpose": { + "name": "run_purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_cost_usd_micros": { + "name": "total_cost_usd_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unpriced_request_count": { + "name": "unpriced_request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_daily_rollup_app_date_idx": { + "name": "usage_daily_rollup_app_date_idx", + "columns": ["app_id", "date"], + "isUnique": false + }, + "usage_daily_rollup_organization_date_idx": { + "name": "usage_daily_rollup_organization_date_idx", + "columns": ["organization_id", "date"], + "isUnique": false + }, + "usage_daily_rollup_agent_date_idx": { + "name": "usage_daily_rollup_agent_date_idx", + "columns": ["agent_id", "date"], + "isUnique": false + }, + "usage_daily_rollup_actor_date_idx": { + "name": "usage_daily_rollup_actor_date_idx", + "columns": ["actor_user_id", "date"], + "isUnique": false + }, + "usage_daily_rollup_owner_date_idx": { + "name": "usage_daily_rollup_owner_date_idx", + "columns": ["agent_owner_user_id", "date"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_daily_rollup_organization_id_app_id_agent_id_actor_user_id_agent_owner_user_id_date_agent_publication_state_at_run_run_purpose_provider_model_pk": { + "columns": [ + "organization_id", + "app_id", + "agent_id", + "actor_user_id", + "agent_owner_user_id", + "date", + "agent_publication_state_at_run", + "run_purpose", + "provider", + "model" + ], + "name": "usage_daily_rollup_organization_id_app_id_agent_id_actor_user_id_agent_owner_user_id_date_agent_publication_state_at_run_run_purpose_provider_model_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_event_rollup_receipt": { + "name": "usage_event_rollup_receipt", + "columns": { + "rolled_up_at": { + "name": "rolled_up_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_event_rollup_receipt_rolled_up_at_idx": { + "name": "usage_event_rollup_receipt_rolled_up_at_idx", + "columns": ["rolled_up_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_event_rollup_receipt_source_source_event_id_pk": { + "columns": ["source", "source_event_id"], + "name": "usage_event_rollup_receipt_source_source_event_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_event": { + "name": "usage_event", + "columns": { + "actor_user_id": { + "name": "actor_user_id", + "type": "text CHECK (\"actor_user_id\" = upper(\"actor_user_id\") AND length(\"actor_user_id\") = 26 AND substr(\"actor_user_id\", 1, 1) GLOB '[0-7]' AND \"actor_user_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text CHECK (\"agent_id\" = upper(\"agent_id\") AND length(\"agent_id\") = 26 AND substr(\"agent_id\", 1, 1) GLOB '[0-7]' AND \"agent_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_owner_user_id": { + "name": "agent_owner_user_id", + "type": "text CHECK (\"agent_owner_user_id\" = upper(\"agent_owner_user_id\") AND length(\"agent_owner_user_id\") = 26 AND substr(\"agent_owner_user_id\", 1, 1) GLOB '[0-7]' AND \"agent_owner_user_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_publication_state_at_run": { + "name": "agent_publication_state_at_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_revision_id": { + "name": "agent_revision_id", + "type": "text CHECK (\"agent_revision_id\" = upper(\"agent_revision_id\") AND length(\"agent_revision_id\") = 26 AND substr(\"agent_revision_id\", 1, 1) GLOB '[0-7]' AND \"agent_revision_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text CHECK (\"organization_id\" = upper(\"organization_id\") AND length(\"organization_id\") = 26 AND substr(\"organization_id\", 1, 1) GLOB '[0-7]' AND \"organization_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_json": { + "name": "price_snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pricing_status": { + "name": "pricing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_purpose": { + "name": "run_purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text CHECK (\"session_id\" = upper(\"session_id\") AND length(\"session_id\") = 26 AND substr(\"session_id\", 1, 1) GLOB '[0-7]' AND \"session_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_run_id": { + "name": "session_run_id", + "type": "text CHECK (\"session_run_id\" = upper(\"session_run_id\") AND length(\"session_run_id\") = 26 AND substr(\"session_run_id\", 1, 1) GLOB '[0-7]' AND \"session_run_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_cost_usd_micros": { + "name": "total_cost_usd_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usage_contract": { + "name": "usage_contract", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_event_app_created_idx": { + "name": "usage_event_app_created_idx", + "columns": ["app_id", "created_at"], + "isUnique": false + }, + "usage_event_organization_created_idx": { + "name": "usage_event_organization_created_idx", + "columns": ["organization_id", "created_at"], + "isUnique": false + }, + "usage_event_agent_created_idx": { + "name": "usage_event_agent_created_idx", + "columns": ["agent_id", "created_at"], + "isUnique": false + }, + "usage_event_actor_created_idx": { + "name": "usage_event_actor_created_idx", + "columns": ["actor_user_id", "created_at"], + "isUnique": false + }, + "usage_event_owner_created_idx": { + "name": "usage_event_owner_created_idx", + "columns": ["agent_owner_user_id", "created_at"], + "isUnique": false + }, + "usage_event_session_run_idx": { + "name": "usage_event_session_run_idx", + "columns": ["session_run_id"], + "isUnique": false + }, + "usage_event_source_event_idx": { + "name": "usage_event_source_event_idx", + "columns": ["source", "source_event_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendor_credential": { + "name": "vendor_credential", + "columns": { + "api_base": { + "name": "api_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_secret_id": { + "name": "api_key_secret_id", + "type": "text CHECK (\"api_key_secret_id\" = upper(\"api_key_secret_id\") AND length(\"api_key_secret_id\") = 26 AND substr(\"api_key_secret_id\", 1, 1) GLOB '[0-7]' AND \"api_key_secret_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text CHECK (\"id\" = upper(\"id\") AND length(\"id\") = 26 AND substr(\"id\", 1, 1) GLOB '[0-7]' AND \"id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "models": { + "name": "models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text CHECK (\"app_id\" = upper(\"app_id\") AND length(\"app_id\") = 26 AND substr(\"app_id\", 1, 1) GLOB '[0-7]' AND \"app_id\" NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "vendor_credential_app_vendor_idx": { + "name": "vendor_credential_app_vendor_idx", + "columns": ["app_id", "vendor_id"], + "isUnique": false + }, + "vendor_credential_app_vendor_name_idx": { + "name": "vendor_credential_app_vendor_name_idx", + "columns": ["app_id", "vendor_id", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "file_record_listing_idx": { + "columns": { + "lower(\"name\")": { + "isExpression": true + } + } + } + } + } +} diff --git a/pkgs/db/drizzle/meta/_journal.json b/pkgs/db/drizzle/meta/_journal.json index 08e8cec7..4827b362 100644 --- a/pkgs/db/drizzle/meta/_journal.json +++ b/pkgs/db/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1787207755791, "tag": "0011_cattle-terminal-checkpoints", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1787329433230, + "tag": "0012_workspace-api-keys", + "breakpoints": true } ] } diff --git a/pkgs/db/src/schema/auth.schema.ts b/pkgs/db/src/schema/auth.schema.ts index 078a96ad..3428c0cd 100644 --- a/pkgs/db/src/schema/auth.schema.ts +++ b/pkgs/db/src/schema/auth.schema.ts @@ -1,4 +1,10 @@ -import type { AccountId, CliOAuthFlowId, PersonalAccessTokenId } from "@mosoo/id"; +import type { + AccountId, + CliOAuthFlowId, + PersonalAccessTokenId, + AppId, + WorkspaceApiKeyId, +} from "@mosoo/id"; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { platformIdColumn } from "./id-column"; @@ -91,6 +97,28 @@ export const personalAccessTokensTable = sqliteTable( export type PersonalAccessTokenRow = typeof personalAccessTokensTable.$inferSelect; +export const workspaceApiKeysTable = sqliteTable( + "workspace_api_key", + { + accountId: platformIdColumn("account_id").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + id: platformIdColumn("id").primaryKey(), + label: text("label").notNull(), + lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }), + revokedAt: integer("revoked_at", { mode: "timestamp_ms" }), + appId: platformIdColumn("app_id").notNull(), + tokenHash: text("token_hash").notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [ + index("workspace_api_key_app_created_idx").on(table.appId, table.createdAt), + index("workspace_api_key_account_app_idx").on(table.accountId, table.appId), + uniqueIndex("workspace_api_key_hash_idx").on(table.tokenHash), + ], +); + +export type WorkspaceApiKeyRow = typeof workspaceApiKeysTable.$inferSelect; + export const cliOAuthFlowsTable = sqliteTable( "cli_oauth_flow", { diff --git a/pkgs/id/src/index.ts b/pkgs/id/src/index.ts index 49014911..8baf1826 100644 --- a/pkgs/id/src/index.ts +++ b/pkgs/id/src/index.ts @@ -43,6 +43,7 @@ export type SkillId = SemanticPlatformId<"SkillId">; export type SkillSnapshotId = SemanticPlatformId<"SkillSnapshotId">; export type UploadId = SemanticPlatformId<"UploadId">; export type VendorCredentialId = SemanticPlatformId<"VendorCredentialId">; +export type WorkspaceApiKeyId = SemanticPlatformId<"WorkspaceApiKeyId">; export const PLATFORM_ID_PATTERN = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$"; export const PLATFORM_ID_INPUT_PATTERN = "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"; diff --git a/pkgs/runtime-catalog/src/harness-catalog.ts b/pkgs/runtime-catalog/src/harness-catalog.ts new file mode 100644 index 00000000..a602d9de --- /dev/null +++ b/pkgs/runtime-catalog/src/harness-catalog.ts @@ -0,0 +1,99 @@ +import type { HarnessCatalogEntry, HarnessSlug } from "@mosoo/contracts/harness"; + +import { getRuntimeCatalogEntry } from "./runtime-catalog"; + +const HARNESS_VERSION = "2026.08-experiment.1"; + +interface HarnessDefinition { + capabilities: HarnessCatalogEntry["capabilities"]; + description: string; + label: string; + runtimeId: string; + slug: HarnessSlug; +} + +const HARNESS_DEFINITIONS: readonly HarnessDefinition[] = [ + { + capabilities: { + approve: "normalized", + artifacts: "normalized", + cancel: "normalized", + resume: "normalized", + stream: "normalized", + subagents: "native", + }, + description: "Claude Code through the Claude Agent SDK, normalized as a Mosoo Run.", + label: "Claude Code", + runtimeId: "claude-agent-sdk", + slug: "claude-code", + }, + { + capabilities: { + approve: "normalized", + artifacts: "normalized", + cancel: "normalized", + resume: "normalized", + stream: "normalized", + subagents: "native", + }, + description: "OpenAI Codex app-server execution, normalized as a Mosoo Run.", + label: "OpenAI Codex", + runtimeId: "openai-runtime", + slug: "openai-codex", + }, + { + capabilities: { + approve: "normalized", + artifacts: "normalized", + cancel: "normalized", + resume: "normalized", + stream: "normalized", + subagents: "unsupported", + }, + description: "OpenCode over ACP, normalized as a Mosoo Run.", + label: "OpenCode", + runtimeId: "acp-fallback", + slug: "opencode", + }, +] as const; + +function createHarnessCatalogEntry(definition: HarnessDefinition): HarnessCatalogEntry { + const runtime = getRuntimeCatalogEntry(definition.runtimeId); + + if (runtime === null) { + throw new Error( + `Harness ${definition.slug} references unknown runtime ${definition.runtimeId}.`, + ); + } + + const supportedModels = runtime.supportedModelIds ?? [runtime.defaultModel]; + + return { + capabilities: definition.capabilities, + defaultModel: runtime.defaultModel, + description: definition.description, + environment: { + default: "workspace", + repositoryRequired: false, + }, + label: definition.label, + quickstart: `await mosoo.run({ harness: "${definition.slug}", input: "Describe this project" })`, + requiredCredentials: runtime.vendors.map((vendor) => vendor.vendorId), + runtimeId: runtime.runtimeId, + slug: definition.slug, + status: runtime.disabledReason === undefined ? "available" : "unavailable", + supportedModels, + version: HARNESS_VERSION, + }; +} + +export const HARNESS_CATALOG: readonly HarnessCatalogEntry[] = + HARNESS_DEFINITIONS.map(createHarnessCatalogEntry); + +export function listHarnessCatalog(): readonly HarnessCatalogEntry[] { + return HARNESS_CATALOG; +} + +export function getHarnessCatalogEntry(slug: string): HarnessCatalogEntry | null { + return HARNESS_CATALOG.find((entry) => entry.slug === slug) ?? null; +} diff --git a/pkgs/runtime-catalog/src/index.ts b/pkgs/runtime-catalog/src/index.ts index 90e948f5..eb0ec174 100644 --- a/pkgs/runtime-catalog/src/index.ts +++ b/pkgs/runtime-catalog/src/index.ts @@ -1,3 +1,4 @@ export * from "./runtime-catalog"; export * from "./runtime-advanced-settings"; export * from "./runtime-icons"; +export * from "./harness-catalog"; diff --git a/pkgs/runtime-catalog/tests/harness-catalog.test.ts b/pkgs/runtime-catalog/tests/harness-catalog.test.ts new file mode 100644 index 00000000..e8e221bd --- /dev/null +++ b/pkgs/runtime-catalog/tests/harness-catalog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; + +import { getHarnessCatalogEntry, listHarnessCatalog } from "../src"; + +describe("Harness Catalog", () => { + test("maps the two launch harnesses to distinct existing runtimes", () => { + expect(getHarnessCatalogEntry("claude-code")).toMatchObject({ + runtimeId: "claude-agent-sdk", + slug: "claude-code", + status: "available", + }); + expect(getHarnessCatalogEntry("openai-codex")).toMatchObject({ + runtimeId: "openai-runtime", + slug: "openai-codex", + status: "available", + }); + }); + + test("gives every curated harness a frozen version and one-line quickstart", () => { + for (const harness of listHarnessCatalog()) { + expect(harness.version).toBe("2026.08-experiment.1"); + expect(harness.quickstart).toContain(`harness: "${harness.slug}"`); + expect(harness.environment.repositoryRequired).toBeFalse(); + } + }); +}); From 262f645b23e73e46aa298210342e7abded6785c4 Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Wed, 19 Aug 2026 23:09:28 +0800 Subject: [PATCH 3/5] feat(run): launch harnesses without agent records --- apps/api/openapi/public-api-v1.generated.json | 943 +++++++++++++++++- .../routes/public-api-openapi-components.ts | 180 +++- .../http/routes/public-api-openapi.ts | 198 +++- .../http/routes/public-api-route-support.ts | 49 + .../adapters/http/routes/public-api-route.ts | 128 +++ .../http/routes/public-thread-api-request.ts | 9 +- .../http/routes/workspace-run-api-request.ts | 133 +++ .../public-api/public-thread-events.ts | 45 +- .../public-api/workspace-run-api.service.ts | 330 ++++++ .../harness-driver-compatibility.ts | 13 + .../hydrate-run-context.service.ts | 191 ++-- .../session-execution.repository.ts | 55 +- .../session-execution.types.ts | 2 + .../application/session-run.service.ts | 1 + .../create-agent-session.service.ts | 6 + .../create-harness-session.service.ts | 221 ++++ apps/api/tests/workspace-run-api.test.ts | 185 ++++ .../contracts/src/harness/harness.contract.ts | 23 +- pkgs/public-api-client/src/index.ts | 72 ++ .../tests/public-api-client.test.ts | 64 +- 20 files changed, 2766 insertions(+), 82 deletions(-) create mode 100644 apps/api/src/adapters/http/routes/workspace-run-api-request.ts create mode 100644 apps/api/src/modules/public-api/workspace-run-api.service.ts create mode 100644 apps/api/src/modules/runtime/application/harness-driver-compatibility.ts create mode 100644 apps/api/src/modules/runtime/application/session-runs/create-harness-session.service.ts create mode 100644 apps/api/tests/workspace-run-api.test.ts diff --git a/apps/api/openapi/public-api-v1.generated.json b/apps/api/openapi/public-api-v1.generated.json index 9f74a377..59057785 100644 --- a/apps/api/openapi/public-api-v1.generated.json +++ b/apps/api/openapi/public-api-v1.generated.json @@ -875,6 +875,275 @@ }, "required": ["code", "message"], "type": "object" + }, + "HarnessCatalogEntry": { + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "approve": { + "enum": ["native", "normalized", "unsupported"] + }, + "artifacts": { + "enum": ["native", "normalized", "unsupported"] + }, + "cancel": { + "enum": ["native", "normalized", "unsupported"] + }, + "resume": { + "enum": ["native", "normalized", "unsupported"] + }, + "stream": { + "enum": ["native", "normalized", "unsupported"] + }, + "subagents": { + "enum": ["native", "normalized", "unsupported"] + } + }, + "required": ["approve", "artifacts", "cancel", "resume", "stream", "subagents"], + "type": "object" + }, + "defaultModel": { + "type": "string" + }, + "description": { + "type": "string" + }, + "environment": { + "additionalProperties": false, + "properties": { + "default": { + "const": "workspace" + }, + "repositoryRequired": { + "const": false + } + }, + "required": ["default", "repositoryRequired"], + "type": "object" + }, + "label": { + "type": "string" + }, + "quickstart": { + "type": "string" + }, + "requiredCredentials": { + "items": { + "type": "string" + }, + "type": "array" + }, + "runtimeId": { + "type": "string" + }, + "slug": { + "enum": ["claude-code", "openai-codex", "opencode"] + }, + "status": { + "enum": ["available", "unavailable"] + }, + "supportedModels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "version": { + "type": "string" + } + }, + "required": [ + "capabilities", + "defaultModel", + "description", + "environment", + "label", + "quickstart", + "requiredCredentials", + "runtimeId", + "slug", + "status", + "supportedModels", + "version" + ], + "type": "object" + }, + "WorkspaceRunRequest": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "agent": { + "minLength": 1, + "type": "string" + }, + "input": {} + }, + "required": ["agent", "input"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "environment": { + "minLength": 1, + "type": "string" + }, + "harness": { + "enum": ["claude-code", "openai-codex", "opencode"] + }, + "input": {}, + "model": { + "minLength": 1, + "type": "string" + } + }, + "required": ["harness", "input"], + "type": "object" + } + ] + }, + "WorkspaceRunResponse": { + "additionalProperties": false, + "properties": { + "environment": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revisionId": { + "type": "string" + } + }, + "required": ["id", "name", "revisionId"], + "type": "object" + }, + "id": { + "type": "string" + }, + "links": { + "additionalProperties": false, + "properties": { + "approve": { + "type": "string" + }, + "artifacts": { + "type": "string" + }, + "cancel": { + "type": "string" + }, + "events": { + "type": "string" + }, + "result": { + "type": "string" + }, + "stream": { + "type": "string" + } + }, + "required": ["approve", "artifacts", "cancel", "events", "result", "stream"], + "type": "object" + }, + "model": { + "type": "string" + }, + "source": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "agentId": { + "type": "string" + }, + "agentVersionId": { + "type": ["string", "null"] + }, + "agentVersionNumber": { + "type": ["integer", "null"] + }, + "kind": { + "const": "agent" + } + }, + "required": ["agentId", "agentVersionId", "agentVersionNumber", "kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "harness": { + "enum": ["claude-code", "openai-codex", "opencode"] + }, + "kind": { + "const": "harness" + }, + "version": { + "type": "string" + } + }, + "required": ["harness", "kind", "version"], + "type": "object" + } + ] + }, + "status": { + "enum": [ + "queued", + "booting", + "running", + "waiting_input", + "completed", + "failed", + "cancelled", + "expired" + ] + }, + "threadId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + } + }, + "required": [ + "environment", + "id", + "links", + "model", + "source", + "status", + "threadId", + "workspaceId" + ], + "type": "object" + }, + "WorkspaceRunResultResponse": { + "additionalProperties": false, + "properties": { + "output": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunFinalOutput" + }, + { + "type": "null" + } + ] + }, + "run": { + "$ref": "#/components/schemas/WorkspaceRunResponse" + } + }, + "required": ["output", "run"], + "type": "object" } }, "securitySchemes": { @@ -883,16 +1152,686 @@ "description": "Use Authorization: Bearer mst_... . Access Tokens identify an account and do not carry scopes.", "scheme": "bearer", "type": "http" + }, + "workspaceApiKey": { + "bearerFormat": "mosoo Workspace API key", + "description": "Use Authorization: Bearer msk_... . A Workspace API key can start and control Runs only in its bound Workspace.", + "scheme": "bearer", + "type": "http" } } }, "info": { - "description": "Public HTTPS API for creating and retrieving Threads on mosoo Agent API Endpoints. v1 resource identifiers are bare ULIDs, not prefixed IDs. Access Tokens identify the account caller. Runtime execution resolves the Agent API Endpoint owner's capabilities while the Thread is attributed to the token owner.", - "title": "mosoo Public Thread API", + "description": "Public HTTPS API for starting Workspace-scoped Runs from curated Harnesses or published Agents, plus the legacy Thread surface. v1 resource identifiers are bare ULIDs. Workspace API keys use the msk_ prefix; account Access Tokens use mst_.", + "title": "mosoo Run API", "version": "v1" }, "openapi": "3.1.0", "paths": { + "/harnesses": { + "get": { + "description": "Lists curated coding-agent Harnesses and the normalized capabilities each exposes through the Run API.", + "security": [], + "summary": "List Harnesses", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "harnesses": { + "items": { + "$ref": "#/components/schemas/HarnessCatalogEntry" + }, + "type": "array" + } + }, + "required": ["harnesses"], + "type": "object" + } + } + }, + "description": "Harness catalog." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/harnesses/{slug}": { + "get": { + "description": "Returns one curated Harness and its normalized capabilities.", + "parameters": [ + { + "description": "Curated Harness slug.", + "example": "openai-codex", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "enum": ["claude-code", "openai-codex", "opencode"], + "type": "string" + } + } + ], + "security": [], + "summary": "Retrieve a Harness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "harness": { + "$ref": "#/components/schemas/HarnessCatalogEntry" + } + }, + "required": ["harness"], + "type": "object" + } + } + }, + "description": "Harness catalog entry." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs": { + "post": { + "description": "Creates an isolated Cattle Run from exactly one source: a curated Harness or a published Workspace Agent. Harness Runs do not create Agent records. The selected source, model, and Environment revision are frozen into the Run snapshot.", + "requestBody": { + "content": { + "application/json": { + "examples": { + "harness": { + "summary": "Run a curated Harness", + "value": { + "harness": "openai-codex", + "input": "Review this repository" + } + }, + "agent": { + "summary": "Run a published Workspace Agent", + "value": { + "agent": "researcher", + "input": { + "query": "Summarize Q2 notes" + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/WorkspaceRunRequest" + } + } + }, + "required": true + }, + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Create a Run", + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRunResponse" + } + } + }, + "description": "Created Run." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}": { + "get": { + "description": "Returns the Run state and its immutable source and Environment snapshots.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Retrieve a Run", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRunResponse" + } + } + }, + "description": "Run state." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/approve": { + "post": { + "description": "Allows or rejects one pending permission request for the Run.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "decision": { + "enum": ["allow_once", "reject_once"] + }, + "requestId": { + "minLength": 1, + "type": "string" + } + }, + "required": ["decision", "requestId"], + "type": "object" + } + } + }, + "required": true + }, + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Approve or reject a Run action", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "const": true + } + }, + "required": ["ok"], + "type": "object" + } + } + }, + "description": "Permission decision accepted." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/artifacts": { + "get": { + "description": "Lists ready artifacts produced in this Run's isolated session.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "List Run artifacts", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/PublicFile" + }, + "type": "array" + } + }, + "required": ["artifacts"], + "type": "object" + } + } + }, + "description": "Run artifact list." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/cancel": { + "post": { + "description": "Cancels the Run if it is active and returns its latest state.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Cancel a Run", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRunResponse" + } + } + }, + "description": "Cancelled Run." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/events": { + "get": { + "description": "Returns normalized, public-safe Run events in chronological order.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + }, + { + "description": "Maximum number of latest Thread events to return.", + "example": 100, + "in": "query", + "name": "limit", + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "List Run events", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventListResponse" + } + } + }, + "description": "Run event list." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/events/stream": { + "get": { + "description": "Streams normalized Run events as Server-Sent Events.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + }, + { + "description": "Maximum number of latest Thread events to return.", + "example": 100, + "in": "query", + "name": "limit", + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Stream Run events", + "responses": { + "200": { + "content": { + "text/event-stream": { + "example": ": connected\n\nevent: thread.event\nid: 01J00000000000000000000010\ndata: {\"id\":\"01J00000000000000000000010\",\"runId\":\"01J0000000000000000000000A\",\"type\":\"run.started\",\"status\":\"available\",\"content\":\"01J0000000000000000000000A\",\"occurredAt\":\"2026-05-19T00:00:01.000Z\",\"durationMs\":null,\"tokens\":null}\n\n", + "schema": { + "type": "string" + } + } + }, + "description": "Run event stream." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/runs/{runId}/result": { + "get": { + "description": "Returns the latest Run state and its canonical final assistant output when available.", + "parameters": [ + { + "description": "Run ID returned by POST /runs.", + "example": "01J0000000000000000000000A", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "example": "01J0000000000000000000000A", + "format": "ulid", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + "type": "string", + "x-default": "01J0000000000000000000000A" + } + } + ], + "security": [ + { + "workspaceApiKey": [] + } + ], + "summary": "Retrieve a Run result", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRunResultResponse" + } + } + }, + "description": "Run result." + }, + "400": { + "$ref": "#/components/responses/InvalidRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/agents/{agentId}/files": { "post": { "description": "Uploads a file into the Agent API Endpoint's App draft scope before a Thread exists. Use the returned file ID in create-thread or send-events resources.", diff --git a/apps/api/src/adapters/http/routes/public-api-openapi-components.ts b/apps/api/src/adapters/http/routes/public-api-openapi-components.ts index 4da0ed84..9efe33da 100644 --- a/apps/api/src/adapters/http/routes/public-api-openapi-components.ts +++ b/apps/api/src/adapters/http/routes/public-api-openapi-components.ts @@ -1,5 +1,173 @@ import { PUBLIC_API_OPENAPI_SCHEMAS } from "@mosoo/contracts/public-api"; +const WORKSPACE_RUN_SOURCE_SCHEMA = { + oneOf: [ + { + additionalProperties: false, + properties: { + agentId: { type: "string" }, + agentVersionId: { type: ["string", "null"] }, + agentVersionNumber: { type: ["integer", "null"] }, + kind: { const: "agent" }, + }, + required: ["agentId", "agentVersionId", "agentVersionNumber", "kind"], + type: "object", + }, + { + additionalProperties: false, + properties: { + harness: { enum: ["claude-code", "openai-codex", "opencode"] }, + kind: { const: "harness" }, + version: { type: "string" }, + }, + required: ["harness", "kind", "version"], + type: "object", + }, + ], +}; + +const WORKSPACE_API_SCHEMAS = { + HarnessCatalogEntry: { + additionalProperties: false, + properties: { + capabilities: { + additionalProperties: false, + properties: Object.fromEntries( + ["approve", "artifacts", "cancel", "resume", "stream", "subagents"].map((name) => [ + name, + { enum: ["native", "normalized", "unsupported"] }, + ]), + ), + required: ["approve", "artifacts", "cancel", "resume", "stream", "subagents"], + type: "object", + }, + defaultModel: { type: "string" }, + description: { type: "string" }, + environment: { + additionalProperties: false, + properties: { + default: { const: "workspace" }, + repositoryRequired: { const: false }, + }, + required: ["default", "repositoryRequired"], + type: "object", + }, + label: { type: "string" }, + quickstart: { type: "string" }, + requiredCredentials: { items: { type: "string" }, type: "array" }, + runtimeId: { type: "string" }, + slug: { enum: ["claude-code", "openai-codex", "opencode"] }, + status: { enum: ["available", "unavailable"] }, + supportedModels: { items: { type: "string" }, type: "array" }, + version: { type: "string" }, + }, + required: [ + "capabilities", + "defaultModel", + "description", + "environment", + "label", + "quickstart", + "requiredCredentials", + "runtimeId", + "slug", + "status", + "supportedModels", + "version", + ], + type: "object", + }, + WorkspaceRunRequest: { + oneOf: [ + { + additionalProperties: false, + properties: { + agent: { minLength: 1, type: "string" }, + input: {}, + }, + required: ["agent", "input"], + type: "object", + }, + { + additionalProperties: false, + properties: { + environment: { minLength: 1, type: "string" }, + harness: { enum: ["claude-code", "openai-codex", "opencode"] }, + input: {}, + model: { minLength: 1, type: "string" }, + }, + required: ["harness", "input"], + type: "object", + }, + ], + }, + WorkspaceRunResponse: { + additionalProperties: false, + properties: { + environment: { + additionalProperties: false, + properties: { + id: { type: "string" }, + name: { type: "string" }, + revisionId: { type: "string" }, + }, + required: ["id", "name", "revisionId"], + type: "object", + }, + id: { type: "string" }, + links: { + additionalProperties: false, + properties: Object.fromEntries( + ["approve", "artifacts", "cancel", "events", "result", "stream"].map((name) => [ + name, + { type: "string" }, + ]), + ), + required: ["approve", "artifacts", "cancel", "events", "result", "stream"], + type: "object", + }, + model: { type: "string" }, + source: WORKSPACE_RUN_SOURCE_SCHEMA, + status: { + enum: [ + "queued", + "booting", + "running", + "waiting_input", + "completed", + "failed", + "cancelled", + "expired", + ], + }, + threadId: { type: "string" }, + workspaceId: { type: "string" }, + }, + required: [ + "environment", + "id", + "links", + "model", + "source", + "status", + "threadId", + "workspaceId", + ], + type: "object", + }, + WorkspaceRunResultResponse: { + additionalProperties: false, + properties: { + output: { + oneOf: [{ $ref: "#/components/schemas/RunFinalOutput" }, { type: "null" }], + }, + run: { $ref: "#/components/schemas/WorkspaceRunResponse" }, + }, + required: ["output", "run"], + type: "object", + }, +} satisfies Record>; + function jsonResponse(description: string, schema: Record) { return { content: { @@ -51,7 +219,10 @@ export function createPublicApiOpenApiComponents() { $ref: "#/components/schemas/ErrorResponse", }), }, - schemas: PUBLIC_API_OPENAPI_SCHEMAS, + schemas: { + ...PUBLIC_API_OPENAPI_SCHEMAS, + ...WORKSPACE_API_SCHEMAS, + }, securitySchemes: { accessToken: { bearerFormat: "mosoo Access Token", @@ -60,6 +231,13 @@ export function createPublicApiOpenApiComponents() { scheme: "bearer", type: "http", }, + workspaceApiKey: { + bearerFormat: "mosoo Workspace API key", + description: + "Use Authorization: Bearer msk_... . A Workspace API key can start and control Runs only in its bound Workspace.", + scheme: "bearer", + type: "http", + }, }, }; } diff --git a/apps/api/src/adapters/http/routes/public-api-openapi.ts b/apps/api/src/adapters/http/routes/public-api-openapi.ts index 4d15dce9..e3acfbc5 100644 --- a/apps/api/src/adapters/http/routes/public-api-openapi.ts +++ b/apps/api/src/adapters/http/routes/public-api-openapi.ts @@ -11,7 +11,7 @@ import { import { createPublicApiOpenApiComponents } from "./public-api-openapi-components"; type HttpMethod = "delete" | "get" | "post" | "put"; -type AccessTokenSecurity = { accessToken: [] }; +type SecurityRequirement = { accessToken?: []; workspaceApiKey?: [] }; interface OpenApiParameter { description?: string; @@ -27,7 +27,7 @@ interface OpenApiOperation { parameters?: OpenApiParameter[]; requestBody?: Record; responses: Record; - security?: AccessTokenSecurity[]; + security?: SecurityRequirement[]; summary: string; } @@ -42,14 +42,16 @@ interface PublicApiOpenApiDocument { }; openapi: "3.1.0"; paths: OpenApiPaths; - security: AccessTokenSecurity[]; + security: SecurityRequirement[]; servers: { url: string }[]; } const EXAMPLE_AGENT_ID = "01J00000000000000000000001"; const EXAMPLE_THREAD_ID = "01J00000000000000000000009"; const EXAMPLE_FILE_ID = "01J0000000000000000000000J"; -const ACCESS_TOKEN_SECURITY: AccessTokenSecurity[] = [{ accessToken: [] }]; +const EXAMPLE_RUN_ID = "01J0000000000000000000000A"; +const ACCESS_TOKEN_SECURITY: SecurityRequirement[] = [{ accessToken: [] }]; +const WORKSPACE_API_KEY_SECURITY: SecurityRequirement[] = [{ workspaceApiKey: [] }]; const EXAMPLE_SESSION_FILE = { committed: true, @@ -105,6 +107,24 @@ const fileIdParameter = platformIdPathParameter({ name: "fileId", }); +const runIdParameter = platformIdPathParameter({ + description: "Run ID returned by POST /runs.", + example: EXAMPLE_RUN_ID, + name: "runId", +}); + +const harnessSlugParameter = { + description: "Curated Harness slug.", + example: "openai-codex", + in: "path", + name: "slug", + required: true, + schema: { + enum: ["claude-code", "openai-codex", "opencode"], + type: "string", + }, +} satisfies OpenApiParameter; + const idempotencyKeyParameter = { description: "Optional key for retry-safe create-thread and send-events calls. Reusing the same key with the same request returns the original response. Reusing the key while the original request is still processing returns 409.", @@ -314,6 +334,172 @@ function operation( export function createPublicApiOpenApiDocument(origin: string): PublicApiOpenApiDocument { const paths = { + "/harnesses": { + get: operation({ + description: + "Lists curated coding-agent Harnesses and the normalized capabilities each exposes through the Run API.", + security: [], + success: { + "200": jsonResponse("Harness catalog.", { + additionalProperties: false, + properties: { + harnesses: { + items: { $ref: "#/components/schemas/HarnessCatalogEntry" }, + type: "array", + }, + }, + required: ["harnesses"], + type: "object", + }), + }, + summary: "List Harnesses", + }), + }, + "/harnesses/{slug}": { + get: operation({ + description: "Returns one curated Harness and its normalized capabilities.", + parameters: [harnessSlugParameter], + security: [], + success: { + "200": jsonResponse("Harness catalog entry.", { + additionalProperties: false, + properties: { + harness: { $ref: "#/components/schemas/HarnessCatalogEntry" }, + }, + required: ["harness"], + type: "object", + }), + }, + summary: "Retrieve a Harness", + }), + }, + "/runs": { + post: operation({ + description: + "Creates an isolated Cattle Run from exactly one source: a curated Harness or a published Workspace Agent. Harness Runs do not create Agent records. The selected source, model, and Environment revision are frozen into the Run snapshot.", + requestBody: jsonRequestBodyExamples( + { $ref: "#/components/schemas/WorkspaceRunRequest" }, + { + harness: { + summary: "Run a curated Harness", + value: { harness: "openai-codex", input: "Review this repository" }, + }, + agent: { + summary: "Run a published Workspace Agent", + value: { agent: "researcher", input: { query: "Summarize Q2 notes" } }, + }, + }, + ), + security: WORKSPACE_API_KEY_SECURITY, + success: { + "201": jsonResponse("Created Run.", { + $ref: "#/components/schemas/WorkspaceRunResponse", + }), + }, + summary: "Create a Run", + }), + }, + "/runs/{runId}": { + get: operation({ + description: "Returns the Run state and its immutable source and Environment snapshots.", + parameters: [runIdParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { + "200": jsonResponse("Run state.", { + $ref: "#/components/schemas/WorkspaceRunResponse", + }), + }, + summary: "Retrieve a Run", + }), + }, + "/runs/{runId}/approve": { + post: operation({ + description: "Allows or rejects one pending permission request for the Run.", + parameters: [runIdParameter], + requestBody: jsonRequestBody({ + additionalProperties: false, + properties: { + decision: { enum: ["allow_once", "reject_once"] }, + requestId: { minLength: 1, type: "string" }, + }, + required: ["decision", "requestId"], + type: "object", + }), + security: WORKSPACE_API_KEY_SECURITY, + success: { "200": okResponse("Permission decision accepted.") }, + summary: "Approve or reject a Run action", + }), + }, + "/runs/{runId}/artifacts": { + get: operation({ + description: "Lists ready artifacts produced in this Run's isolated session.", + parameters: [runIdParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { + "200": jsonResponse("Run artifact list.", { + additionalProperties: false, + properties: { + artifacts: { + items: { $ref: "#/components/schemas/PublicFile" }, + type: "array", + }, + }, + required: ["artifacts"], + type: "object", + }), + }, + summary: "List Run artifacts", + }), + }, + "/runs/{runId}/cancel": { + post: operation({ + description: "Cancels the Run if it is active and returns its latest state.", + parameters: [runIdParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { + "200": jsonResponse("Cancelled Run.", { + $ref: "#/components/schemas/WorkspaceRunResponse", + }), + }, + summary: "Cancel a Run", + }), + }, + "/runs/{runId}/events": { + get: operation({ + description: "Returns normalized, public-safe Run events in chronological order.", + parameters: [runIdParameter, threadEventsLimitParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { + "200": jsonResponse("Run event list.", { + $ref: "#/components/schemas/ThreadEventListResponse", + }), + }, + summary: "List Run events", + }), + }, + "/runs/{runId}/events/stream": { + get: operation({ + description: "Streams normalized Run events as Server-Sent Events.", + parameters: [runIdParameter, threadEventsLimitParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { "200": textEventStreamResponse("Run event stream.") }, + summary: "Stream Run events", + }), + }, + "/runs/{runId}/result": { + get: operation({ + description: + "Returns the latest Run state and its canonical final assistant output when available.", + parameters: [runIdParameter], + security: WORKSPACE_API_KEY_SECURITY, + success: { + "200": jsonResponse("Run result.", { + $ref: "#/components/schemas/WorkspaceRunResultResponse", + }), + }, + summary: "Retrieve a Run result", + }), + }, "/agents/{agentId}/files": { post: operation({ description: @@ -583,8 +769,8 @@ export function createPublicApiOpenApiDocument(origin: string): PublicApiOpenApi components: createPublicApiOpenApiComponents(), info: { description: - "Public HTTPS API for creating and retrieving Threads on mosoo Agent API Endpoints. v1 resource identifiers are bare ULIDs, not prefixed IDs. Access Tokens identify the account caller. Runtime execution resolves the Agent API Endpoint owner's capabilities while the Thread is attributed to the token owner.", - title: "mosoo Public Thread API", + "Public HTTPS API for starting Workspace-scoped Runs from curated Harnesses or published Agents, plus the legacy Thread surface. v1 resource identifiers are bare ULIDs. Workspace API keys use the msk_ prefix; account Access Tokens use mst_.", + title: "mosoo Run API", version: PUBLIC_API_VERSION, }, openapi: "3.1.0", diff --git a/apps/api/src/adapters/http/routes/public-api-route-support.ts b/apps/api/src/adapters/http/routes/public-api-route-support.ts index 2abcaeec..be943202 100644 --- a/apps/api/src/adapters/http/routes/public-api-route-support.ts +++ b/apps/api/src/adapters/http/routes/public-api-route-support.ts @@ -10,6 +10,11 @@ import type { DeploymentCapabilityPublicApiCaller, PublicApiCaller, } from "../../../modules/auth/application/public-api-caller.service"; +import { + authenticateWorkspaceApiKey, + readWorkspaceApiKey, +} from "../../../modules/auth/application/workspace-api-key.service"; +import type { WorkspaceApiKeyCaller } from "../../../modules/auth/application/workspace-api-key.service"; import { FileControlError } from "../../../modules/files/application/file-control-errors"; import { admitDeploymentCapability, @@ -110,6 +115,25 @@ async function requireAccessTokenCaller(c: PublicApiRouteContext): Promise { + const value = readWorkspaceApiKey(c.req.raw); + + if (!isTruthy(value)) { + throw publicUnauthenticated("A valid Workspace API key is required."); + } + + const caller = await authenticateWorkspaceApiKey(c.env.DB, value); + + if (caller === null) { + throw publicUnauthenticated("Workspace API key is invalid or revoked."); + } + + await enforcePublicApiRateLimit(c.env.DB, caller.keyId); + return caller; +} + async function requirePublicApiCaller(c: PublicApiRouteContext): Promise { const token = readPublicApiBearerToken(c.req.raw); @@ -451,6 +475,31 @@ export async function runPublicApiAuthenticatedResponse( } } +export async function runWorkspaceApiAuthenticatedJson( + c: PublicApiRouteContext, + operation: (caller: WorkspaceApiKeyCaller) => Promise, + status = 200, +): Promise { + try { + const caller = await requireRateLimitedWorkspaceApiKeyCaller(c); + return Response.json(await operation(caller), { status }); + } catch (error) { + return toErrorResponse(error); + } +} + +export async function runWorkspaceApiAuthenticatedResponse( + c: PublicApiRouteContext, + operation: (caller: WorkspaceApiKeyCaller) => Promise, +): Promise { + try { + const caller = await requireRateLimitedWorkspaceApiKeyCaller(c); + return await operation(caller); + } catch (error) { + return toErrorResponse(error); + } +} + export async function runPublicApiSessionMutation( c: PublicApiRouteContext, input: PublicApiCallerOptions & { diff --git a/apps/api/src/adapters/http/routes/public-api-route.ts b/apps/api/src/adapters/http/routes/public-api-route.ts index ae03221a..4e47876c 100644 --- a/apps/api/src/adapters/http/routes/public-api-route.ts +++ b/apps/api/src/adapters/http/routes/public-api-route.ts @@ -1,5 +1,6 @@ import { PUBLIC_API_VERSION_PREFIX } from "@mosoo/contracts/public-api"; import type { AgentId, PublicThreadId } from "@mosoo/id"; +import { getHarnessCatalogEntry, listHarnessCatalog } from "@mosoo/runtime-catalog"; import { Hono } from "hono"; import type { Context } from "hono"; @@ -23,6 +24,8 @@ import { runPublicApiThreadMutation, runPublicApiThreadReadJson, runPublicApiThreadReadResponse, + runWorkspaceApiAuthenticatedJson, + runWorkspaceApiAuthenticatedResponse, } from "./public-api-route-support"; import type { PublicApiCallerOptions } from "./public-api-route-support"; import { @@ -30,6 +33,7 @@ import { parseOptionalBoolean, parseAgentIdParam, parseFileIdParam, + parseRunIdParam, parseThreadIdParam, parseThreadEventsLimit, readBoundAgentCallRequestBody, @@ -37,6 +41,10 @@ import { readSendEventsRequest, } from "./public-thread-api-request"; import type { ParsedCreateThreadRequest } from "./public-thread-api-request"; +import { + readCreateWorkspaceRunRequest, + readWorkspaceRunApprovalRequest, +} from "./workspace-run-api-request"; type PublicApiRouteContext = Context; interface PublicAgentFileUploadRequest { @@ -108,6 +116,10 @@ async function loadBoundAgentAskService() { return import("../../../modules/public-api/app-agent-bound-ask.service"); } +async function loadWorkspaceRunService() { + return import("../../../modules/public-api/workspace-run-api.service"); +} + async function runPublicThreadFileRoute( c: PublicApiRouteContext, scope: PublicThreadRouteScope, @@ -418,6 +430,122 @@ export function registerPublicApiRoute(app: Hono) { v1.get("/openapi.json", (c) => c.json(createPublicApiOpenApiDocument(new URL(c.req.url).origin))); + v1.get("/harnesses", (c) => c.json({ harnesses: listHarnessCatalog() })); + + v1.get("/harnesses/:slug", (c) => { + const harness = getHarnessCatalogEntry(c.req.param("slug")); + + return harness === null + ? c.json({ error: { code: "not_found", message: "Harness was not found." } }, 404) + : c.json({ harness }); + }); + + v1.post("/runs", (c) => + runWorkspaceApiAuthenticatedJson( + c, + async (caller) => { + const service = await loadWorkspaceRunService(); + return service.startWorkspaceRun({ + bindings: c.env, + caller, + executionContext: c.executionCtx, + input: await readCreateWorkspaceRunRequest(c), + requestUrl: c.req.url, + }); + }, + 201, + ), + ); + + v1.get("/runs/:runId", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.retrieveWorkspaceRun(c.env.DB, caller, parseRunIdParam(c.req.param("runId"))); + }), + ); + + v1.get("/runs/:runId/events", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.listWorkspaceRunEvents({ + caller, + database: c.env.DB, + limit: parseThreadEventsLimit(c.req.query("limit")), + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.get("/runs/:runId/events/stream", (c) => + runWorkspaceApiAuthenticatedResponse(c, async (caller) => { + const service = await loadWorkspaceRunService(); + const stream = await service.streamWorkspaceRunEvents({ + bindings: c.env, + caller, + limit: parseThreadEventsLimit(c.req.query("limit")), + runId: parseRunIdParam(c.req.param("runId")), + signal: c.req.raw.signal, + }); + + return new Response(stream, { + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no", + }, + }); + }), + ); + + v1.get("/runs/:runId/result", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.retrieveWorkspaceRunResult({ + caller, + database: c.env.DB, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.get("/runs/:runId/artifacts", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.listWorkspaceRunArtifacts({ + bindings: c.env, + caller, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.post("/runs/:runId/approve", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + const approval = await readWorkspaceRunApprovalRequest(c); + return service.approveWorkspaceRun({ + bindings: c.env, + caller, + decision: approval.decision, + executionContext: c.executionCtx, + requestId: approval.requestId, + requestUrl: c.req.url, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.post("/runs/:runId/cancel", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.cancelWorkspaceRun({ + bindings: c.env, + caller, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + // The blocking bound-agent ask: POST the injected capability URL itself. v1.post(BOUND_CAPABILITY_ROUTE_BASE, async (c) => { try { diff --git a/apps/api/src/adapters/http/routes/public-thread-api-request.ts b/apps/api/src/adapters/http/routes/public-thread-api-request.ts index 5c820246..777f93b7 100644 --- a/apps/api/src/adapters/http/routes/public-thread-api-request.ts +++ b/apps/api/src/adapters/http/routes/public-thread-api-request.ts @@ -112,7 +112,10 @@ async function readRequestTextWithLimit(request: Request, maxBytes: number): Pro } } -async function readJsonBodyWithLimit(c: RawJsonRequestContext, maxBytes: number): Promise { +export async function readJsonBodyWithLimit( + c: RawJsonRequestContext, + maxBytes: number, +): Promise { return JSON.parse(await readRequestTextWithLimit(c.req.raw, maxBytes)); } @@ -200,6 +203,10 @@ export function parseThreadIdParam(value: string): PublicThreadId { return parsePublicPlatformId(value, "Thread ID") as PublicThreadId; } +export function parseRunIdParam(value: string): SessionRunId { + return parsePublicPlatformId(value, "Run ID") as SessionRunId; +} + export function parseFileIdParam(value: string): FileId { return parsePublicPlatformId(value, "File ID") as FileId; } diff --git a/apps/api/src/adapters/http/routes/workspace-run-api-request.ts b/apps/api/src/adapters/http/routes/workspace-run-api-request.ts new file mode 100644 index 00000000..84aa3c38 --- /dev/null +++ b/apps/api/src/adapters/http/routes/workspace-run-api-request.ts @@ -0,0 +1,133 @@ +import type { CreateWorkspaceRunRequest, HarnessSlug, RunInput } from "@mosoo/contracts/harness"; +import { HARNESS_SLUGS } from "@mosoo/contracts/harness"; +import { PUBLIC_THREAD_JSON_BODY_MAX_BYTES } from "@mosoo/contracts/public-api"; + +import { publicInvalidRequest } from "../../../modules/public-api/public-api-errors"; +import { readJsonBodyWithLimit } from "./public-thread-api-request"; + +interface RawJsonRequestContext { + req: { + raw: Request; + }; +} + +export interface WorkspaceRunApprovalRequest { + decision: "allow_once" | "reject_once"; + requestId: string; +} + +const RUN_FIELDS: ReadonlySet = new Set([ + "agent", + "environment", + "harness", + "input", + "model", +]); +const APPROVAL_FIELDS: ReadonlySet = new Set(["decision", "requestId"]); + +function readObject(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw publicInvalidRequest("Request body must be a JSON object."); + } + + return value as Record; +} + +function assertOnlyFields( + input: Record, + allowedFields: ReadonlySet, +): void { + for (const field of Object.keys(input)) { + if (!allowedFields.has(field)) { + throw publicInvalidRequest(`Unsupported request field: ${field}.`); + } + } +} + +function readOptionalNonEmptyString( + input: Record, + field: string, +): string | undefined { + const value = input[field]; + + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string" || value.trim().length === 0) { + throw publicInvalidRequest(`${field} must be a non-empty string.`); + } + + return value.trim(); +} + +function readRequiredNonEmptyString(input: Record, field: string): string { + const value = readOptionalNonEmptyString(input, field); + + if (value === undefined) { + throw publicInvalidRequest(`${field} is required.`); + } + + return value; +} + +function readHarnessSlug(value: string): HarnessSlug { + if ((HARNESS_SLUGS as readonly string[]).includes(value)) { + return value as HarnessSlug; + } + + throw publicInvalidRequest(`harness must be one of: ${HARNESS_SLUGS.join(", ")}.`); +} + +export async function readCreateWorkspaceRunRequest( + c: RawJsonRequestContext, +): Promise { + const body = readObject(await readJsonBodyWithLimit(c, PUBLIC_THREAD_JSON_BODY_MAX_BYTES)); + assertOnlyFields(body, RUN_FIELDS); + + if (!Object.hasOwn(body, "input")) { + throw publicInvalidRequest("input is required."); + } + + const input = body["input"] as RunInput; + const agent = readOptionalNonEmptyString(body, "agent"); + const harnessValue = readOptionalNonEmptyString(body, "harness"); + + if ((agent === undefined) === (harnessValue === undefined)) { + throw publicInvalidRequest("Exactly one of agent or harness is required."); + } + + if (agent !== undefined) { + if (body["environment"] !== undefined || body["model"] !== undefined) { + throw publicInvalidRequest("environment and model can only be used with harness."); + } + + return { agent, input }; + } + + const environment = readOptionalNonEmptyString(body, "environment"); + const model = readOptionalNonEmptyString(body, "model"); + const harness = readHarnessSlug(harnessValue as string); + + return { + ...(environment === undefined ? {} : { environment }), + harness, + input, + ...(model === undefined ? {} : { model }), + }; +} + +export async function readWorkspaceRunApprovalRequest( + c: RawJsonRequestContext, +): Promise { + const body = readObject(await readJsonBodyWithLimit(c, PUBLIC_THREAD_JSON_BODY_MAX_BYTES)); + assertOnlyFields(body, APPROVAL_FIELDS); + const requestId = readRequiredNonEmptyString(body, "requestId"); + const decision = body["decision"]; + + if (decision !== "allow_once" && decision !== "reject_once") { + throw publicInvalidRequest("decision must be allow_once or reject_once."); + } + + return { decision, requestId }; +} diff --git a/apps/api/src/modules/public-api/public-thread-events.ts b/apps/api/src/modules/public-api/public-thread-events.ts index 0e250ba4..9e0867be 100644 --- a/apps/api/src/modules/public-api/public-thread-events.ts +++ b/apps/api/src/modules/public-api/public-thread-events.ts @@ -19,6 +19,7 @@ import { and, asc, desc, eq, gt, lt } from "drizzle-orm"; import type { SQL } from "drizzle-orm"; import { createErrorLogContext, logWarn } from "../../platform/cloudflare/logger"; +import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../platform/db/drizzle"; import { createSessionProcessEventsFromSessionEventRows } from "../sessions/application/session-process-events.service"; import type { SessionEventProcessRow } from "../sessions/application/session-process-events.service"; @@ -286,7 +287,7 @@ class PublicThreadEventWakeup { } async function connectPublicThreadEventWakeup( - request: StreamPublicThreadEventsRequest, + request: { bindings: ApiBindings }, sessionId: SessionId, ): Promise { try { @@ -525,13 +526,25 @@ async function resolvePublicThreadEventSessionId( export async function listPublicThreadEvents( request: ListPublicThreadEventsRequest, ): Promise { - const limit = normalizePublicThreadEventsLimit(request.limit); const sessionId = await resolvePublicThreadEventSessionId(request); - const window = await readPublicThreadEventWindow({ + return listPublicSessionEvents({ database: request.database, - limit, + limit: request.limit, sessionId, }); +} + +export async function listPublicSessionEvents(input: { + database: D1Database; + limit: number; + sessionId: SessionId; +}): Promise { + const limit = normalizePublicThreadEventsLimit(input.limit); + const window = await readPublicThreadEventWindow({ + database: input.database, + limit, + sessionId: input.sessionId, + }); return { events: window.events, @@ -592,16 +605,32 @@ function toSseErrorPayload(error: unknown) { export async function createPublicThreadEventStream( request: StreamPublicThreadEventsRequest, ): Promise> { - const limit = normalizePublicThreadEventsLimit(request.limit); const sessionId = await resolvePublicThreadEventSessionId(request); - const wakeup = await connectPublicThreadEventWakeup(request, sessionId); + return createPublicSessionEventStream({ + bindings: request.bindings, + database: request.database, + limit: request.limit, + signal: request.signal, + sessionId, + }); +} + +export async function createPublicSessionEventStream(request: { + bindings: ApiBindings; + database: D1Database; + limit: number; + signal: AbortSignal | null | undefined; + sessionId: SessionId; +}): Promise> { + const limit = normalizePublicThreadEventsLimit(request.limit); + const wakeup = await connectPublicThreadEventWakeup(request, request.sessionId); let initialWindow: PublicThreadEventWindow; try { initialWindow = await readPublicThreadEventWindow({ database: request.database, limit, - sessionId, + sessionId: request.sessionId, }); } catch (error) { wakeup.close(); @@ -642,7 +671,7 @@ export async function createPublicThreadEventStream( const rows = await readPublicThreadEventRowsAfterSeq({ afterSeq: lastSeenSeq, database: request.database, - sessionId, + sessionId: request.sessionId, }); if (rows.length === 0) { diff --git a/apps/api/src/modules/public-api/workspace-run-api.service.ts b/apps/api/src/modules/public-api/workspace-run-api.service.ts new file mode 100644 index 00000000..a2fd523b --- /dev/null +++ b/apps/api/src/modules/public-api/workspace-run-api.service.ts @@ -0,0 +1,330 @@ +import type { CreateWorkspaceRunRequest, WorkspaceRunResponse } from "@mosoo/contracts/harness"; +import type { PublicThreadApiListThreadEventsResponse } from "@mosoo/contracts/public-api"; +import type { SessionRunSummary } from "@mosoo/contracts/session-run"; +import { agentsTable, sessionRunsTable, sessionsTable } from "@mosoo/db"; +import { isPlatformId, parsePlatformId } from "@mosoo/id"; +import type { AgentId, AppId, SessionId, SessionRunId } from "@mosoo/id"; +import { and, desc, eq } from "drizzle-orm"; + +import type { ApiBindings } from "../../platform/cloudflare/worker-types"; +import { getAppDatabase } from "../../platform/db/drizzle"; +import type { WorkspaceApiKeyCaller } from "../auth/application/workspace-api-key.service"; +import { fileStore } from "../files/application/file-store"; +import { getSessionExecutionPlan } from "../runtime/application/session-definition/session-execution.repository"; +import { cancelRun } from "../runtime/application/session-runs/cancel-run.service"; +import { createAgentSession } from "../runtime/application/session-runs/create-agent-session.service"; +import { createHarnessSession } from "../runtime/application/session-runs/create-harness-session.service"; +import { sendAgentSessionEvents } from "../runtime/application/session-runs/send-agent-session-events.service"; +import { getSessionRunSummary } from "../runtime/infrastructure/session-runs/session-run-read.repository"; +import { publicInvalidRequest, publicNotFound } from "./public-api-errors"; +import { + createPublicSessionEventStream, + listPublicSessionEvents, + readPublicThreadRunFinalOutput, +} from "./public-thread-events"; + +interface WorkspaceRunAccess { + run: SessionRunSummary; + sessionId: SessionId; +} + +interface StartWorkspaceRunRequest { + bindings: ApiBindings; + caller: WorkspaceApiKeyCaller; + executionContext: Pick | null; + input: CreateWorkspaceRunRequest; + requestUrl: string; +} + +function createRunLinks(runId: SessionRunId) { + const runPath = `/api/v1/runs/${runId}`; + + return { + approve: `${runPath}/approve`, + artifacts: `${runPath}/artifacts`, + cancel: `${runPath}/cancel`, + events: `${runPath}/events`, + result: `${runPath}/result`, + stream: `${runPath}/events/stream`, + }; +} + +function toPrompt(input: CreateWorkspaceRunRequest["input"]): string { + const prompt = typeof input === "string" ? input : JSON.stringify(input); + + if (prompt.trim().length === 0) { + throw publicInvalidRequest("input must not be an empty string."); + } + + return prompt; +} + +async function findPublishedWorkspaceAgent( + database: D1Database, + workspaceId: AppId, + reference: string, +): Promise { + const normalizedReference = reference.trim(); + const idReference = normalizedReference.toUpperCase(); + const nameReference = normalizedReference.split("/").at(-1)?.trim() ?? ""; + const rows = await getAppDatabase(database) + .select({ id: agentsTable.id }) + .from(agentsTable) + .where( + and( + eq(agentsTable.appId, workspaceId), + eq(agentsTable.status, "published"), + isPlatformId(idReference) + ? eq(agentsTable.id, parsePlatformId(idReference, "Agent ID")) + : eq(agentsTable.name, nameReference), + ), + ) + .orderBy(desc(agentsTable.updatedAt)) + .limit(2) + .all(); + + if (rows.length === 0) { + throw publicNotFound(`Published Agent ${reference} was not found in this Workspace.`); + } + + if (rows.length > 1 && !isPlatformId(idReference)) { + throw publicInvalidRequest( + `Agent name ${nameReference} is ambiguous. Use the Agent ULID instead.`, + ); + } + + return rows[0]!.id; +} + +async function requireWorkspaceRunAccess( + database: D1Database, + caller: WorkspaceApiKeyCaller, + runId: SessionRunId, +): Promise { + const row = + (await getAppDatabase(database) + .select({ sessionId: sessionRunsTable.sessionId }) + .from(sessionRunsTable) + .innerJoin(sessionsTable, eq(sessionsTable.id, sessionRunsTable.sessionId)) + .where(and(eq(sessionRunsTable.id, runId), eq(sessionsTable.appId, caller.workspaceId))) + .limit(1) + .get()) ?? null; + + if (row === null) { + throw publicNotFound(`Run ${runId} was not found.`); + } + + const run = await getSessionRunSummary(database, runId); + + if (run === null) { + throw publicNotFound(`Run ${runId} was not found.`); + } + + return { run, sessionId: row.sessionId }; +} + +async function toWorkspaceRunResponse( + database: D1Database, + caller: WorkspaceApiKeyCaller, + access: WorkspaceRunAccess, +): Promise { + const plan = await getSessionExecutionPlan(database, access.sessionId); + + return { + environment: { + id: plan.environment.environmentId, + name: plan.environment.environmentName, + revisionId: plan.environment.revisionId, + }, + id: access.run.id, + links: createRunLinks(access.run.id), + model: access.run.model ?? plan.binding.model, + source: plan.source, + status: access.run.status, + threadId: access.sessionId, + workspaceId: caller.workspaceId, + }; +} + +export async function startWorkspaceRun( + request: StartWorkspaceRunRequest, +): Promise { + const prompt = toPrompt(request.input.input); + const session = + "agent" in request.input + ? await createAgentSession({ + bindings: request.bindings, + executionContext: request.executionContext, + input: { + agentId: await findPublishedWorkspaceAgent( + request.bindings.DB, + request.caller.workspaceId, + request.input.agent, + ), + appId: request.caller.workspaceId, + type: "api_channel", + }, + options: { + participantAccountId: request.caller.viewer.id, + }, + requestUrl: request.requestUrl, + viewer: request.caller.viewer, + }) + : await createHarnessSession({ + bindings: request.bindings, + environment: request.input.environment, + harness: request.input.harness, + model: request.input.model, + viewer: request.caller.viewer, + workspaceId: request.caller.workspaceId, + }); + const result = await sendAgentSessionEvents({ + bindings: request.bindings, + executionContext: request.executionContext, + input: { + events: [{ text: prompt, type: "user_message" }], + appId: request.caller.workspaceId, + sessionId: session.id, + }, + requestUrl: request.requestUrl, + viewer: request.caller.viewer, + }); + const run = result.events[0]?.run ?? null; + + if (run === null) { + throw new Error("Run creation did not return a Run."); + } + + return toWorkspaceRunResponse(request.bindings.DB, request.caller, { + run, + sessionId: session.id, + }); +} + +export async function retrieveWorkspaceRun( + database: D1Database, + caller: WorkspaceApiKeyCaller, + runId: SessionRunId, +): Promise { + const access = await requireWorkspaceRunAccess(database, caller, runId); + return toWorkspaceRunResponse(database, caller, access); +} + +export async function listWorkspaceRunEvents(input: { + caller: WorkspaceApiKeyCaller; + database: D1Database; + limit: number; + runId: SessionRunId; +}): Promise { + const access = await requireWorkspaceRunAccess(input.database, input.caller, input.runId); + return listPublicSessionEvents({ + database: input.database, + limit: input.limit, + sessionId: access.sessionId, + }); +} + +export async function streamWorkspaceRunEvents(input: { + bindings: ApiBindings; + caller: WorkspaceApiKeyCaller; + limit: number; + runId: SessionRunId; + signal: AbortSignal | null | undefined; +}): Promise> { + const access = await requireWorkspaceRunAccess(input.bindings.DB, input.caller, input.runId); + return createPublicSessionEventStream({ + bindings: input.bindings, + database: input.bindings.DB, + limit: input.limit, + signal: input.signal, + sessionId: access.sessionId, + }); +} + +export async function retrieveWorkspaceRunResult(input: { + caller: WorkspaceApiKeyCaller; + database: D1Database; + runId: SessionRunId; +}) { + const access = await requireWorkspaceRunAccess(input.database, input.caller, input.runId); + return { + output: await readPublicThreadRunFinalOutput({ + database: input.database, + runId: input.runId, + sessionId: access.sessionId, + }), + run: await toWorkspaceRunResponse(input.database, input.caller, access), + }; +} + +export async function listWorkspaceRunArtifacts(input: { + bindings: ApiBindings; + caller: WorkspaceApiKeyCaller; + runId: SessionRunId; +}) { + const access = await requireWorkspaceRunAccess(input.bindings.DB, input.caller, input.runId); + const listing = await fileStore.list(input.bindings, input.caller.viewer, { + appId: input.caller.workspaceId, + scopeKind: "session", + sessionId: access.sessionId, + sessionKind: "artifact", + }); + + return { + artifacts: listing.files.map((file) => ({ + createdAt: file.createdAt, + id: file.id, + mimeType: file.mimeType, + name: file.name, + size: file.size, + })), + }; +} + +export async function approveWorkspaceRun(input: { + bindings: ApiBindings; + caller: WorkspaceApiKeyCaller; + decision: "allow_once" | "reject_once"; + executionContext: Pick | null; + requestId: string; + requestUrl: string; + runId: SessionRunId; +}): Promise<{ ok: true }> { + const access = await requireWorkspaceRunAccess(input.bindings.DB, input.caller, input.runId); + await sendAgentSessionEvents({ + bindings: input.bindings, + executionContext: input.executionContext, + input: { + events: [ + { + decision: input.decision, + requestId: input.requestId, + type: "permission_decision", + }, + ], + appId: input.caller.workspaceId, + sessionId: access.sessionId, + }, + requestUrl: input.requestUrl, + viewer: input.caller.viewer, + }); + return { ok: true }; +} + +export async function cancelWorkspaceRun(input: { + bindings: ApiBindings; + caller: WorkspaceApiKeyCaller; + runId: SessionRunId; +}): Promise { + const access = await requireWorkspaceRunAccess(input.bindings.DB, input.caller, input.runId); + const cancelled = await cancelRun(input.bindings, input.caller.viewer, { + appId: input.caller.workspaceId, + runId: input.runId, + sessionId: access.sessionId, + }); + + return toWorkspaceRunResponse(input.bindings.DB, input.caller, { + run: cancelled.run, + sessionId: access.sessionId, + }); +} diff --git a/apps/api/src/modules/runtime/application/harness-driver-compatibility.ts b/apps/api/src/modules/runtime/application/harness-driver-compatibility.ts new file mode 100644 index 00000000..fe5f24b9 --- /dev/null +++ b/apps/api/src/modules/runtime/application/harness-driver-compatibility.ts @@ -0,0 +1,13 @@ +import type { AgentId, SessionId } from "@mosoo/id"; + +/** + * The pinned Driver protocol requires Agent provenance even when Mosoo's Run + * source is a Harness. Keep that temporary impedance mismatch in one place. + * The value is a Session-scoped internal correlation id; no Agent row is + * inserted and it is never exposed by the Run API. + * + * Removal is tracked by https://github.com/langgenius/mosoo-agent-driver/issues/118. + */ +export function toHarnessDriverCompatibilityAgentId(sessionId: SessionId): AgentId { + return sessionId as unknown as AgentId; +} diff --git a/apps/api/src/modules/runtime/application/session-definition/hydrate-run-context.service.ts b/apps/api/src/modules/runtime/application/session-definition/hydrate-run-context.service.ts index 2e594605..1b4c488a 100644 --- a/apps/api/src/modules/runtime/application/session-definition/hydrate-run-context.service.ts +++ b/apps/api/src/modules/runtime/application/session-definition/hydrate-run-context.service.ts @@ -13,18 +13,20 @@ import type { SessionId, } from "@mosoo/id"; import { getRuntimeCatalogEntry, getRuntimeCatalogVendorForProvider } from "@mosoo/runtime-catalog"; +import { getHarnessCatalogEntry } from "@mosoo/runtime-catalog"; import { RUNTIME_DIAGNOSTIC_EVENT } from "@mosoo/runtime-events"; import type { ApiBindings } from "../../../../platform/cloudflare/worker-types"; import { validationError } from "../../../../platform/errors"; -import { isTruthy } from "../../../../shared/truthiness"; import { ensureAppAgentOwner } from "../../../agents/application/agent-access.service"; import { getAgentDeploymentVersionRecord } from "../../../agents/application/agent-deployment-version.service"; +import type { AgentDeploymentVersionRecord } from "../../../agents/application/agent-deployment-version.service"; import { computeAgentReadiness, formatAgentReadinessFailureMessage, } from "../../../agents/application/agent-readiness.service"; import { parseAgentStoredConfig } from "../../../agents/application/agent-stored-config.service"; +import { ensureAppOwnership } from "../../../apps/application/app.service"; import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth.service"; import { decryptEnvironmentVariables, @@ -53,7 +55,7 @@ import { toRuntimeDiagnosticReason, } from "../runtime-diagnostic-events"; import { getSessionExecutionPlan } from "./session-execution.repository"; -import type { HydratedSessionRunContext } from "./session-execution.types"; +import type { HydratedSessionRunContext, SessionExecutionPlan } from "./session-execution.types"; import { resolveSessionSkillReferences } from "./session-skill-reference-resolution.service"; import { buildSnapshotAgentEnvironment } from "./session-snapshot-hydration"; @@ -147,6 +149,72 @@ function sanitizeHydratedRunContextForCache( }; } +interface ResolvedExecutionSourceContext { + agentId: AgentId; + deploymentVersion: AgentDeploymentVersionRecord | null; + executionOwnerUserId: AccountId; + kind: "agent" | "harness"; + storedConfig: ReturnType; +} + +async function resolveExecutionSourceContext(input: { + accessViewerId: AccountId; + bindings: ApiBindings; + executionPlan: SessionExecutionPlan; + appId: AppId; +}): Promise { + const source = input.executionPlan.source; + const binding = input.executionPlan.binding; + + if (source.kind === "agent") { + if (source.agentId !== binding.agentId) { + throw new Error("Session Agent source does not match its execution binding."); + } + + const [agent, deploymentVersion] = await Promise.all([ + ensureAppAgentOwner(input.bindings.DB, input.accessViewerId, { + agentId: source.agentId as AgentId, + appId: input.appId, + }).then((access) => access.agent), + binding.deploymentVersionId + ? getAgentDeploymentVersionRecord(input.bindings.DB, binding.deploymentVersionId) + : Promise.resolve(null), + ]); + + return { + agentId: agent.id, + deploymentVersion, + executionOwnerUserId: agent.ownerId, + kind: "agent", + storedConfig: parseAgentStoredConfig(deploymentVersion?.configJson ?? agent.configJson), + }; + } + + if (binding.kind !== "cattle") { + throw new Error("Harness Runs require Cattle session isolation."); + } + + const harness = getHarnessCatalogEntry(source.harness); + if ( + harness === null || + harness.status !== "available" || + harness.version !== source.version || + harness.runtimeId !== binding.runtimeId + ) { + throw new Error(`Frozen Harness ${source.harness}@${source.version} is unavailable.`); + } + + const workspace = await ensureAppOwnership(input.bindings.DB, input.accessViewerId, input.appId); + + return { + agentId: binding.agentId, + deploymentVersion: null, + executionOwnerUserId: workspace.ownerAccountId, + kind: "harness", + storedConfig: parseAgentStoredConfig("{}"), + }; +} + export function toDriverNetworkProfile(input: { environment: { allowedHostsJson: string; networkPolicy: DriverNetworkProfile["networkPolicy"] }; }): DriverNetworkProfile { @@ -180,16 +248,13 @@ async function hydrateRunContextFromSession( throw new Error(`Unsupported runtime: ${binding.runtimeId}.`); } - const [agent, deploymentVersion] = await Promise.all([ - ensureAppAgentOwner(bindings.DB, session.accessViewer?.id ?? viewer.id, { - agentId: binding.agentId, - appId: session.appId, - }).then((access) => access.agent), - isTruthy(binding.deploymentVersionId) - ? getAgentDeploymentVersionRecord(bindings.DB, binding.deploymentVersionId) - : Promise.resolve(null), - ]); - const storedConfig = parseAgentStoredConfig(deploymentVersion?.configJson ?? agent.configJson); + const sourceContext = await resolveExecutionSourceContext({ + accessViewerId: session.accessViewer?.id ?? viewer.id, + bindings, + executionPlan, + appId: session.appId, + }); + const storedConfig = sourceContext.storedConfig; const environmentSnapshot = executionPlan.environment; const toolReferences = executionPlan.tools.toSorted( (left, right) => left.sortOrder - right.sortOrder, @@ -202,18 +267,22 @@ async function hydrateRunContextFromSession( // timeout each) on the first-token critical path. D1-backed checks still // gate the run; broken credentials surface from the actual model call. // Config/publish readiness callers keep the live probe. - const agentReadiness = await computeAgentReadiness(bindings.DB, agent.ownerId, { - agentId: agent.id, - environment: snapshotEnvironment, - environmentNetworkPolicy: environmentSnapshot.networkPolicy, - kind: binding.kind, - mcpServerIds: toolReferences.map((reference) => reference.serverId), - model: binding.model, - packageResolution: storedConfig.packageResolution, - appId: agent.appId, - provider: binding.provider, - runtimeId, - }); + const agentReadiness = await computeAgentReadiness( + bindings.DB, + sourceContext.executionOwnerUserId, + { + agentId: sourceContext.agentId, + environment: snapshotEnvironment, + environmentNetworkPolicy: environmentSnapshot.networkPolicy, + kind: binding.kind, + mcpServerIds: toolReferences.map((reference) => reference.serverId), + model: binding.model, + packageResolution: storedConfig.packageResolution, + appId: session.appId, + provider: binding.provider, + runtimeId, + }, + ); if (!agentReadiness.ready) { throw validationError( @@ -252,7 +321,7 @@ async function hydrateRunContextFromSession( const [vendorCredential, envVars, environmentArtifact, setupScript] = await Promise.all([ resolveVendorCredentialRef({ bindings, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, options: { modelId: binding.model }, appId: session.appId, vendorId: vendor.vendorId, @@ -275,7 +344,7 @@ async function hydrateRunContextFromSession( sessionId: session.id, value: { ...toRuntimeDiagnosticBaseValue({ - agentId: agent.id, + agentId: sourceContext.agentId, sessionId: session.id, }), provider: binding.provider, @@ -287,16 +356,16 @@ async function hydrateRunContextFromSession( let profile: DriverProfileConfig; const runtimeProfileIds = await resolveRuntimeProfileIds(bindings, { - agentId: agent.id, + agentId: sourceContext.agentId, appId: session.appId, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, kind: binding.kind, sessionId: session.id, }); try { profile = createAgentRuntimeProfile({ - agentId: agent.id, + agentId: sourceContext.agentId, sandboxSessionId: runtimeProfileIds.sandboxSessionId, callerUserId: viewer.id, configRevision: { @@ -310,7 +379,7 @@ async function hydrateRunContextFromSession( }, envVars, environmentArtifact, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, kind: binding.kind, model: binding.model, network: toDriverNetworkProfile({ @@ -332,7 +401,7 @@ async function hydrateRunContextFromSession( sessionId: session.id, value: { ...toRuntimeDiagnosticBaseValue({ - agentId: agent.id, + agentId: sourceContext.agentId, sessionId: session.id, }), fieldPath: "runtimeProfile", @@ -341,18 +410,21 @@ async function hydrateRunContextFromSession( }); throw error; } - const mcpServers = await resolveRuntimeMcpServersForSnapshot(bindings, { - agentId: agent.id, - bindings: toolReferences.map((reference) => ({ - agentCredentialId: reference.agentCredentialId, - credentialMode: reference.credentialMode, - enabled: true, - serverId: reference.serverId, - sortOrder: reference.sortOrder, - })), - callerUserId: viewer.id, - executionOwnerUserId: agent.ownerId, - }); + const mcpServers = + sourceContext.kind === "harness" || toolReferences.length === 0 + ? [] + : await resolveRuntimeMcpServersForSnapshot(bindings, { + agentId: sourceContext.agentId, + bindings: toolReferences.map((reference) => ({ + agentCredentialId: reference.agentCredentialId, + credentialMode: reference.credentialMode, + enabled: true, + serverId: reference.serverId, + sortOrder: reference.sortOrder, + })), + callerUserId: viewer.id, + executionOwnerUserId: sourceContext.executionOwnerUserId, + }); return { builtInTools: executionPlan.builtInTools, @@ -384,16 +456,13 @@ async function refreshCachedRunContextVolatileFields( throw new Error(`Unsupported runtime: ${binding.runtimeId}.`); } - const [agent, deploymentVersion] = await Promise.all([ - ensureAppAgentOwner(bindings.DB, session.accessViewer?.id ?? viewer.id, { - agentId: binding.agentId, - appId: session.appId, - }).then((access) => access.agent), - isTruthy(binding.deploymentVersionId) - ? getAgentDeploymentVersionRecord(bindings.DB, binding.deploymentVersionId) - : Promise.resolve(null), - ]); - const storedConfig = parseAgentStoredConfig(deploymentVersion?.configJson ?? agent.configJson); + const sourceContext = await resolveExecutionSourceContext({ + accessViewerId: session.accessViewer?.id ?? viewer.id, + bindings, + executionPlan, + appId: session.appId, + }); + const storedConfig = sourceContext.storedConfig; const catalogEntry = getRuntimeCatalogEntry(runtimeId); if (catalogEntry === null) { @@ -413,7 +482,7 @@ async function refreshCachedRunContextVolatileFields( const [vendorCredential, envVars, mcpServers] = await Promise.all([ resolveVendorCredentialRef({ bindings, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, options: { modelId: binding.model }, appId: session.appId, vendorId: vendor.vendorId, @@ -422,9 +491,9 @@ async function refreshCachedRunContextVolatileFields( environmentId: environmentSnapshot.environmentId, envVars: parseStoredEnvVarsJson(environmentSnapshot.envVarsJson), }), - toolReferences.length > 0 + toolReferences.length > 0 && sourceContext.kind === "agent" ? resolveRuntimeMcpServersForSnapshot(bindings, { - agentId: agent.id, + agentId: sourceContext.agentId, bindings: toolReferences.map((reference) => ({ agentCredentialId: reference.agentCredentialId, credentialMode: reference.credentialMode, @@ -433,7 +502,7 @@ async function refreshCachedRunContextVolatileFields( sortOrder: reference.sortOrder, })), callerUserId: viewer.id, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, }) : Promise.resolve([]), ]); @@ -443,14 +512,14 @@ async function refreshCachedRunContextVolatileFields( } const runtimeProfileIds = await resolveRuntimeProfileIds(bindings, { - agentId: agent.id, + agentId: sourceContext.agentId, appId: session.appId, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, kind: binding.kind, sessionId: session.id, }); const profile = createAgentRuntimeProfile({ - agentId: agent.id, + agentId: sourceContext.agentId, sandboxSessionId: runtimeProfileIds.sandboxSessionId, callerUserId: viewer.id, configRevision: { @@ -464,7 +533,7 @@ async function refreshCachedRunContextVolatileFields( }, envVars, environmentArtifact: cached.profile.environmentArtifact ?? null, - executionOwnerUserId: agent.ownerId, + executionOwnerUserId: sourceContext.executionOwnerUserId, kind: binding.kind, model: binding.model, network: toDriverNetworkProfile({ diff --git a/apps/api/src/modules/runtime/application/session-definition/session-execution.repository.ts b/apps/api/src/modules/runtime/application/session-definition/session-execution.repository.ts index 083d6843..afdacaed 100644 --- a/apps/api/src/modules/runtime/application/session-definition/session-execution.repository.ts +++ b/apps/api/src/modules/runtime/application/session-definition/session-execution.repository.ts @@ -6,6 +6,8 @@ import { normalizeAgentBuiltInTools, } from "@mosoo/contracts/agent"; import type { EnvironmentNetworkPolicy } from "@mosoo/contracts/environment"; +import { HARNESS_SLUGS } from "@mosoo/contracts/harness"; +import type { HarnessSlug, RunSourceSnapshot } from "@mosoo/contracts/harness"; import type { AgentMcpCredentialMode } from "@mosoo/contracts/mcp"; import type { SkillResolutionMode } from "@mosoo/contracts/skill"; import { sessionExecutionSnapshotsTable } from "@mosoo/db"; @@ -247,15 +249,66 @@ function parseBuiltInTools(value: unknown): SessionExecutionPlan["builtInTools"] ); } +function readHarnessSlug(value: unknown, field: string): HarnessSlug { + const slug = readString(value, field); + + if ((HARNESS_SLUGS as readonly string[]).includes(slug)) { + return slug as HarnessSlug; + } + + throw new TypeError(`${field} is not a curated Harness slug.`); +} + +function parseSource(value: unknown, binding: SessionExecutionPlan["binding"]): RunSourceSnapshot { + if (value === undefined) { + return { + agentId: binding.agentId, + agentVersionId: binding.deploymentVersionId, + agentVersionNumber: binding.deploymentVersionNumber, + kind: "agent", + }; + } + + const record = readRecord(value, "sessionExecutionPlan.source"); + + if (record["kind"] === "agent") { + return { + agentId: readPlatformId(record["agentId"], "sessionExecutionPlan.source.agentId") as AgentId, + agentVersionId: + readNullablePlatformId( + record["agentVersionId"], + "sessionExecutionPlan.source.agentVersionId", + ) ?? null, + agentVersionNumber: readNullableNumber( + record["agentVersionNumber"], + "sessionExecutionPlan.source.agentVersionNumber", + ), + kind: "agent", + }; + } + + if (record["kind"] === "harness") { + return { + harness: readHarnessSlug(record["harness"], "sessionExecutionPlan.source.harness"), + kind: "harness", + version: readString(record["version"], "sessionExecutionPlan.source.version"), + }; + } + + throw new TypeError("sessionExecutionPlan.source.kind must be agent or harness."); +} + function parseSessionExecutionPlanJson(planJson: string): SessionExecutionPlan { const parsed: unknown = JSON.parse(planJson); const record = readRecord(parsed, "sessionExecutionPlan"); + const binding = parseBinding(record["binding"]); return { - binding: parseBinding(record["binding"]), + binding, builtInTools: parseBuiltInTools(record["builtInTools"]), environment: parseEnvironment(record["environment"]), skills: readArray(record["skills"], "sessionExecutionPlan.skills").map(parseSkillReference), + source: parseSource(record["source"], binding), tools: readArray(record["tools"], "sessionExecutionPlan.tools").map(parseToolReference), }; } diff --git a/apps/api/src/modules/runtime/application/session-definition/session-execution.types.ts b/apps/api/src/modules/runtime/application/session-definition/session-execution.types.ts index 4d363e68..2d2dcde4 100644 --- a/apps/api/src/modules/runtime/application/session-definition/session-execution.types.ts +++ b/apps/api/src/modules/runtime/application/session-definition/session-execution.types.ts @@ -1,5 +1,6 @@ import type { AgentBuiltInToolConfig } from "@mosoo/contracts/agent"; import type { EnvironmentNetworkPolicy } from "@mosoo/contracts/environment"; +import type { RunSourceSnapshot } from "@mosoo/contracts/harness"; import type { SessionExecutionBinding, SessionExecutionSkillReference, @@ -31,6 +32,7 @@ export interface SessionExecutionPlan { setupScript: string; }; skills: Omit[]; + source: RunSourceSnapshot; tools: Omit[]; } diff --git a/apps/api/src/modules/runtime/application/session-run.service.ts b/apps/api/src/modules/runtime/application/session-run.service.ts index 4576e912..81221d72 100644 --- a/apps/api/src/modules/runtime/application/session-run.service.ts +++ b/apps/api/src/modules/runtime/application/session-run.service.ts @@ -1,4 +1,5 @@ export { createAgentSession } from "./session-runs/create-agent-session.service"; +export { createHarnessSession } from "./session-runs/create-harness-session.service"; export { queueSessionRun, SessionRunCreationGuardRejectedError, diff --git a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts index e5e8d55f..af1fbdef 100644 --- a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts @@ -214,6 +214,12 @@ async function buildSessionExecutionPlan(input: { setupScript: environmentSnapshot.setupScript, }, skills, + source: { + agentId: input.source.agent.id, + agentVersionId: input.source.liveVersion?.id ?? null, + agentVersionNumber: input.source.liveVersion?.versionNumber ?? null, + kind: "agent", + }, tools, }; } diff --git a/apps/api/src/modules/runtime/application/session-runs/create-harness-session.service.ts b/apps/api/src/modules/runtime/application/session-runs/create-harness-session.service.ts new file mode 100644 index 00000000..3dc3614f --- /dev/null +++ b/apps/api/src/modules/runtime/application/session-runs/create-harness-session.service.ts @@ -0,0 +1,221 @@ +import { createDefaultAgentBuiltInTools } from "@mosoo/contracts/agent"; +import type { HarnessSlug } from "@mosoo/contracts/harness"; +import type { SessionSummary } from "@mosoo/contracts/session"; +import { environmentsTable, sessionExecutionSnapshotsTable, sessionsTable } from "@mosoo/db"; +import { createPlatformId, isPlatformId, parsePlatformId } from "@mosoo/id"; +import type { AccountId, EnvironmentId, AppId, SessionId } from "@mosoo/id"; +import { getHarnessCatalogEntry, getRuntimeCatalogEntry } from "@mosoo/runtime-catalog"; +import { and, eq } from "drizzle-orm"; + +import type { ApiBindings } from "../../../../platform/cloudflare/worker-types"; +import { getAppDatabase, runAppDatabaseBatch } from "../../../../platform/db/drizzle"; +import { validationError } from "../../../../platform/errors"; +import { currentTimestampMs, toIsoString } from "../../../../time"; +import { + computeAgentReadiness, + formatAgentReadinessFailureMessage, +} from "../../../agents/application/agent-readiness.service"; +import { ensureAppOwnership } from "../../../apps/application/app.service"; +import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth.service"; +import { ensureEnvironmentAccess } from "../../../environments/application/environment-access.service"; +import { getAppDefaultEnvironmentId } from "../../../environments/application/environment-defaults"; +import { resolveReadyEnvironmentPackageArtifact } from "../../../environments/application/environment-package-artifact.service"; +import type { EnvironmentRecordRow } from "../../../environments/application/environment-types"; +import { toHarnessDriverCompatibilityAgentId } from "../harness-driver-compatibility"; +import type { SessionExecutionPlan } from "../session-definition/session-execution.types"; + +export interface CreateHarnessSessionRequest { + bindings: ApiBindings; + environment?: string | undefined; + harness: HarnessSlug; + model?: string | undefined; + viewer: AuthenticatedViewer; + workspaceId: AppId; +} + +async function resolveEnvironmentId( + database: D1Database, + input: { environment?: string | undefined; workspaceId: AppId }, +): Promise { + const reference = input.environment?.trim(); + + if (reference === undefined || reference === "" || reference === "mosoo/general") { + return getAppDefaultEnvironmentId(database, input.workspaceId); + } + + if (isPlatformId(reference.toUpperCase())) { + return parsePlatformId(reference, "Environment ID"); + } + + const row = + (await getAppDatabase(database) + .select({ id: environmentsTable.id }) + .from(environmentsTable) + .where( + and(eq(environmentsTable.appId, input.workspaceId), eq(environmentsTable.name, reference)), + ) + .limit(1) + .get()) ?? null; + + if (row === null) { + throw validationError(`Environment ${reference} was not found in this Workspace.`); + } + + return row.id; +} + +function buildExecutionPlan(input: { + environment: EnvironmentRecordRow; + harness: NonNullable>; + model: string; + sessionId: SessionId; +}): SessionExecutionPlan { + const runtime = getRuntimeCatalogEntry(input.harness.runtimeId); + + if (runtime === null) { + throw new Error(`Harness runtime ${input.harness.runtimeId} is unavailable.`); + } + + return { + binding: { + agentId: toHarnessDriverCompatibilityAgentId(input.sessionId), + deploymentVersionId: null, + deploymentVersionNumber: null, + kind: "cattle", + model: input.model, + prompt: "", + provider: runtime.defaultProvider, + runtimeId: runtime.runtimeId, + }, + builtInTools: createDefaultAgentBuiltInTools(), + environment: { + allowMcpServers: input.environment.allowMcpServers === 1, + allowPackageManagers: input.environment.allowPackageManagers === 1, + allowedHostsJson: input.environment.allowedHostsJson, + envVarsJson: input.environment.envVarsJson, + environmentId: input.environment.id, + environmentName: input.environment.name, + networkPolicy: input.environment.networkPolicy, + packagesJson: input.environment.packagesJson, + revisionId: input.environment.currentRevisionId, + setupScript: input.environment.setupScript, + }, + skills: [], + source: { + harness: input.harness.slug, + kind: "harness", + version: input.harness.version, + }, + tools: [], + }; +} + +export async function createHarnessSession( + request: CreateHarnessSessionRequest, +): Promise { + const workspaceId = parsePlatformId(request.workspaceId, "Workspace ID"); + const viewerId = parsePlatformId(request.viewer.id, "viewer ID"); + const workspace = await ensureAppOwnership(request.bindings.DB, viewerId, workspaceId); + const harness = getHarnessCatalogEntry(request.harness); + + if (harness === null || harness.status !== "available") { + throw validationError(`Harness ${request.harness} is unavailable.`); + } + + const model = request.model?.trim() || harness.defaultModel; + if (!harness.supportedModels.includes(model)) { + throw validationError(`Model ${model} is not supported by Harness ${harness.slug}.`); + } + + const environmentId = await resolveEnvironmentId(request.bindings.DB, { + environment: request.environment, + workspaceId, + }); + const environmentAccess = await ensureEnvironmentAccess(request.bindings.DB, viewerId, { + environmentId, + appId: workspaceId, + }); + const sessionId = createPlatformId(); + const executionPlan = buildExecutionPlan({ + environment: environmentAccess.row, + harness, + model, + sessionId, + }); + const readiness = await computeAgentReadiness(request.bindings.DB, workspace.ownerAccountId, { + agentId: executionPlan.binding.agentId, + bindings: request.bindings, + environment: { environmentId }, + environmentNetworkPolicy: executionPlan.environment.networkPolicy, + kind: "cattle", + mcpServerIds: [], + model, + packageResolution: null, + appId: workspaceId, + provider: executionPlan.binding.provider, + runtimeId: executionPlan.binding.runtimeId, + }); + + if (!readiness.ready) { + throw validationError( + formatAgentReadinessFailureMessage("Harness is not ready to run", readiness), + "AGENT_SESSION_NOT_READY", + ); + } + + await resolveReadyEnvironmentPackageArtifact( + request.bindings, + workspaceId, + executionPlan.environment.packagesJson, + ); + + const timestampMs = currentTimestampMs(); + await runAppDatabaseBatch(request.bindings.DB, (database) => [ + database.insert(sessionsTable).values({ + agentId: executionPlan.binding.agentId, + createdAt: timestampMs, + creatorAccountId: viewerId, + deploymentVersionId: null, + deploymentVersionNumber: null, + id: sessionId, + kind: "cattle", + metadataJson: JSON.stringify({ source: "run_api" }), + model, + appId: workspaceId, + provider: executionPlan.binding.provider, + participantAccountId: viewerId, + renamed: false, + runtimeId: executionPlan.binding.runtimeId, + status: "IDLE", + title: null, + type: "api_channel", + updatedAt: timestampMs, + }), + database.insert(sessionExecutionSnapshotsTable).values({ + createdAt: timestampMs, + planJson: JSON.stringify(executionPlan), + sessionId, + }), + ]); + + const timestamp = toIsoString(timestampMs); + return { + agentId: executionPlan.binding.agentId, + archivedAt: null, + createdAt: timestamp, + deploymentVersionId: null, + deploymentVersionNumber: null, + id: sessionId, + kind: "cattle", + lastMessageAt: null, + lastRun: null, + model, + appId: workspaceId, + provider: executionPlan.binding.provider, + runtimeId: executionPlan.binding.runtimeId, + status: "IDLE", + title: null, + type: "api_channel", + updatedAt: timestamp, + }; +} diff --git a/apps/api/tests/workspace-run-api.test.ts b/apps/api/tests/workspace-run-api.test.ts new file mode 100644 index 00000000..886a5481 --- /dev/null +++ b/apps/api/tests/workspace-run-api.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; + +import { PUBLIC_API_PREFIX } from "@mosoo/contracts/public-api"; + +import { createHttpApp } from "../src/adapters/http/create-http-app"; +import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; +import { createWorkspaceApiKey } from "../src/modules/auth/application/workspace-api-key.service"; +import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; +import { + createPublicHttpContractDatabase, + createPublicHttpTestBindings, + createTestExecutionContext, + PUBLIC_API_TEST_IDS, +} from "./helpers/public-api-http-test-fixture"; + +const OWNER: AuthenticatedViewer = { + email: "owner@example.com", + emailVerified: true, + id: PUBLIC_API_TEST_IDS.ownerAccount, + imageUrl: null, + name: "Owner", +}; + +async function withProviderProbeMock(operation: () => Promise): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + Response.json({ + data: [{ id: "gpt-5.4" }, { id: "gpt-5.5" }], + }); + + try { + return await operation(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function runRequest( + apiKey: string, + body: Record, + bindings: ApiBindings, +): Promise { + return createHttpApp().request( + `${PUBLIC_API_PREFIX}/v1/runs`, + { + body: JSON.stringify(body), + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + method: "POST", + }, + bindings, + createTestExecutionContext(), + ); +} + +describe("Workspace Run API", () => { + test("lists the curated Harness marketplace", async () => { + const database = await createPublicHttpContractDatabase(); + const response = await createHttpApp().request( + `${PUBLIC_API_PREFIX}/v1/harnesses`, + undefined, + createPublicHttpTestBindings(database) as ApiBindings, + ); + + expect(response.status).toBe(200); + const body = await response.json<{ harnesses: { slug: string }[] }>(); + expect(body.harnesses.map((harness) => harness.slug)).toEqual([ + "claude-code", + "openai-codex", + "opencode", + ]); + }); + + test("launches two Harnesses with one Workspace key and creates no Agent rows", async () => { + const database = await createPublicHttpContractDatabase(); + const bindings = createPublicHttpTestBindings(database) as ApiBindings; + const key = await createWorkspaceApiKey(database, OWNER, { + label: "Harness experiment", + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + + const [codexResponse, openCodeResponse] = await withProviderProbeMock(async () => [ + await runRequest( + key.value, + { harness: "openai-codex", input: "Review this repository" }, + bindings, + ), + await runRequest( + key.value, + { harness: "opencode", input: { task: "Review this repository" } }, + bindings, + ), + ]); + + expect(codexResponse.status).toBe(201); + expect(openCodeResponse.status).toBe(201); + const codexRun = await codexResponse.json<{ + id: string; + source: { harness: string; kind: string; version: string }; + threadId: string; + workspaceId: string; + }>(); + const openCodeRun = await openCodeResponse.json<{ + id: string; + source: { harness: string; kind: string; version: string }; + threadId: string; + workspaceId: string; + }>(); + + expect(codexRun).toMatchObject({ + source: { harness: "openai-codex", kind: "harness" }, + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + expect(openCodeRun).toMatchObject({ + source: { harness: "opencode", kind: "harness" }, + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + expect(codexRun.source.version).toBe(openCodeRun.source.version); + expect(codexRun.id).not.toBe(openCodeRun.id); + + const counts = await database + .prepare( + ` + SELECT + (SELECT COUNT(*) FROM agent) AS agent_count, + (SELECT COUNT(*) FROM session) AS session_count, + (SELECT COUNT(*) FROM session_run) AS run_count + `, + ) + .first<{ agent_count: number; run_count: number; session_count: number }>(); + expect(counts).toEqual({ agent_count: 1, run_count: 2, session_count: 2 }); + + const compatibilityRows = await database + .prepare("SELECT agent_id, id FROM session ORDER BY created_at, id") + .all<{ agent_id: string; id: string }>(); + expect(compatibilityRows.results).toEqual([ + { agent_id: codexRun.threadId, id: codexRun.threadId }, + { agent_id: openCodeRun.threadId, id: openCodeRun.threadId }, + ]); + + const retrieveResponse = await createHttpApp().request( + `${PUBLIC_API_PREFIX}/v1/runs/${codexRun.id}`, + { headers: { authorization: `Bearer ${key.value}` } }, + bindings, + createTestExecutionContext(), + ); + expect(retrieveResponse.status).toBe(200); + expect(await retrieveResponse.json()).toMatchObject({ + id: codexRun.id, + source: { harness: "openai-codex", kind: "harness" }, + }); + }); + + test("rejects mixed source selectors before allocating a Session", async () => { + const database = await createPublicHttpContractDatabase(); + const bindings = createPublicHttpTestBindings(database) as ApiBindings; + const key = await createWorkspaceApiKey(database, OWNER, { + label: "Contract test", + workspaceId: PUBLIC_API_TEST_IDS.app, + }); + const response = await runRequest( + key.value, + { + agent: PUBLIC_API_TEST_IDS.agent, + harness: "openai-codex", + input: "This must be rejected", + }, + bindings, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: { + code: "invalid_request", + message: "Exactly one of agent or harness is required.", + }, + }); + const sessions = await database + .prepare("SELECT COUNT(*) AS count FROM session") + .first<{ count: number }>(); + expect(sessions?.count).toBe(0); + }); +}); diff --git a/pkgs/contracts/src/harness/harness.contract.ts b/pkgs/contracts/src/harness/harness.contract.ts index d49c674c..c029725a 100644 --- a/pkgs/contracts/src/harness/harness.contract.ts +++ b/pkgs/contracts/src/harness/harness.contract.ts @@ -1,4 +1,5 @@ import type { AppId } from "../id/id.contract"; +import type { SessionRunStatus } from "../session/session-run.contract"; import type { JsonObject, JsonValue } from "../validation/primitives.contract"; export const HARNESS_SLUGS = ["claude-code", "openai-codex", "opencode"] as const; @@ -92,7 +93,27 @@ export interface WorkspaceRunResponse { links: WorkspaceRunLinks; model: string; source: RunSourceSnapshot; - status: string; + status: SessionRunStatus; threadId: string; workspaceId: AppId; } + +export interface WorkspaceRunArtifact { + createdAt: string; + id: string; + mimeType: string | null; + name: string; + size: number; +} + +export interface WorkspaceRunArtifactListResponse { + artifacts: WorkspaceRunArtifact[]; +} + +export interface WorkspaceRunResultResponse { + output: { + text: string; + warnings?: { code: string; count: number }[]; + } | null; + run: WorkspaceRunResponse; +} diff --git a/pkgs/public-api-client/src/index.ts b/pkgs/public-api-client/src/index.ts index 9307a2df..1afb6e82 100644 --- a/pkgs/public-api-client/src/index.ts +++ b/pkgs/public-api-client/src/index.ts @@ -1,3 +1,10 @@ +import type { + CreateWorkspaceRunRequest, + HarnessCatalogEntry, + WorkspaceRunArtifactListResponse, + WorkspaceRunResponse, + WorkspaceRunResultResponse, +} from "@mosoo/contracts/harness"; import { PUBLIC_THREAD_EVENTS_MAX_LIMIT, PUBLIC_THREAD_RUN_TERMINAL_STATUSES, @@ -46,6 +53,11 @@ export interface MosooPublicThreadClientOptions { token: string; } +export type MosooClientOptions = MosooPublicThreadClientOptions; +export type MosooRunInput = CreateWorkspaceRunRequest & { + signal?: AbortSignal | undefined; +}; + export interface MosooCreateThreadInput { agentId: string; fileIds?: string[]; @@ -514,6 +526,64 @@ export class MosooPublicThreadClient { }); } + async listHarnesses(options: { signal?: AbortSignal | undefined } = {}): Promise<{ + harnesses: HarnessCatalogEntry[]; + }> { + return this.requestJson("GET", "/harnesses", { + signal: options.signal, + status: 200, + }); + } + + async run(input: MosooRunInput): Promise { + const { signal, ...body } = input; + return this.requestJson("POST", "/runs", { + body, + signal, + status: 201, + }); + } + + async retrieveRun( + runId: string, + options: { signal?: AbortSignal | undefined } = {}, + ): Promise { + return this.requestJson("GET", `/runs/${runId}`, { + signal: options.signal, + status: 200, + }); + } + + async retrieveRunResult( + runId: string, + options: { signal?: AbortSignal | undefined } = {}, + ): Promise { + return this.requestJson("GET", `/runs/${runId}/result`, { + signal: options.signal, + status: 200, + }); + } + + async listRunArtifacts( + runId: string, + options: { signal?: AbortSignal | undefined } = {}, + ): Promise { + return this.requestJson("GET", `/runs/${runId}/artifacts`, { + signal: options.signal, + status: 200, + }); + } + + async cancelRun( + runId: string, + options: { signal?: AbortSignal | undefined } = {}, + ): Promise { + return this.requestJson("POST", `/runs/${runId}/cancel`, { + signal: options.signal, + status: 200, + }); + } + async uploadAgentFile(input: MosooUploadAgentFileInput): Promise { const formData = new FormData(); @@ -829,3 +899,5 @@ export class MosooPublicThreadClient { }); } } + +export class MosooClient extends MosooPublicThreadClient {} diff --git a/pkgs/public-api-client/tests/public-api-client.test.ts b/pkgs/public-api-client/tests/public-api-client.test.ts index f6f9e4d3..4d891238 100644 --- a/pkgs/public-api-client/tests/public-api-client.test.ts +++ b/pkgs/public-api-client/tests/public-api-client.test.ts @@ -11,7 +11,7 @@ import type { PublicThreadSummary, } from "@mosoo/contracts/public-api"; import type { MosooPublicApiError, MosooPublicApiFetch } from "@mosoo/public-api-client"; -import { MosooPublicThreadClient } from "@mosoo/public-api-client"; +import { MosooClient, MosooPublicThreadClient } from "@mosoo/public-api-client"; import { MosooPublicThreadTerminalRunError } from "@mosoo/public-api-client"; import { extractFinalOutput } from "@mosoo/public-api-client"; @@ -81,6 +81,68 @@ function jsonResponse(value: unknown, status = 200): Response { } describe("MosooPublicThreadClient", () => { + test("starts a Harness Run with a Workspace API key", async () => { + const requests: RecordedRequest[] = []; + const fetchMock: MosooPublicApiFetch = async (input, init) => { + const request = new Request(input, init); + requests.push({ + body: await readRequestBody(request.clone()), + headers: request.headers, + method: request.method, + url: request.url, + }); + return jsonResponse( + { + environment: { + id: "01J00000000000000000000007", + name: "Default", + revisionId: "01J00000000000000000000008", + }, + id: RUN_ID, + links: { + approve: `/api/v1/runs/${RUN_ID}/approve`, + artifacts: `/api/v1/runs/${RUN_ID}/artifacts`, + cancel: `/api/v1/runs/${RUN_ID}/cancel`, + events: `/api/v1/runs/${RUN_ID}/events`, + result: `/api/v1/runs/${RUN_ID}/result`, + stream: `/api/v1/runs/${RUN_ID}/events/stream`, + }, + model: "gpt-5.5", + source: { + harness: "openai-codex", + kind: "harness", + version: "2026.08-experiment.1", + }, + status: "queued", + threadId: THREAD_ID, + workspaceId: "01J0000000000000000000000Q", + }, + 201, + ); + }; + const mosoo = new MosooClient({ + baseUrl: "https://api.example.com", + fetch: fetchMock, + token: "msk_workspace_test", + }); + + const run = await mosoo.run({ + harness: "openai-codex", + input: "Review this repository", + }); + + expect(run).toMatchObject({ + id: RUN_ID, + source: { harness: "openai-codex", kind: "harness" }, + }); + expect(requests[0]).toMatchObject({ + body: { harness: "openai-codex", input: "Review this repository" }, + method: "POST", + url: "https://api.example.com/api/v1/runs", + }); + expect(requests[0]?.headers.get("Authorization")).toBe("Bearer msk_workspace_test"); + }); + test("maps createThread fileIds to public file resources", async () => { const requests: RecordedRequest[] = []; const fetchMock: MosooPublicApiFetch = async (input, init) => { From 73fc619f450ab658ad5de8bee0f9d35e71e03799 Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Thu, 20 Aug 2026 00:22:17 +0800 Subject: [PATCH 4/5] feat(product): pivot console to harness marketplace --- apps/api/bin/deploy-prod.ts | 6 +- apps/api/bin/dev-local.ts | 126 +- apps/api/bin/lark-ws-sidecar.ts | 280 --- apps/api/bin/wechat-ilink-mock.ts | 250 --- apps/api/openapi/public-api-v1.generated.json | 72 +- apps/api/package.json | 1 - .../durable-objects/channel-connection.do.ts | 110 -- .../retired-channel-connection.do.ts | 12 + .../adapters/graphql/create-graphql-schema.ts | 2 - .../adapters/graphql/graphql-module-specs.ts | 32 +- .../adapters/graphql/schema.generated.graphql | 334 ---- .../adapters/graphql/schema/agent-schema.ts | 27 - .../src/adapters/graphql/schema/app-schema.ts | 134 -- .../adapters/graphql/schema/channel-schema.ts | 146 -- .../adapters/graphql/schema/session-schema.ts | 10 - apps/api/src/adapters/http/create-http-app.ts | 12 - .../http/request-logging.middleware.ts | 31 +- .../http/routes/discord-events-route.ts | 114 -- .../adapters/http/routes/lark-events-route.ts | 140 -- .../routes/lark-gateway-internal-route.ts | 174 -- .../http/routes/owner-debug-terminal-route.ts | 44 - .../routes/public-api-openapi-components.ts | 128 +- .../http/routes/public-api-route-support.ts | 171 +- .../adapters/http/routes/public-api-route.ts | 488 ++--- .../http/routes/slack-events-route.ts | 124 -- .../http/routes/telegram-events-route.ts | 111 -- apps/api/src/index.ts | 2 +- .../application/agent-command.service.ts | 7 +- .../modules/agents/graphql/agent-graphql.ts | 15 - .../application/api-command-enqueue.ts | 36 - .../application/api-command-payload.ts | 314 +--- .../application/api-command-policy.ts | 17 - .../application/api-command-processor.ts | 448 +---- .../app-agent-binding-resolution.ts | 71 - ...deployment-capability-authority.service.ts | 171 -- .../app-deployment-cloudflare-client.ts | 406 ---- .../application/app-deployment-detector.ts | 936 ---------- .../app-deployment-executor.service.ts | 1015 ---------- .../application/app-deployment.service.ts | 843 --------- .../apps/application/app-overview.service.ts | 231 --- .../apps/domain/app-deployment-lifecycle.ts | 10 - .../src/modules/apps/graphql/app-graphql.ts | 64 +- .../application/public-api-caller.service.ts | 25 +- .../agent-channel-binding-create.service.ts | 291 --- .../agent-channel-binding-error.ts | 52 - ...annel-binding-lark-registration.service.ts | 114 -- ...ent-channel-binding-maintenance.service.ts | 251 --- .../agent-channel-binding-records.ts | 378 ---- ...-channel-binding-wechat-pairing.service.ts | 327 ---- .../agent-channel-binding.service.ts | 30 - .../agent-channel-binding.types.ts | 117 -- .../application/channel-adapter-logger.ts | 31 - .../application/channel-agent-reply.ts | 44 - .../application/channel-binding-context.ts | 104 -- .../application/channel-connection-client.ts | 82 - .../application/channel-connection-health.ts | 89 - .../channel-connection-state.service.ts | 352 ---- .../channel-credential-secret-resolution.ts | 289 --- .../application/channel-display-metadata.ts | 21 - .../channel-event-receipt-store.ts | 173 -- .../channel-final-delivery-errors.ts | 132 -- .../channel-final-delivery-jobs.ts | 314 ---- .../channel-final-delivery-message.ts | 62 - .../channel-final-delivery-payload.ts | 134 -- .../channel-final-delivery-reply.ts | 301 --- .../channel-final-delivery.service.ts | 333 ---- .../channel-session-command-client.ts | 226 --- .../application/channel-session-reply.ts | 59 - .../application/channel-session.service.ts | 7 - .../application/channel-session.types.ts | 48 - .../channel-thread-session-store.ts | 307 --- ...-gateway-connection-maintenance.service.ts | 110 -- ...ark-long-connection-maintenance.service.ts | 178 -- .../slack-channel-session.service.ts | 323 ---- ...echat-polling-owner-maintenance.service.ts | 365 ---- .../api/src/modules/channels/channel-fetch.ts | 58 - .../channels/discord/discord-credentials.ts | 66 - .../channels/discord/discord-events.ts | 233 --- .../discord/discord-first-party-adapter.ts | 181 -- .../discord/discord-gateway-client.ts | 394 ---- .../discord-gateway-connection-client.ts | 25 - .../discord/discord-gateway-health.ts | 79 - .../channels/discord/discord-gateway-owner.ts | 233 --- .../channels/discord/discord-gateway-relay.ts | 77 - .../discord/discord-gateway-runtime-state.ts | 158 -- .../discord/discord-gateway-socket.ts | 46 - .../channels/discord/discord-gateway.do.ts | 521 ------ .../channels/discord/discord-signing.ts | 132 -- .../channels/discord/discord-web-api.ts | 222 --- .../channels/graphql/channel-graphql.ts | 118 -- .../channels/lark/lark-app-registration.ts | 261 --- .../modules/channels/lark/lark-credentials.ts | 163 -- .../src/modules/channels/lark/lark-events.ts | 470 ----- .../channels/lark/lark-first-party-adapter.ts | 165 -- .../channels/lark/lark-gateway-state.ts | 116 -- .../channels/lark/lark-gateway-wiring.ts | 147 -- .../modules/channels/lark/lark-gateway.do.ts | 424 ----- .../lark/lark-long-connection-client.ts | 432 ----- .../channels/lark/lark-sidecar-registry.ts | 107 -- .../src/modules/channels/lark/lark-signing.ts | 133 -- .../src/modules/channels/lark/lark-web-api.ts | 222 --- .../channels/slack/slack-adapter-logger.ts | 9 - .../channels/slack/slack-agent-reply.ts | 1 - .../channels/slack/slack-credentials.ts | 80 - .../modules/channels/slack/slack-events.ts | 266 --- .../slack/slack-first-party-adapter.ts | 205 -- .../modules/channels/slack/slack-signing.ts | 121 -- .../modules/channels/slack/slack-web-api.ts | 190 -- .../channels/telegram/telegram-credentials.ts | 62 - .../channels/telegram/telegram-events.ts | 263 --- .../telegram/telegram-first-party-adapter.ts | 168 -- .../channels/telegram/telegram-signing.ts | 66 - .../channels/telegram/telegram-web-api.ts | 176 -- .../wechat-context-token-secret-store.ts | 82 - .../channels/wechat/wechat-credentials.ts | 82 - .../modules/channels/wechat/wechat-events.ts | 370 ---- .../wechat/wechat-first-party-adapter.ts | 121 -- .../channels/wechat/wechat-ilink-base-url.ts | 37 - .../channels/wechat/wechat-ilink-client.ts | 394 ---- .../wechat/wechat-polling-owner-store.ts | 189 -- .../channels/wechat/wechat-polling-owner.ts | 421 ----- .../channels/wechat/wechat-reply.service.ts | 90 - .../channels/wechat/wechat-runtime-store.ts | 468 ----- .../modules/channels/wechat/wechat-runtime.ts | 153 -- .../public-api/app-agent-bound-ask.service.ts | 449 ----- .../public-api/app-agent-bound-call.ts | 207 -- .../public-api/app-agent-bound-errors.ts | 114 -- .../app-agent-bound-idempotency.service.ts | 172 -- .../public-api/app-agent-capability.ts | 183 -- ...bound-capability-run-provenance.service.ts | 76 - .../deployment-capability-caller.service.ts | 167 -- .../public-api/public-thread-admission.ts | 70 +- .../public-thread-api-command.service.ts | 28 +- .../public-api/public-thread-create.ts | 12 +- .../public-thread-file-api.service.ts | 48 +- .../public-api/public-thread-metadata.ts | 110 +- .../public-thread-session-query.service.ts | 62 +- .../modules/public-api/public-thread-store.ts | 39 +- .../owner-debug-terminal.service.ts | 96 - .../create-agent-session.service.ts | 16 +- .../session-runs/queue-run.service.ts | 5 - .../send-agent-session-events.service.ts | 2 - .../session-runs/start-runs.service.ts | 3 - .../domain/bound-capability-run-provenance.ts | 26 - .../session-run-admission.repository.ts | 26 - .../session-run-write.repository.ts | 14 - .../sessions/graphql/session-graphql.ts | 21 +- .../platform/cloudflare/create-api-worker.ts | 11 - .../src/platform/cloudflare/worker-types.ts | 39 - apps/api/src/platform/db/drizzle.ts | 22 - apps/api/src/platform/errors.ts | 46 - .../agent-channel-binding-delete.test.ts | 197 -- .../tests/agent-channel-binding-fixtures.ts | 375 ---- ...hannel-binding-provider-validation.test.ts | 405 ---- .../agent-channel-binding-wechat.test.ts | 653 ------- apps/api/tests/agent-channel-binding.test.ts | 406 ---- apps/api/tests/api-web-boundary.test.ts | 70 +- .../app-agent-binding-resolution.test.ts | 54 - apps/api/tests/app-agent-bound-call.test.ts | 312 ---- .../app-agent-bound-run-revocation.test.ts | 380 ---- ...p-agent-capability-revocation-http.test.ts | 262 --- apps/api/tests/app-agent-capability.test.ts | 103 - ...pp-deployment-capability-authority.test.ts | 162 -- .../app-deployment-cloudflare-client.test.ts | 38 - .../api/tests/app-deployment-detector.test.ts | 404 ---- apps/api/tests/app-deployment-service.test.ts | 1663 ----------------- apps/api/tests/app-overview.test.ts | 479 ----- .../tests/bound-agent-idempotency.e2e.test.ts | 858 --------- apps/api/tests/bound-capability-fixtures.ts | 161 -- ...d-capability-public-thread-api.e2e.test.ts | 638 ------- .../tests/channel-connection-do-fixtures.ts | 276 --- apps/api/tests/channel-connection-do.test.ts | 441 ----- .../tests/channel-connection-state.test.ts | 336 ---- .../channel-final-delivery-fetch-fixtures.ts | 183 -- .../channel-final-delivery-job-fixtures.ts | 339 ---- .../channel-final-delivery-scheduling.test.ts | 695 ------- .../tests/channel-provider-contract.test.ts | 70 - apps/api/tests/channel-session-fixtures.ts | 31 - .../discord-channel-adapter-dispatch.test.ts | 374 ---- .../api/tests/discord-channel-adapter.test.ts | 213 --- .../discord-channel-events-route.test.ts | 445 ----- apps/api/tests/discord-gateway-owner.test.ts | 321 ---- .../api/tests/discord-gateway-runtime.test.ts | 385 ---- ...ixture.ts => api-command-queue-fixture.ts} | 32 - apps/api/tests/helpers/api-test-fixture.ts | 41 +- .../helpers/public-api-http-core-schema.sql | 112 -- .../helpers/public-api-http-test-fixture.ts | 14 +- .../helpers/public-api-http-wechat-schema.sql | 101 - .../http-route-platform-id-error.test.ts | 30 - apps/api/tests/lark-channel-adapter.test.ts | 462 ----- .../tests/lark-channel-events-route.test.ts | 504 ----- .../multi-provider-channel-session.test.ts | 256 --- apps/api/tests/owner-debug-terminal.test.ts | 141 -- apps/api/tests/platform-errors.test.ts | 4 +- apps/api/tests/prod-schema-guard.test.ts | 2 +- apps/api/tests/public-thread-metadata.test.ts | 53 +- .../request-logging-path-redaction.test.ts | 27 - .../slack-channel-adapter-events.test.ts | 265 --- apps/api/tests/slack-channel-adapter.test.ts | 288 --- .../tests/slack-channel-events-route.test.ts | 268 --- .../tests/slack-channel-session-fixtures.ts | 100 - .../slack-channel-session-threading.test.ts | 322 ---- apps/api/tests/slack-channel-session.test.ts | 441 ----- .../tests/telegram-channel-adapter.test.ts | 333 ---- .../telegram-channel-events-route.test.ts | 183 -- .../wechat-channel-connection-fixtures.ts | 184 -- ...hat-channel-connection-maintenance.test.ts | 287 --- ...hat-channel-connection-persistence.test.ts | 241 --- .../tests/wechat-channel-connection.test.ts | 208 --- .../wechat-channel-final-delivery.test.ts | 279 --- apps/api/tests/wechat-polling-owner.test.ts | 411 ---- apps/api/tests/workspace-api-key.test.ts | 3 +- apps/api/wrangler.toml | 48 - apps/web/src/app/app-shell.tsx | 8 +- apps/web/src/app/document-title.tsx | 4 +- apps/web/src/app/navigation.tsx | 35 +- apps/web/src/app/route-registry.tsx | 10 +- .../agent/api/agent-channel-documents.ts | 134 -- .../web/src/domains/agent/api/agent-client.ts | 147 -- .../src/domains/agent/api/agent-documents.ts | 1 - .../agent/api/agent-runtime-documents.ts | 33 - .../src/domains/agent/query/agent-queries.ts | 52 +- .../domains/app/api/app-deployment-client.ts | 241 --- .../app/query/app-deployment-queries.ts | 122 -- .../auth/api/workspace-api-key-client.ts | 105 ++ .../domains/run/api/workspace-run-client.ts | 49 + apps/web/src/gql/gql.ts | 120 -- apps/web/src/gql/graphql.ts | 648 ------- apps/web/src/import-meta.d.ts | 2 - .../routes/agent/agent-debug-menu-policy.ts | 26 - .../src/routes/agent/agent-detail.route.tsx | 46 +- .../agent/components/agent-kind-section.tsx | 101 - .../components/agent-session-panel-header.tsx | 14 +- .../agent-session-panel-model-types.ts | 1 - .../components/agent-session-panel-rules.ts | 9 +- .../agent/components/agent-session-panel.tsx | 30 +- .../components/channel-webhook-origin.ts | 35 - .../components/channels-config-dialog.tsx | 74 - .../agent/components/channels-field.tsx | 46 - .../agent/components/channels-list-widget.tsx | 81 - .../components/create-agent-launcher.tsx | 2 +- .../editor/environment-picker-policy.ts | 16 - .../components/editor/environment-picker.tsx | 64 +- .../agent/components/editor/form-sections.tsx | 10 - .../agent/components/editor/form-view.tsx | 12 +- .../agent/components/editor/use-model.ts | 89 +- .../routes/agent/components/kind-selector.tsx | 230 --- .../routes/agent/components/preview-mode.tsx | 37 +- .../settings-dialog-channel-agent.ts | 8 - .../settings-dialog-channels-view.tsx | 503 ----- .../settings-dialog-danger-zone.tsx | 308 +-- .../settings-dialog-discord-setup.tsx | 141 -- .../components/settings-dialog-lark-setup.tsx | 668 ------- .../agent/components/settings-dialog-model.ts | 29 - .../settings-dialog-slack-manifest.ts | 49 - .../settings-dialog-slack-setup.tsx | 265 --- .../settings-dialog-telegram-setup.tsx | 109 -- .../settings-dialog-wechat-setup.tsx | 277 --- .../routes/agent/components/terminal-mode.tsx | 322 ---- .../use-agent-session-panel-model.ts | 84 +- .../agent/lifecycle/distribution-info.ts | 9 - .../agent/lifecycle/kind-fork-dialog.tsx | 199 -- .../lifecycle/live-config-action-dialog.tsx | 62 +- .../lifecycle/pending-changes-banner.tsx | 1 - .../routes/agent/lifecycle/publish-menu.tsx | 25 +- .../routes/agent/terminal-reconnect-buffer.ts | 32 - .../app-overview/app-overview-install.tsx | 188 -- .../app-overview/app-overview.route.tsx | 476 ++++- .../deploy/components/deploy-actions.tsx | 122 -- .../deploy/components/deploy-overview.tsx | 199 -- .../deploy/components/deploy-repo-card.tsx | 111 -- .../deploy/components/deploy-status-badge.tsx | 51 - .../deploy/components/deploy-url-card.tsx | 169 -- .../deploy/components/deployments-history.tsx | 272 --- .../deploy/deploy-console-data.ts | 166 -- .../deploy/deploy-console-mapping.ts | 126 -- .../app-overview/deploy/deploy-surface.tsx | 219 --- .../app-overview/deploy/deployment-status.ts | 34 - .../app-overview/deploy/local-preview-url.ts | 125 -- .../app-overview/deploy/use-deploy-console.ts | 177 -- .../deploy/use-live-deploy-console.ts | 122 -- .../app-overview/deploy/use-now-tick.ts | 21 - .../deploy/v0-deploy-preview.route.tsx | 47 - .../app-overview/onboarding-setup-prompt.ts | 28 - .../routes/app-overview/onboarding-steps.tsx | 221 --- .../app-overview/use-onboarding-progress.ts | 87 - .../environments/environment-list-table.tsx | 2 +- .../src/routes/threads/compose/new-dialog.tsx | 10 - apps/web/src/routes/typed-id.ts | 15 - .../workspace-api-keys.route.tsx | 253 +++ apps/web/src/shared/i18n/translations/en.json | 73 +- apps/web/src/shared/i18n/translations/ja.json | 73 +- .../src/shared/i18n/translations/zh-CN.json | 73 +- .../src/shared/i18n/translations/zh-TW.json | 73 +- apps/web/src/shared/ui/app-id-badge.tsx | 10 +- apps/web/src/shared/ui/channel-brand-icon.tsx | 134 -- .../web/tests/agent-debug-menu-policy.test.ts | 31 - .../tests/agent-instruction-prompt.test.ts | 104 -- .../tests/agent-runtime-lock-boundary.test.ts | 38 +- .../agent-session-panel-boundary.test.ts | 51 +- .../agent-terminal-entry-boundary.test.ts | 28 +- .../web/tests/app-navigation-boundary.test.ts | 38 +- apps/web/tests/app-overview-boundary.test.ts | 211 +-- apps/web/tests/app-settings-boundary.test.ts | 8 +- apps/web/tests/channels-ia-boundary.test.ts | 37 - apps/web/tests/deployment-status.test.ts | 107 -- .../tests/environment-picker-policy.test.ts | 21 - .../tests/mobile-responsive-boundary.test.ts | 13 +- apps/web/tests/onboarding-progress.test.ts | 18 - .../reported-i18n-layout-regressions.test.ts | 10 - apps/web/tests/slack-channel-setup.test.ts | 47 - bun.lock | 57 - pkgs/contracts/package.json | 1 - pkgs/contracts/src/app/app.contract.ts | 138 +- .../contracts/src/channel/channel.contract.ts | 58 - pkgs/contracts/src/id/id.contract.ts | 3 - pkgs/contracts/src/index.ts | 1 - pkgs/db/src/index.ts | 1 - pkgs/db/src/schema/api-command.schema.ts | 2 - pkgs/db/src/schema/app.schema.ts | 123 +- pkgs/db/src/schema/channel.schema.ts | 305 --- pkgs/db/src/schema/public-api.schema.ts | 25 +- pkgs/db/src/schema/session/runs.schema.ts | 13 - pkgs/id/src/index.ts | 3 - 324 files changed, 1937 insertions(+), 50921 deletions(-) delete mode 100755 apps/api/bin/lark-ws-sidecar.ts delete mode 100755 apps/api/bin/wechat-ilink-mock.ts delete mode 100644 apps/api/src/adapters/durable-objects/channel-connection.do.ts create mode 100644 apps/api/src/adapters/durable-objects/retired-channel-connection.do.ts delete mode 100644 apps/api/src/adapters/graphql/schema/channel-schema.ts delete mode 100644 apps/api/src/adapters/http/routes/discord-events-route.ts delete mode 100644 apps/api/src/adapters/http/routes/lark-events-route.ts delete mode 100644 apps/api/src/adapters/http/routes/lark-gateway-internal-route.ts delete mode 100644 apps/api/src/adapters/http/routes/owner-debug-terminal-route.ts delete mode 100644 apps/api/src/adapters/http/routes/slack-events-route.ts delete mode 100644 apps/api/src/adapters/http/routes/telegram-events-route.ts delete mode 100644 apps/api/src/modules/api-command/application/api-command-policy.ts delete mode 100644 apps/api/src/modules/apps/application/app-agent-binding-resolution.ts delete mode 100644 apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts delete mode 100644 apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts delete mode 100644 apps/api/src/modules/apps/application/app-deployment-detector.ts delete mode 100644 apps/api/src/modules/apps/application/app-deployment-executor.service.ts delete mode 100644 apps/api/src/modules/apps/application/app-deployment.service.ts delete mode 100644 apps/api/src/modules/apps/application/app-overview.service.ts delete mode 100644 apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-create.service.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-error.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-lark-registration.service.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-maintenance.service.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-records.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding-wechat-pairing.service.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding.service.ts delete mode 100644 apps/api/src/modules/channels/application/agent-channel-binding.types.ts delete mode 100644 apps/api/src/modules/channels/application/channel-adapter-logger.ts delete mode 100644 apps/api/src/modules/channels/application/channel-agent-reply.ts delete mode 100644 apps/api/src/modules/channels/application/channel-binding-context.ts delete mode 100644 apps/api/src/modules/channels/application/channel-connection-client.ts delete mode 100644 apps/api/src/modules/channels/application/channel-connection-health.ts delete mode 100644 apps/api/src/modules/channels/application/channel-connection-state.service.ts delete mode 100644 apps/api/src/modules/channels/application/channel-credential-secret-resolution.ts delete mode 100644 apps/api/src/modules/channels/application/channel-display-metadata.ts delete mode 100644 apps/api/src/modules/channels/application/channel-event-receipt-store.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery-errors.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery-jobs.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery-message.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery-payload.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery-reply.ts delete mode 100644 apps/api/src/modules/channels/application/channel-final-delivery.service.ts delete mode 100644 apps/api/src/modules/channels/application/channel-session-command-client.ts delete mode 100644 apps/api/src/modules/channels/application/channel-session-reply.ts delete mode 100644 apps/api/src/modules/channels/application/channel-session.service.ts delete mode 100644 apps/api/src/modules/channels/application/channel-session.types.ts delete mode 100644 apps/api/src/modules/channels/application/channel-thread-session-store.ts delete mode 100644 apps/api/src/modules/channels/application/discord-gateway-connection-maintenance.service.ts delete mode 100644 apps/api/src/modules/channels/application/lark-long-connection-maintenance.service.ts delete mode 100644 apps/api/src/modules/channels/application/slack-channel-session.service.ts delete mode 100644 apps/api/src/modules/channels/application/wechat-polling-owner-maintenance.service.ts delete mode 100644 apps/api/src/modules/channels/channel-fetch.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-credentials.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-events.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-first-party-adapter.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-client.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-connection-client.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-health.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-owner.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-relay.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-runtime-state.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway-socket.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-gateway.do.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-signing.ts delete mode 100644 apps/api/src/modules/channels/discord/discord-web-api.ts delete mode 100644 apps/api/src/modules/channels/graphql/channel-graphql.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-app-registration.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-credentials.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-events.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-first-party-adapter.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-gateway-state.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-gateway-wiring.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-gateway.do.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-long-connection-client.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-sidecar-registry.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-signing.ts delete mode 100644 apps/api/src/modules/channels/lark/lark-web-api.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-adapter-logger.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-agent-reply.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-credentials.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-events.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-first-party-adapter.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-signing.ts delete mode 100644 apps/api/src/modules/channels/slack/slack-web-api.ts delete mode 100644 apps/api/src/modules/channels/telegram/telegram-credentials.ts delete mode 100644 apps/api/src/modules/channels/telegram/telegram-events.ts delete mode 100644 apps/api/src/modules/channels/telegram/telegram-first-party-adapter.ts delete mode 100644 apps/api/src/modules/channels/telegram/telegram-signing.ts delete mode 100644 apps/api/src/modules/channels/telegram/telegram-web-api.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-context-token-secret-store.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-credentials.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-events.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-first-party-adapter.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-ilink-base-url.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-ilink-client.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-polling-owner-store.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-polling-owner.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-reply.service.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-runtime-store.ts delete mode 100644 apps/api/src/modules/channels/wechat/wechat-runtime.ts delete mode 100644 apps/api/src/modules/public-api/app-agent-bound-ask.service.ts delete mode 100644 apps/api/src/modules/public-api/app-agent-bound-call.ts delete mode 100644 apps/api/src/modules/public-api/app-agent-bound-errors.ts delete mode 100644 apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts delete mode 100644 apps/api/src/modules/public-api/app-agent-capability.ts delete mode 100644 apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts delete mode 100644 apps/api/src/modules/public-api/deployment-capability-caller.service.ts delete mode 100644 apps/api/src/modules/runtime/application/owner-debug-terminal.service.ts delete mode 100644 apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts delete mode 100644 apps/api/tests/agent-channel-binding-delete.test.ts delete mode 100644 apps/api/tests/agent-channel-binding-fixtures.ts delete mode 100644 apps/api/tests/agent-channel-binding-provider-validation.test.ts delete mode 100644 apps/api/tests/agent-channel-binding-wechat.test.ts delete mode 100644 apps/api/tests/agent-channel-binding.test.ts delete mode 100644 apps/api/tests/app-agent-binding-resolution.test.ts delete mode 100644 apps/api/tests/app-agent-bound-call.test.ts delete mode 100644 apps/api/tests/app-agent-bound-run-revocation.test.ts delete mode 100644 apps/api/tests/app-agent-capability-revocation-http.test.ts delete mode 100644 apps/api/tests/app-agent-capability.test.ts delete mode 100644 apps/api/tests/app-deployment-capability-authority.test.ts delete mode 100644 apps/api/tests/app-deployment-cloudflare-client.test.ts delete mode 100644 apps/api/tests/app-deployment-detector.test.ts delete mode 100644 apps/api/tests/app-deployment-service.test.ts delete mode 100644 apps/api/tests/app-overview.test.ts delete mode 100644 apps/api/tests/bound-agent-idempotency.e2e.test.ts delete mode 100644 apps/api/tests/bound-capability-fixtures.ts delete mode 100644 apps/api/tests/bound-capability-public-thread-api.e2e.test.ts delete mode 100644 apps/api/tests/channel-connection-do-fixtures.ts delete mode 100644 apps/api/tests/channel-connection-do.test.ts delete mode 100644 apps/api/tests/channel-connection-state.test.ts delete mode 100644 apps/api/tests/channel-final-delivery-fetch-fixtures.ts delete mode 100644 apps/api/tests/channel-final-delivery-job-fixtures.ts delete mode 100644 apps/api/tests/channel-final-delivery-scheduling.test.ts delete mode 100644 apps/api/tests/channel-provider-contract.test.ts delete mode 100644 apps/api/tests/channel-session-fixtures.ts delete mode 100644 apps/api/tests/discord-channel-adapter-dispatch.test.ts delete mode 100644 apps/api/tests/discord-channel-adapter.test.ts delete mode 100644 apps/api/tests/discord-channel-events-route.test.ts delete mode 100644 apps/api/tests/discord-gateway-owner.test.ts delete mode 100644 apps/api/tests/discord-gateway-runtime.test.ts rename apps/api/tests/helpers/{channel-final-delivery-queue-fixture.ts => api-command-queue-fixture.ts} (64%) delete mode 100644 apps/api/tests/lark-channel-adapter.test.ts delete mode 100644 apps/api/tests/lark-channel-events-route.test.ts delete mode 100644 apps/api/tests/multi-provider-channel-session.test.ts delete mode 100644 apps/api/tests/owner-debug-terminal.test.ts delete mode 100644 apps/api/tests/request-logging-path-redaction.test.ts delete mode 100644 apps/api/tests/slack-channel-adapter-events.test.ts delete mode 100644 apps/api/tests/slack-channel-adapter.test.ts delete mode 100644 apps/api/tests/slack-channel-events-route.test.ts delete mode 100644 apps/api/tests/slack-channel-session-fixtures.ts delete mode 100644 apps/api/tests/slack-channel-session-threading.test.ts delete mode 100644 apps/api/tests/slack-channel-session.test.ts delete mode 100644 apps/api/tests/telegram-channel-adapter.test.ts delete mode 100644 apps/api/tests/telegram-channel-events-route.test.ts delete mode 100644 apps/api/tests/wechat-channel-connection-fixtures.ts delete mode 100644 apps/api/tests/wechat-channel-connection-maintenance.test.ts delete mode 100644 apps/api/tests/wechat-channel-connection-persistence.test.ts delete mode 100644 apps/api/tests/wechat-channel-connection.test.ts delete mode 100644 apps/api/tests/wechat-channel-final-delivery.test.ts delete mode 100644 apps/api/tests/wechat-polling-owner.test.ts delete mode 100644 apps/web/src/domains/agent/api/agent-channel-documents.ts delete mode 100644 apps/web/src/domains/app/api/app-deployment-client.ts delete mode 100644 apps/web/src/domains/app/query/app-deployment-queries.ts create mode 100644 apps/web/src/domains/auth/api/workspace-api-key-client.ts create mode 100644 apps/web/src/domains/run/api/workspace-run-client.ts delete mode 100644 apps/web/src/routes/agent/agent-debug-menu-policy.ts delete mode 100644 apps/web/src/routes/agent/components/agent-kind-section.tsx delete mode 100644 apps/web/src/routes/agent/components/channel-webhook-origin.ts delete mode 100644 apps/web/src/routes/agent/components/channels-config-dialog.tsx delete mode 100644 apps/web/src/routes/agent/components/channels-field.tsx delete mode 100644 apps/web/src/routes/agent/components/channels-list-widget.tsx delete mode 100644 apps/web/src/routes/agent/components/editor/environment-picker-policy.ts delete mode 100644 apps/web/src/routes/agent/components/kind-selector.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-channel-agent.ts delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-channels-view.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-discord-setup.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-lark-setup.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-model.ts delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-slack-manifest.ts delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-slack-setup.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-telegram-setup.tsx delete mode 100644 apps/web/src/routes/agent/components/settings-dialog-wechat-setup.tsx delete mode 100644 apps/web/src/routes/agent/components/terminal-mode.tsx delete mode 100644 apps/web/src/routes/agent/lifecycle/kind-fork-dialog.tsx delete mode 100644 apps/web/src/routes/agent/terminal-reconnect-buffer.ts delete mode 100644 apps/web/src/routes/app-overview/app-overview-install.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deploy-actions.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deploy-overview.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deploy-repo-card.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deploy-status-badge.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deploy-url-card.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/components/deployments-history.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/deploy-console-data.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/deploy-console-mapping.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/deploy-surface.tsx delete mode 100644 apps/web/src/routes/app-overview/deploy/deployment-status.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/local-preview-url.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/use-deploy-console.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/use-live-deploy-console.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/use-now-tick.ts delete mode 100644 apps/web/src/routes/app-overview/deploy/v0-deploy-preview.route.tsx delete mode 100644 apps/web/src/routes/app-overview/onboarding-setup-prompt.ts delete mode 100644 apps/web/src/routes/app-overview/onboarding-steps.tsx delete mode 100644 apps/web/src/routes/app-overview/use-onboarding-progress.ts create mode 100644 apps/web/src/routes/workspace-api-keys/workspace-api-keys.route.tsx delete mode 100644 apps/web/src/shared/ui/channel-brand-icon.tsx delete mode 100644 apps/web/tests/agent-debug-menu-policy.test.ts delete mode 100644 apps/web/tests/agent-instruction-prompt.test.ts delete mode 100644 apps/web/tests/channels-ia-boundary.test.ts delete mode 100644 apps/web/tests/deployment-status.test.ts delete mode 100644 apps/web/tests/environment-picker-policy.test.ts delete mode 100644 apps/web/tests/onboarding-progress.test.ts delete mode 100644 apps/web/tests/slack-channel-setup.test.ts delete mode 100644 pkgs/contracts/src/channel/channel.contract.ts delete mode 100644 pkgs/db/src/schema/channel.schema.ts diff --git a/apps/api/bin/deploy-prod.ts b/apps/api/bin/deploy-prod.ts index 286a6f4b..a9014198 100755 --- a/apps/api/bin/deploy-prod.ts +++ b/apps/api/bin/deploy-prod.ts @@ -107,11 +107,7 @@ async function assertProdSchemaMatchesMigrations(expectedTables: readonly string writeStdout(` prod schema OK (${expectedTables.length} migration tables present)`); } -const REQUIRED_PROD_QUEUES: readonly string[] = [ - "environment-artifact-build", - "channel-final-delivery", - "channel-final-delivery-dlq", -]; +const REQUIRED_PROD_QUEUES: readonly string[] = ["environment-artifact-build"]; function listProdQueues(): string[] { const result = Bun.spawnSync([wranglerBin, "queues", "list"], { cwd: apiDir }); diff --git a/apps/api/bin/dev-local.ts b/apps/api/bin/dev-local.ts index 52daee66..6a181ae5 100755 --- a/apps/api/bin/dev-local.ts +++ b/apps/api/bin/dev-local.ts @@ -17,8 +17,6 @@ const DOCKER_HOST_ENV_KEY = "DOCKER_HOST"; const DEV_DOCKER_HOST_ENV_KEY = "MOSOO_API_DEV_DOCKER_HOST"; const DEV_RUNTIME_PROXY_HOST_ENV_KEY = "MOSOO_API_DEV_RUNTIME_PROXY_HOST"; const RUNTIME_CONTROL_ORIGIN_ENV_KEY = "MOSOO_RUNTIME_CONTROL_ORIGIN"; -const LARK_SIDECAR_DISABLED_ENV_KEY = "MOSOO_LARK_SIDECAR_DISABLED"; -const LARK_SIDECAR_SECRET_ENV_KEY = "MOSOO_LARK_SIDECAR_SECRET"; const SCRUB_HOST_PROXY_ENV_KEY = "MOSOO_API_DEV_SCRUB_HOST_PROXY"; const USE_DEFAULT_DOCKER_ENV_KEY = "MOSOO_API_DEV_USE_DEFAULT_DOCKER"; const SCHEDULED_HANDLER_PUMP_INTERVAL_ENV_KEY = "MOSOO_API_DEV_SCHEDULED_PUMP_INTERVAL_MS"; @@ -232,50 +230,6 @@ function createRuntimeControlOriginVarArgs(env: NodeJS.ProcessEnv): string[] { : ["--var", `${RUNTIME_CONTROL_ORIGIN_ENV_KEY}:${value}`]; } -function unquoteDevVarValue(value: string): string { - const trimmed = value.trim(); - - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - - return trimmed; -} - -async function readLocalDevVars(): Promise> { - const devVarsPath = `${apiDir}/.dev.vars`; - - if (!(await Bun.file(devVarsPath).exists())) { - return {}; - } - - const entries: Record = {}; - const content = await Bun.file(devVarsPath).text(); - - for (const rawLine of content.split(/\r?\n/u)) { - const line = rawLine.trim(); - - if (line.length === 0 || line.startsWith("#")) { - continue; - } - - const separatorIndex = line.indexOf("="); - - if (separatorIndex <= 0) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - const value = unquoteDevVarValue(line.slice(separatorIndex + 1)); - entries[key] = value; - } - - return entries; -} - function readNonEmptyEnvValue(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined { for (const key of keys) { const value = env[key]?.trim(); @@ -288,21 +242,8 @@ function readNonEmptyEnvValue(env: NodeJS.ProcessEnv, keys: readonly string[]): return undefined; } -async function createWeChatIlinkBaseUrlVarArgs(env: NodeJS.ProcessEnv): Promise { - const devVars = await readLocalDevVars(); - const value = - readNonEmptyEnvValue(env, ["WECHAT_ILINK_BASE_URL"]) ?? - readNonEmptyEnvValue(devVars, ["WECHAT_ILINK_BASE_URL"]); - - return typeof value === "string" && value.trim().length > 0 - ? ["--var", `WECHAT_ILINK_BASE_URL:${value.trim()}`] - : []; -} - -// Wrangler dev does not fire `[triggers] crons` automatically; cron-driven flows like -// channel_final_delivery_job stay queued until something POSTs `/cdn-cgi/handler/scheduled`. -// This pump mirrors the prod `* * * * *` cron locally so Slack/Discord/Lark/Telegram/WeChat -// replies drain without manual curls. Returns null when explicitly disabled. +// Wrangler dev does not fire `[triggers] crons` automatically. This pump mirrors the +// production schedule locally so maintenance and usage jobs run without manual requests. function parseScheduledHandlerPumpIntervalMs(env: NodeJS.ProcessEnv): number | null { const raw = env[SCHEDULED_HANDLER_PUMP_INTERVAL_ENV_KEY]?.trim(); if (raw === undefined || raw.length === 0) { @@ -329,7 +270,7 @@ function startScheduledHandlerPump(port: string, intervalMs: number): void { announced = true; writeStderr( `[mosoo/api] Local scheduled-handler pump active at ${url} every ${Math.round(intervalMs / 1000)}s. ` + - `Mirrors the prod * * * * * cron so cron-picked channel queues drain locally. ` + + `Mirrors the production maintenance cron. ` + `Set ${SCHEDULED_HANDLER_PUMP_INTERVAL_ENV_KEY}=off to disable.`, ); } @@ -344,55 +285,6 @@ function startScheduledHandlerPump(port: string, intervalMs: number): void { }, SCHEDULED_HANDLER_PUMP_BOOT_DELAY_MS); } -// The official Lark long-connection SDK is Node-only, so local dev runs it in a -// sidecar process and authenticates loopback callbacks with a boot secret. -function shouldStartLarkSidecar(env: NodeJS.ProcessEnv): boolean { - const raw = env[LARK_SIDECAR_DISABLED_ENV_KEY]?.trim().toLowerCase(); - return raw !== "1" && raw !== "true" && raw !== "yes"; -} - -function createLarkSidecarVarArgs(secret: string): string[] { - return ["--var", `${LARK_SIDECAR_SECRET_ENV_KEY}:${secret}`]; -} - -function startLarkSidecar(input: { - apiDir: string; - env: NodeJS.ProcessEnv; - secret: string; - workerUrl: string; -}): void { - const child = Bun.spawn([vpBin, "exec", "bun", "bin/lark-ws-sidecar.ts"], { - cwd: input.apiDir, - env: { - ...input.env, - MOSOO_API_BASE_URL: input.workerUrl, - [LARK_SIDECAR_SECRET_ENV_KEY]: input.secret, - }, - stderr: "inherit", - stdin: "inherit", - stdout: "inherit", - }); - - void child.exited.then((code) => { - if (code !== 0 && code !== 130 && code !== 143) { - writeStderr(`[mosoo/api] Lark WebSocket sidecar exited unexpectedly (code=${code})`); - } - }); - - process.on("exit", () => { - try { - child.kill(); - } catch { - // best effort - } - }); - - writeStderr( - `[mosoo/api] Lark WebSocket sidecar started (pid=${child.pid ?? "?"}, worker=${input.workerUrl}). ` + - `Set ${LARK_SIDECAR_DISABLED_ENV_KEY}=1 to disable.`, - ); -} - function resolveDevWebOrigin(env: NodeJS.ProcessEnv): string { const explicit = env.WEB_ORIGIN?.trim(); if (explicit !== undefined && explicit.length > 0) { @@ -454,16 +346,6 @@ const scheduledPumpIntervalMs = parseScheduledHandlerPumpIntervalMs(wranglerEnv) if (scheduledPumpIntervalMs !== null) { startScheduledHandlerPump(wranglerPort, scheduledPumpIntervalMs); } -const larkSidecarEnabled = shouldStartLarkSidecar(wranglerEnv); -const larkSidecarSecret = larkSidecarEnabled ? crypto.randomUUID() : null; -if (larkSidecarEnabled && larkSidecarSecret) { - startLarkSidecar({ - apiDir, - env: wranglerEnv, - secret: larkSidecarSecret, - workerUrl: `http://127.0.0.1:${wranglerPort}`, - }); -} const wranglerResult = await run( wranglerBin, [ @@ -478,8 +360,6 @@ const wranglerResult = await run( ...createProviderFetchProxyVarArgs(providerFetchProxy), ...createRuntimeControlOriginVarArgs(wranglerEnv), ...createRuntimeProxyVarArgs(wranglerEnv), - ...(await createWeChatIlinkBaseUrlVarArgs(wranglerEnv)), - ...(larkSidecarSecret ? createLarkSidecarVarArgs(larkSidecarSecret) : []), ], { cwd: apiDir, diff --git a/apps/api/bin/lark-ws-sidecar.ts b/apps/api/bin/lark-ws-sidecar.ts deleted file mode 100755 index 546f7136..00000000 --- a/apps/api/bin/lark-ws-sidecar.ts +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env bun -// Local-only bridge for Lark long-connection mode. The official SDK is -// Node-only, so this process owns WSClient and forwards decoded events to the -// worker over authenticated loopback HTTP. - -import * as lark from "@larksuiteoapi/node-sdk"; - -const DEFAULT_POLL_INTERVAL_MS = 30_000; -const MIN_POLL_INTERVAL_MS = 1_000; -const WORKER_URL = process.env.MOSOO_API_BASE_URL?.trim(); -const SECRET = process.env.MOSOO_LARK_SIDECAR_SECRET?.trim(); - -if (!WORKER_URL || !SECRET) { - process.stderr.write( - "[lark-ws-sidecar] missing MOSOO_API_BASE_URL or MOSOO_LARK_SIDECAR_SECRET; exiting\n", - ); - process.exit(1); -} - -const workerUrl: string = WORKER_URL; -const secret: string = SECRET; -const pollIntervalMs = readPollIntervalMs(process.env.MOSOO_LARK_SIDECAR_POLL_MS); - -interface BindingDescriptor { - appId: string; - appSecret: string; - bindingId: string; - domain: "feishu" | "lark"; -} - -interface Session { - client: lark.WSClient; - descriptor: BindingDescriptor; -} - -function log(message: string): void { - process.stdout.write(`[lark-ws-sidecar] ${message}\n`); -} - -function readPollIntervalMs(raw: string | undefined): number { - if (raw === undefined) { - return DEFAULT_POLL_INTERVAL_MS; - } - - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed >= MIN_POLL_INTERVAL_MS - ? parsed - : DEFAULT_POLL_INTERVAL_MS; -} - -function describeError(error: unknown): string { - if (error instanceof Error) { - return `${error.name}: ${error.message}`; - } - if (error === undefined || error === null) { - return ""; - } - if (typeof error === "string") { - return error; - } - try { - return JSON.stringify(error); - } catch { - return ""; - } -} - -function logError(message: string, error?: unknown): void { - const detail = describeError(error); - process.stderr.write(`[lark-ws-sidecar] ${message}${detail ? ` - ${detail}` : ""}\n`); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(record: Record, field: string): string { - const value = record[field]; - - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Lark sidecar binding ${field} is required.`); - } - - return value; -} - -function readDomain(record: Record): BindingDescriptor["domain"] { - const value = record["domain"]; - - if (value === "feishu" || value === "lark") { - return value; - } - - throw new Error("Lark sidecar binding domain must be feishu or lark."); -} - -function readDescriptor(value: unknown): BindingDescriptor { - if (!isRecord(value)) { - throw new Error("Lark sidecar binding must be an object."); - } - - return { - appId: readString(value, "appId"), - appSecret: readString(value, "appSecret"), - bindingId: readString(value, "bindingId"), - domain: readDomain(value), - }; -} - -function readDescriptorList(value: unknown): BindingDescriptor[] { - if (!isRecord(value) || !Array.isArray(value["bindings"])) { - throw new Error("Lark sidecar bindings response must contain a bindings array."); - } - - return value["bindings"].map(readDescriptor); -} - -// The SDK's EventDispatcher flattens envelopes for typed handlers. The worker -// owns canonical decoding, so this dispatcher forwards the raw parsed envelope. -class ForwardingDispatcher extends lark.EventDispatcher { - readonly #bindingId: string; - - constructor(bindingId: string) { - super({}); - this.#bindingId = bindingId; - } - - async invoke(envelope: unknown, _params?: { needCheck?: boolean }): Promise { - try { - const response = await fetch( - `${workerUrl}/api/v1/internal/lark-gateway/event/${this.#bindingId}`, - { - body: JSON.stringify({ envelope }), - headers: { - "content-type": "application/json", - "x-sidecar-auth": secret, - }, - method: "POST", - }, - ); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - logError( - `forward to worker failed (binding ${this.#bindingId}, HTTP ${response.status}): ${text.slice(0, 200)}`, - ); - } - } catch (error) { - logError(`forward to worker threw (binding ${this.#bindingId})`, error); - } - - return undefined; - } -} - -const sessions = new Map(); -let shuttingDown = false; - -function resolveSdkDomain(domain: "feishu" | "lark"): lark.Domain { - return domain === "lark" ? lark.Domain.Lark : lark.Domain.Feishu; -} - -async function fetchDescriptors(): Promise { - const response = await fetch(`${workerUrl}/api/v1/internal/lark-gateway/bindings`, { - headers: { "x-sidecar-auth": secret }, - }); - - if (response.status === 404) { - return []; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status} ${response.statusText}`); - } - - return readDescriptorList(await response.json()); -} - -function startSession(descriptor: BindingDescriptor): Session { - log( - `starting WS for binding ${descriptor.bindingId} (app ${descriptor.appId}, domain ${descriptor.domain})`, - ); - - const dispatcher = new ForwardingDispatcher(descriptor.bindingId); - const client = new lark.WSClient({ - appId: descriptor.appId, - appSecret: descriptor.appSecret, - autoReconnect: true, - domain: resolveSdkDomain(descriptor.domain), - loggerLevel: lark.LoggerLevel.warn, - onError: (error: unknown) => { - logError(`WS error for binding ${descriptor.bindingId}`, error); - }, - onReady: () => { - log(`WS ready for binding ${descriptor.bindingId}`); - }, - onReconnecting: () => { - log(`WS reconnecting for binding ${descriptor.bindingId}`); - }, - }); - - void client.start({ eventDispatcher: dispatcher }); - - return { client, descriptor }; -} - -function stopSession(bindingId: string, session: Session): void { - log(`stopping WS for binding ${bindingId}`); - try { - session.client.close({ force: true }); - } catch (error) { - logError(`close threw for binding ${bindingId}`, error); - } -} - -async function reconcile(): Promise { - if (shuttingDown) { - return; - } - - let descriptors: BindingDescriptor[]; - try { - descriptors = await fetchDescriptors(); - } catch (error) { - logError("fetchDescriptors failed", error); - return; - } - - const wanted = new Map(descriptors.map((d) => [d.bindingId, d])); - - for (const [bindingId, session] of sessions.entries()) { - const next = wanted.get(bindingId); - if (!next) { - stopSession(bindingId, session); - sessions.delete(bindingId); - continue; - } - if ( - next.appId !== session.descriptor.appId || - next.appSecret !== session.descriptor.appSecret || - next.domain !== session.descriptor.domain - ) { - log(`credentials changed for binding ${bindingId}; recycling session`); - stopSession(bindingId, session); - sessions.delete(bindingId); - } - } - - for (const descriptor of descriptors) { - if (sessions.has(descriptor.bindingId)) { - continue; - } - try { - const session = startSession(descriptor); - sessions.set(descriptor.bindingId, session); - } catch (error) { - logError(`startSession failed for ${descriptor.bindingId}`, error); - } - } -} - -for (const signal of ["SIGTERM", "SIGINT"] as const) { - process.on(signal, () => { - if (shuttingDown) { - return; - } - shuttingDown = true; - log(`received ${signal}, closing ${sessions.size} session(s)`); - for (const [bindingId, session] of sessions.entries()) { - stopSession(bindingId, session); - } - sessions.clear(); - process.exit(0); - }); -} - -log(`starting (worker=${workerUrl}, poll=${pollIntervalMs}ms)`); - -void reconcile(); -setInterval(() => void reconcile(), pollIntervalMs); diff --git a/apps/api/bin/wechat-ilink-mock.ts b/apps/api/bin/wechat-ilink-mock.ts deleted file mode 100755 index 6102adb4..00000000 --- a/apps/api/bin/wechat-ilink-mock.ts +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env bun -const DEFAULT_PORT = 5189; -const DEFAULT_LONGPOLL_MS = 1_500; -const PORT = readIntegerEnv(process.env.WECHAT_ILINK_MOCK_PORT, { - defaultValue: DEFAULT_PORT, - max: 65_535, - min: 1, -}); - -const MOCK_QR_TOKEN = "mock-wechat-qr-token"; -const MOCK_QR_SVG = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - MOCK -`; -const MOCK_QR_DATA_URL = `data:image/svg+xml;base64,${Buffer.from(MOCK_QR_SVG).toString("base64")}`; - -type StatusPhase = "wait" | "scaned" | "confirmed"; - -const STATUS_SEQUENCE: ReadonlyArray = ["wait", "wait", "scaned", "confirmed"]; -const pollCounts = new Map(); -const MOCK_ILINK_USER_ID = "mock-user-id"; -const MOCK_ILINK_BOT_ID = "mock-bot-id"; -const MOCK_PEER_ID = "mock-peer-id"; -const MOCK_LONGPOLL_MS = readIntegerEnv(process.env.WECHAT_ILINK_MOCK_LONGPOLL_MS, { - defaultValue: DEFAULT_LONGPOLL_MS, - min: 0, -}); - -interface PendingMockMessage { - context_token: string; - from_user_id: string; - item_list: Array<{ text_item: { text: string }; type: number }>; - message_id: string; - message_state: number; - message_type: number; - to_user_id: string; -} - -const pendingMessages: PendingMockMessage[] = []; - -function readIntegerEnv( - raw: string | undefined, - options: { defaultValue: number; max?: number; min: number }, -): number { - if (raw === undefined || raw.trim().length === 0) { - return options.defaultValue; - } - - const parsed = Number.parseInt(raw, 10); - - if (!Number.isFinite(parsed) || parsed < options.min) { - return options.defaultValue; - } - - return options.max === undefined ? parsed : Math.min(parsed, options.max); -} - -function createQrBody(): { qrcode_img_content: string; qrcode: string } { - return { - qrcode: MOCK_QR_TOKEN, - qrcode_img_content: MOCK_QR_DATA_URL, - }; -} - -function nextStatus(token: string): StatusPhase { - const current = pollCounts.get(token) ?? 0; - const phase = STATUS_SEQUENCE[Math.min(current, STATUS_SEQUENCE.length - 1)] ?? "wait"; - pollCounts.set(token, current + 1); - return phase; -} - -function readJson(request: Request): Promise { - return request.json().catch(() => null); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readTrimmedString(record: Record, field: string): string | null { - const value = record[field]; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function parseInjectPayload(payload: unknown): { peerId: string; text: string } | null { - if (!isRecord(payload)) { - return null; - } - - const text = readTrimmedString(payload, "text"); - - if (text === null) { - return null; - } - - return { - peerId: readTrimmedString(payload, "peerId") ?? MOCK_PEER_ID, - text, - }; -} - -function readGetUpdatesCursor(payload: unknown): string { - if (isRecord(payload)) { - const candidate = payload["get_updates_buf"]; - if (typeof candidate === "string") { - return candidate; - } - } - return ""; -} - -function nextGetUpdatesCursor(cursor: string): string { - const match = /^mock-cursor-(\d+)$/u.exec(cursor); - if (match) { - const next = Number.parseInt(match[1] ?? "0", 10) + 1; - return `mock-cursor-${next}`; - } - return "mock-cursor-1"; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -const server = Bun.serve({ - port: PORT, - async fetch(request) { - const url = new URL(request.url); - const path = url.pathname; - - if (request.method === "POST" && path === "/_mock/inject") { - const payload = parseInjectPayload(await readJson(request)); - if (payload === null) { - return Response.json({ error: "missing_text" }, { status: 400 }); - } - pendingMessages.push({ - context_token: `mock-ctx-${Date.now()}`, - from_user_id: payload.peerId, - item_list: [{ text_item: { text: payload.text }, type: 1 }], - message_id: `${Date.now()}`, - message_state: 2, - message_type: 1, - to_user_id: MOCK_ILINK_BOT_ID, - }); - return Response.json({ ok: true, pending: pendingMessages.length }); - } - - if (request.method === "GET" && path === "/ilink/bot/get_bot_qrcode") { - pollCounts.delete(MOCK_QR_TOKEN); - return Response.json(createQrBody()); - } - - if (request.method === "GET" && path === "/ilink/bot/get_qrcode_status") { - const qrcode = url.searchParams.get("qrcode") ?? ""; - const phase = nextStatus(qrcode); - - if (phase === "confirmed") { - return Response.json({ - base_info: { channel_version: "2.2.0" }, - baseurl: `http://localhost:${PORT}`, - bot_token: "mock-bot-token", - ilink_bot_id: MOCK_ILINK_BOT_ID, - ilink_user_id: MOCK_ILINK_USER_ID, - status: "confirmed", - }); - } - - return Response.json({ status: phase }); - } - - if (request.method === "POST" && path === "/ilink/bot/sendmessage") { - return Response.json({ errcode: 0, errmsg: "ok", ret: 0 }); - } - - if (request.method === "POST" && path === "/ilink/bot/getupdates") { - const cursor = readGetUpdatesCursor(await readJson(request)); - const drained = pendingMessages.splice(0, pendingMessages.length); - if (drained.length === 0 && MOCK_LONGPOLL_MS > 0) { - await delay(MOCK_LONGPOLL_MS); - } - return Response.json({ - errcode: 0, - errmsg: "", - get_updates_buf: nextGetUpdatesCursor(cursor), - longpolling_timeout_ms: MOCK_LONGPOLL_MS, - msgs: drained, - ret: 0, - }); - } - - return new Response(`Not found: ${request.method} ${path}`, { status: 404 }); - }, -}); - -function log(message: string): void { - process.stdout.write(`${message}\n`); -} - -log(`WeChat iLink mock listening on http://localhost:${server.port}`); -log(` set WECHAT_ILINK_BASE_URL=http://localhost:${server.port} in apps/api/.dev.vars`); -log(` QR token: ${MOCK_QR_TOKEN}`); -log(` ilink_user_id: ${MOCK_ILINK_USER_ID}`); -log(` status sequence: ${STATUS_SEQUENCE.join(" -> ")}`); -log(` getupdates long-poll: ${MOCK_LONGPOLL_MS}ms (override with WECHAT_ILINK_MOCK_LONGPOLL_MS)`); -log( - ` inject a DM: curl -X POST http://localhost:${server.port}/_mock/inject -d '{"peerId":"...","text":"..."}' -H 'content-type: application/json'`, -); diff --git a/apps/api/openapi/public-api-v1.generated.json b/apps/api/openapi/public-api-v1.generated.json index 59057785..75edb4c2 100644 --- a/apps/api/openapi/public-api-v1.generated.json +++ b/apps/api/openapi/public-api-v1.generated.json @@ -878,26 +878,34 @@ }, "HarnessCatalogEntry": { "additionalProperties": false, + "description": "One curated coding-agent Harness available to a Workspace.", "properties": { "capabilities": { "additionalProperties": false, + "description": "Normalized lifecycle capabilities exposed by this Harness.", "properties": { "approve": { + "description": "Support level for the approve Run capability.", "enum": ["native", "normalized", "unsupported"] }, "artifacts": { + "description": "Support level for the artifacts Run capability.", "enum": ["native", "normalized", "unsupported"] }, "cancel": { + "description": "Support level for the cancel Run capability.", "enum": ["native", "normalized", "unsupported"] }, "resume": { + "description": "Support level for the resume Run capability.", "enum": ["native", "normalized", "unsupported"] }, "stream": { + "description": "Support level for the stream Run capability.", "enum": ["native", "normalized", "unsupported"] }, "subagents": { + "description": "Support level for the subagents Run capability.", "enum": ["native", "normalized", "unsupported"] } }, @@ -905,52 +913,65 @@ "type": "object" }, "defaultModel": { + "description": "Default model selected for this Harness.", "type": "string" }, "description": { + "description": "Short explanation of the Harness use case.", "type": "string" }, "environment": { "additionalProperties": false, + "description": "Default Environment behavior for new Harness Runs.", "properties": { "default": { - "const": "workspace" + "const": "workspace", + "description": "Environment selection default." }, "repositoryRequired": { - "const": false + "const": false, + "description": "Whether a repository is required to launch." } }, "required": ["default", "repositoryRequired"], "type": "object" }, "label": { + "description": "Human-readable Harness name.", "type": "string" }, "quickstart": { + "description": "One-line command for starting this Harness.", "type": "string" }, "requiredCredentials": { + "description": "Credential providers required before launch.", "items": { "type": "string" }, "type": "array" }, "runtimeId": { + "description": "Internal runtime implementation identifier.", "type": "string" }, "slug": { + "description": "Stable Harness identifier accepted by the Run API.", "enum": ["claude-code", "openai-codex", "opencode"] }, "status": { + "description": "Current launch availability for this Harness.", "enum": ["available", "unavailable"] }, "supportedModels": { + "description": "Models currently offered by this Harness.", "items": { "type": "string" }, "type": "array" }, "version": { + "description": "Frozen Harness package version.", "type": "string" } }, @@ -971,15 +992,19 @@ "type": "object" }, "WorkspaceRunRequest": { + "description": "Request to start a Run from exactly one supported source.", "oneOf": [ { "additionalProperties": false, "properties": { "agent": { + "description": "Published Agent identifier or unambiguous Agent name.", "minLength": 1, "type": "string" }, - "input": {} + "input": { + "description": "Task input delivered to the selected Agent." + } }, "required": ["agent", "input"], "type": "object" @@ -988,14 +1013,19 @@ "additionalProperties": false, "properties": { "environment": { + "description": "Optional Environment identifier or unambiguous name.", "minLength": 1, "type": "string" }, "harness": { + "description": "Curated Harness to launch for this Run.", "enum": ["claude-code", "openai-codex", "opencode"] }, - "input": {}, + "input": { + "description": "Task input delivered to the selected Harness." + }, "model": { + "description": "Optional supported model override for the Harness.", "minLength": 1, "type": "string" } @@ -1007,17 +1037,22 @@ }, "WorkspaceRunResponse": { "additionalProperties": false, + "description": "Current state and immutable snapshots for one Workspace Run.", "properties": { "environment": { "additionalProperties": false, + "description": "Frozen Environment revision used by this Run.", "properties": { "id": { + "description": "Environment identifier used by this Run.", "type": "string" }, "name": { + "description": "Environment name captured for display.", "type": "string" }, "revisionId": { + "description": "Frozen Environment revision identifier.", "type": "string" } }, @@ -1025,27 +1060,35 @@ "type": "object" }, "id": { + "description": "Stable identifier for this Run.", "type": "string" }, "links": { "additionalProperties": false, + "description": "Run lifecycle and result endpoint links.", "properties": { "approve": { + "description": "Relative link for the Run approve endpoint.", "type": "string" }, "artifacts": { + "description": "Relative link for the Run artifacts endpoint.", "type": "string" }, "cancel": { + "description": "Relative link for the Run cancel endpoint.", "type": "string" }, "events": { + "description": "Relative link for the Run events endpoint.", "type": "string" }, "result": { + "description": "Relative link for the Run result endpoint.", "type": "string" }, "stream": { + "description": "Relative link for the Run stream endpoint.", "type": "string" } }, @@ -1053,24 +1096,30 @@ "type": "object" }, "model": { + "description": "Frozen model selected for this Run.", "type": "string" }, "source": { + "description": "Immutable source snapshot selected for this Run.", "oneOf": [ { "additionalProperties": false, "properties": { "agentId": { + "description": "Published Agent selected as the Run source.", "type": "string" }, "agentVersionId": { + "description": "Frozen Agent version identifier, when published.", "type": ["string", "null"] }, "agentVersionNumber": { + "description": "Frozen Agent version number, when published.", "type": ["integer", "null"] }, "kind": { - "const": "agent" + "const": "agent", + "description": "Discriminator for an Agent-backed Run." } }, "required": ["agentId", "agentVersionId", "agentVersionNumber", "kind"], @@ -1080,12 +1129,15 @@ "additionalProperties": false, "properties": { "harness": { + "description": "Curated Harness selected as the Run source.", "enum": ["claude-code", "openai-codex", "opencode"] }, "kind": { - "const": "harness" + "const": "harness", + "description": "Discriminator for a Harness-backed Run." }, "version": { + "description": "Frozen Harness version used by the Run.", "type": "string" } }, @@ -1095,6 +1147,7 @@ ] }, "status": { + "description": "Current lifecycle status for this Run.", "enum": [ "queued", "booting", @@ -1107,9 +1160,11 @@ ] }, "threadId": { + "description": "Backing Thread identifier for event history.", "type": "string" }, "workspaceId": { + "description": "Workspace that owns this Run.", "type": "string" } }, @@ -1127,8 +1182,10 @@ }, "WorkspaceRunResultResponse": { "additionalProperties": false, + "description": "Run state paired with its canonical final output.", "properties": { "output": { + "description": "Canonical final assistant output, or null while unavailable.", "oneOf": [ { "$ref": "#/components/schemas/RunFinalOutput" @@ -1139,7 +1196,8 @@ ] }, "run": { - "$ref": "#/components/schemas/WorkspaceRunResponse" + "$ref": "#/components/schemas/WorkspaceRunResponse", + "description": "Latest lifecycle state for the requested Run." } }, "required": ["output", "run"], diff --git a/apps/api/package.json b/apps/api/package.json index cd198f45..80fa6125 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "@cloudflare/sandbox": "0.12.6", - "@larksuiteoapi/node-sdk": "^1.73.0", "@mosoo/ag-ui-session": "workspace:*", "@mosoo/agent-driver": "workspace:*", "@mosoo/agent-package": "workspace:*", diff --git a/apps/api/src/adapters/durable-objects/channel-connection.do.ts b/apps/api/src/adapters/durable-objects/channel-connection.do.ts deleted file mode 100644 index e11806a1..00000000 --- a/apps/api/src/adapters/durable-objects/channel-connection.do.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { DurableObject } from "cloudflare:workers"; - -import type { - DiscordGatewaySnapshotResult, - DiscordGatewayStartResult, - DiscordGatewayStopResult, -} from "../../modules/channels/discord/discord-gateway.do"; -import type { - LarkGatewaySnapshot, - LarkGatewayStartResult, - LarkGatewayStopResult, -} from "../../modules/channels/lark/lark-gateway.do"; -import type { ApiBindings } from "../../platform/cloudflare/worker-types"; - -const CHANNEL_CONNECTION_PROVIDER_STORAGE_KEY = "provider"; - -export type ChannelConnectionProvider = "discord" | "lark"; - -export type ChannelConnectionStartResult = DiscordGatewayStartResult | LarkGatewayStartResult; -export type ChannelConnectionStopResult = DiscordGatewayStopResult | LarkGatewayStopResult; -export type ChannelConnectionSnapshotResult = DiscordGatewaySnapshotResult | LarkGatewaySnapshot; - -interface ChannelConnectionDelegate { - alarm(): Promise; - snapshot( - bindingId: string, - ): ChannelConnectionSnapshotResult | Promise; - start(bindingId: string): Promise; - stop(bindingId: string): Promise; -} - -function isChannelConnectionProvider(value: unknown): value is ChannelConnectionProvider { - return value === "discord" || value === "lark"; -} - -function isBindingMissing(result: ChannelConnectionStartResult): boolean { - return result.status === "binding_not_found"; -} - -export class ChannelConnection extends DurableObject { - readonly #delegates = new Map>(); - - async start( - provider: ChannelConnectionProvider, - bindingId: string, - ): Promise { - await this.ctx.storage.put(CHANNEL_CONNECTION_PROVIDER_STORAGE_KEY, provider); - const result = await (await this.#getDelegate(provider)).start(bindingId); - - if (isBindingMissing(result)) { - await this.ctx.storage.delete(CHANNEL_CONNECTION_PROVIDER_STORAGE_KEY); - } - - return result; - } - - async stop( - provider: ChannelConnectionProvider, - bindingId: string, - ): Promise { - const result = await (await this.#getDelegate(provider)).stop(bindingId); - await this.ctx.storage.delete(CHANNEL_CONNECTION_PROVIDER_STORAGE_KEY); - return result; - } - - async snapshot( - provider: ChannelConnectionProvider, - bindingId: string, - ): Promise { - return await (await this.#getDelegate(provider)).snapshot(bindingId); - } - - override async alarm(): Promise { - const provider = await this.ctx.storage.get(CHANNEL_CONNECTION_PROVIDER_STORAGE_KEY); - - if (!isChannelConnectionProvider(provider)) { - await this.ctx.storage.deleteAlarm(); - return; - } - - await (await this.#getDelegate(provider)).alarm(); - } - - #getDelegate(provider: ChannelConnectionProvider): Promise { - const existing = this.#delegates.get(provider); - - if (existing) { - return existing; - } - - const created = this.#createDelegate(provider); - this.#delegates.set(provider, created); - return created; - } - - async #createDelegate(provider: ChannelConnectionProvider): Promise { - switch (provider) { - case "discord": { - const { DiscordGatewayConnectionRuntimeService } = - await import("../../modules/channels/discord/discord-gateway.do"); - return new DiscordGatewayConnectionRuntimeService(this.ctx, this.env); - } - case "lark": { - const { LarkLongConnectionRuntimeService } = - await import("../../modules/channels/lark/lark-gateway.do"); - return new LarkLongConnectionRuntimeService(this.ctx, this.env); - } - } - } -} diff --git a/apps/api/src/adapters/durable-objects/retired-channel-connection.do.ts b/apps/api/src/adapters/durable-objects/retired-channel-connection.do.ts new file mode 100644 index 00000000..aac30db2 --- /dev/null +++ b/apps/api/src/adapters/durable-objects/retired-channel-connection.do.ts @@ -0,0 +1,12 @@ +import { DurableObject } from "cloudflare:workers"; + +/** + * Compatibility tombstone for the immutable Durable Object migrations in wrangler.toml. + * No active binding routes traffic here; the retired class name must remain exportable so + * Wrangler can load existing local and production migration history. + */ +export class ChannelConnection extends DurableObject { + override fetch(): Promise { + return Promise.resolve(new Response(null, { status: 410 })); + } +} diff --git a/apps/api/src/adapters/graphql/create-graphql-schema.ts b/apps/api/src/adapters/graphql/create-graphql-schema.ts index 2462d157..c6eda09f 100644 --- a/apps/api/src/adapters/graphql/create-graphql-schema.ts +++ b/apps/api/src/adapters/graphql/create-graphql-schema.ts @@ -3,7 +3,6 @@ import { createSchema } from "graphql-yoga"; import { agentGraphQLModule } from "../../modules/agents/graphql/agent-graphql"; import { appGraphQLModule } from "../../modules/apps/graphql/app-graphql"; -import { channelGraphQLModule } from "../../modules/channels/graphql/channel-graphql"; import { costGraphQLModule } from "../../modules/cost/graphql/cost-graphql"; import { environmentGraphQLModule } from "../../modules/environments/graphql/environment-graphql"; import { fileGraphQLModule } from "../../modules/files/graphql/file-graphql"; @@ -24,7 +23,6 @@ import { ulidScalar } from "./scalars/ulid-scalar"; const composedGraphQLModules = composeGraphQLModules([ commonGraphQLModule, agentGraphQLModule, - channelGraphQLModule, costGraphQLModule, environmentGraphQLModule, fileGraphQLModule, diff --git a/apps/api/src/adapters/graphql/graphql-module-specs.ts b/apps/api/src/adapters/graphql/graphql-module-specs.ts index bb93ab12..61bd6a79 100644 --- a/apps/api/src/adapters/graphql/graphql-module-specs.ts +++ b/apps/api/src/adapters/graphql/graphql-module-specs.ts @@ -1,7 +1,6 @@ import type { GraphQLModule } from "./graphql-module.ts"; import { agentSchema } from "./schema/agent-schema.ts"; import { appSchema } from "./schema/app-schema.ts"; -import { channelSchema } from "./schema/channel-schema.ts"; import { commonSchema } from "./schema/common-schema.ts"; import { costSchema } from "./schema/cost-schema.ts"; import { environmentSchema } from "./schema/environment-schema.ts"; @@ -20,22 +19,6 @@ export const commonGraphQLSpec = { typeDefs: commonSchema, } satisfies GraphQLModuleSpec; -export const channelGraphQLSpec = { - mutationFields: [ - "createDiscordAgentChannelBinding(input: CreateDiscordAgentChannelBindingInput!): AgentChannelBinding!", - "createLarkAgentChannelBinding(input: CreateLarkAgentChannelBindingInput!): AgentChannelBinding!", - "createSlackAgentChannelBinding(input: CreateSlackAgentChannelBindingInput!): AgentChannelBinding!", - "createTelegramAgentChannelBinding(input: CreateTelegramAgentChannelBindingInput!): AgentChannelBinding!", - "pollLarkAgentChannelRegistration(input: PollLarkAgentChannelRegistrationInput!): LarkAgentChannelRegistration!", - "pollWeChatAgentChannelPairing(input: PollWeChatAgentChannelPairingInput!): WeChatAgentChannelPairing!", - "startLarkAgentChannelRegistration(input: StartLarkAgentChannelRegistrationInput!): LarkAgentChannelRegistration!", - "startWeChatAgentChannelPairing(input: StartWeChatAgentChannelPairingInput!): WeChatAgentChannelPairing!", - "deleteAgentChannelBinding(input: DeleteAgentChannelBindingInput!): OperationResult!", - ], - queryFields: ["agentChannelBindingList(appId: ULID!, agentId: ULID!): [AgentChannelBinding!]!"], - typeDefs: channelSchema, -} satisfies GraphQLModuleSpec; - export const costGraphQLSpec = { queryFields: [ "agentCostCard(appId: ULID!, agentId: ULID!, range: CostRange!, runPurposes: [CostRunPurpose!]): AgentCostCard!", @@ -52,9 +35,6 @@ export const agentGraphQLSpec = { "deleteAgent(input: DeleteAgentInput!): OperationResult!", "importAgentPackage(input: ImportAgentPackageInput!): AgentPackageImportResult!", "publishAgent(input: PublishAgentInput!): Agent!", - "recreateSandbox(input: RuntimeStateOperationInput!): RuntimeStateOperationResult!", - "resetAgentState(input: RuntimeStateOperationInput!): RuntimeStateOperationResult!", - "restartDriver(input: RuntimeStateOperationInput!): RuntimeStateOperationResult!", "unpublishAgent(appId: ULID!, agentId: ULID!): Agent!", "updateAgentConfig(input: UpdateAgentConfigInput!): Agent!", ], @@ -114,17 +94,9 @@ export const onboardingGraphQLSpec = { export const appGraphQLSpec = { mutationFields: [ "createApp(input: CreateAppInput!): App!", - "deleteAppDeployment(input: DeleteAppDeploymentInput!): OperationResult!", - "deployApp(input: DeployAppInput!): AppDeploymentRun!", "renameApp(input: RenameAppInput!): App!", ], - queryFields: [ - "appDeploymentRunList(appId: ULID!, limit: Int): [AppDeploymentRun!]!", - "appDeploymentStatus(appId: ULID!): AppDeploymentRun", - "appList(organizationId: ULID!): [App!]!", - "appOverview(appId: ULID!, agentLimit: Int, credentialLimit: Int): AppOverview!", - "controlPlaneOverview(appLimit: Int, agentLimit: Int, credentialLimit: Int): ControlPlaneOverview!", - ], + queryFields: ["appList(organizationId: ULID!): [App!]!"], typeDefs: appSchema, } satisfies GraphQLModuleSpec; @@ -144,7 +116,6 @@ export const sessionGraphQLSpec = { ], queryFields: [ "agentSessionDiagnostics(appId: ULID!, sessionId: ULID!): AgentSessionDiagnostics!", - "boundCapabilityRunProvenance(appId: ULID!, runId: ULID!): BoundCapabilityRunProvenance", "agentSessionRetrieve(appId: ULID!, sessionId: ULID!): AgentSessionRetrieve!", "session(appId: ULID!, sessionId: ULID!): Session!", "sessionMessages(appId: ULID!, sessionId: ULID!): [SessionMessage!]!", @@ -205,7 +176,6 @@ export const organizationGraphQLSpec = { export const graphqlModuleSpecs = [ commonGraphQLSpec, agentGraphQLSpec, - channelGraphQLSpec, costGraphQLSpec, environmentGraphQLSpec, fileGraphQLSpec, diff --git a/apps/api/src/adapters/graphql/schema.generated.graphql b/apps/api/src/adapters/graphql/schema.generated.graphql index 5ea48c54..7a5ffce6 100644 --- a/apps/api/src/adapters/graphql/schema.generated.graphql +++ b/apps/api/src/adapters/graphql/schema.generated.graphql @@ -62,27 +62,6 @@ enum AgentBuiltInToolName { write } -type AgentChannelBinding { - activityLastTriggeredAt: String - activitySessionCount7d: Int! - agentId: ULID! - appId: ULID! - createdAt: String! - displayMetadata: PrimitiveRecord! - externalBotId: String! - externalTenantId: String! - id: ULID! - lastErrorCode: String - provider: ChannelProvider! - status: AgentChannelBindingStatus! - updatedAt: String! -} - -enum AgentChannelBindingStatus { - active - error -} - type AgentCostCard { agentId: ULID! agentName: String! @@ -275,7 +254,6 @@ enum AgentResolutionStatus { enum AgentResolutionTargetType { agent - channel environment mcp_server model @@ -476,125 +454,12 @@ type AppCostCard { totals: CostTotals! } -type AppDeployment { - appId: ULID! - createdAt: String! - defaultBranch: String! - id: ULID! - latestRun: AppDeploymentRun - liveUrl: String - plannedUrl: String! - repoName: String! - repoOwner: String! - repoUrl: String! - updatedAt: String! -} - -type AppDeploymentRun { - appId: ULID! - createdAt: String! - deploymentId: ULID! - errorCode: String - errorMessage: String - id: ULID! - liveUrl: String - plannedUrl: String! - sourceBranch: String! - sourceCommitSha: String! - status: AppDeploymentRunStatus! - targetKind: AppDeploymentTargetKind - updatedAt: String! -} - -enum AppDeploymentRunStatus { - activating - building - failed - preparing - queued - submitted - submitting - success -} - -enum AppDeploymentTargetKind { - cloudflare_pages - cloudflare_worker -} - type AppInfo { api: String! name: String! runtime: String! } -type AppOverview { - agents: AppOverviewAgentList! - app: App! - boundAgents: [AppOverviewBoundAgent!]! - deployment: AppDeployment - providerCredentials: AppOverviewProviderCredentialList! -} - -type AppOverviewAgent { - appId: ULID! - description: String - id: ULID! - kind: AgentKind! - model: String! - name: String! - provider: String! - runtimeId: String! - status: AgentStatus! - updatedAt: String! -} - -type AppOverviewAgentList { - hasMore: Boolean! - items: [AppOverviewAgent!]! - limit: Int! -} - -type AppOverviewBoundAgent { - agentId: ULID! - envVar: String! - expose: AppOverviewBoundAgentExposure! - name: String! -} - -enum AppOverviewBoundAgentExposure { - public_thread -} - -type AppOverviewProviderCredential { - appId: ULID! - hasCustomApiBase: Boolean! - id: ULID! - isDefault: Boolean! - modelCount: Int! - name: String! - status: AppOverviewProviderCredentialStatus! - vendorId: String! -} - -type AppOverviewProviderCredentialList { - byVendor: [AppOverviewProviderCredentialVendorCount!]! - configuredCount: Int! - hasMore: Boolean! - items: [AppOverviewProviderCredential!]! - limit: Int! -} - -enum AppOverviewProviderCredentialStatus { - configured -} - -type AppOverviewProviderCredentialVendorCount { - count: Int! - defaultCredentialId: ULID - vendorId: String! -} - enum AuthMethod { email_otp google_oauth @@ -610,24 +475,6 @@ input BootstrapOnboardingInput { name: String } -type BoundCapabilityRunProvenance { - agentId: ULID! - appId: ULID! - bindingEnv: String! - bindingName: String! - deploymentId: ULID! - deploymentRunId: ULID! - runId: ULID! -} - -enum ChannelProvider { - discord - lark - slack - telegram - wechat -} - input ConnectMcpBearerInput { appId: ULID! serverId: ULID! @@ -635,17 +482,6 @@ input ConnectMcpBearerInput { token: String! } -type ControlPlaneOverview { - activeOrganization: Organization - apps: ControlPlaneOverviewAppList! -} - -type ControlPlaneOverviewAppList { - hasMore: Boolean! - items: [AppOverview!]! - limit: Int! -} - type CostAgentRow implements CostAggregate { activeUsers: Int! agentId: ULID! @@ -800,14 +636,6 @@ input CreateAppMcpServerInput { url: String! } -input CreateDiscordAgentChannelBindingInput { - agentId: ULID! - appId: ULID! - applicationId: String! - botToken: String! - relaySecret: String! -} - input CreateEnvironmentForkInput { appId: ULID! environmentId: ULID! @@ -826,38 +654,11 @@ input CreateEnvironmentInput { setupScript: String! } -input CreateLarkAgentChannelBindingInput { - agentId: ULID! - appId: ULID! - appSecret: String! - connectionMode: LarkConnectionMode! - domain: LarkDomain! - encryptKey: String - larkAppId: String! - verificationToken: String -} - input CreateSkillForkInput { appId: ULID! skillId: ULID! } -input CreateSlackAgentChannelBindingInput { - agentId: ULID! - appId: ULID! - appLevelToken: String - botToken: String! - signingSecret: String! - threadRepliesRequireMention: Boolean -} - -input CreateTelegramAgentChannelBindingInput { - agentId: ULID! - appId: ULID! - botToken: String! - webhookSecret: String! -} - input CreateVendorCredentialInput { apiBase: String apiKey: String! @@ -867,20 +668,11 @@ input CreateVendorCredentialInput { vendorId: String! } -input DeleteAgentChannelBindingInput { - appId: ULID! - bindingId: ULID! -} - input DeleteAgentInput { agentId: ULID! appId: ULID! } -input DeleteAppDeploymentInput { - appId: ULID! -} - input DeleteEnvironmentInput { appId: ULID! environmentId: ULID! @@ -891,12 +683,6 @@ input DeleteVendorCredentialInput { id: ULID! } -input DeployAppInput { - appId: ULID! - configPath: String - repoUrl: String! -} - type EnvironmentDetail { allowMcpServers: Boolean! allowPackageManagers: Boolean! @@ -1098,39 +884,6 @@ input ImportAgentPackageInput { scalar JsonObject -type LarkAgentChannelRegistration { - appId: String - appSecret: String - deviceCode: String - domain: LarkDomain! - expireIn: Int - interval: Int - lastErrorCode: String - openId: String - qrUrl: String - status: LarkAppRegistrationStatus! - userCode: String -} - -enum LarkAppRegistrationStatus { - access_denied - confirmed - expired - failed - qr_pending - slow_down -} - -enum LarkConnectionMode { - webhook - websocket -} - -enum LarkDomain { - feishu - lark -} - enum McpAuthType { bearer oauth @@ -1254,36 +1007,24 @@ type Mutation { createAgentSession(input: CreateAgentSessionInput!): Session! createApp(input: CreateAppInput!): App! createAppMcpServer(input: CreateAppMcpServerInput!): McpServerWithCredential! - createDiscordAgentChannelBinding(input: CreateDiscordAgentChannelBindingInput!): AgentChannelBinding! createEnvironment(input: CreateEnvironmentInput!): EnvironmentSummary! createEnvironmentFork(input: CreateEnvironmentForkInput!): EnvironmentSummary! - createLarkAgentChannelBinding(input: CreateLarkAgentChannelBindingInput!): AgentChannelBinding! createSkillFork(input: CreateSkillForkInput!): SkillSummary! - createSlackAgentChannelBinding(input: CreateSlackAgentChannelBindingInput!): AgentChannelBinding! - createTelegramAgentChannelBinding(input: CreateTelegramAgentChannelBindingInput!): AgentChannelBinding! createVendorCredential(input: CreateVendorCredentialInput!): VendorCredential! deleteAgent(input: DeleteAgentInput!): OperationResult! - deleteAgentChannelBinding(input: DeleteAgentChannelBindingInput!): OperationResult! deleteAgentSession(appId: ULID!, sessionId: ULID!): OperationResult! - deleteAppDeployment(input: DeleteAppDeploymentInput!): OperationResult! deleteEnvironment(input: DeleteEnvironmentInput!): OperationResult! deleteMcpServer(appId: ULID!, serverId: ULID!): OperationResult! deleteOwnedSkill(appId: ULID!, skillId: ULID!): OperationResult! deleteVendorCredential(input: DeleteVendorCredentialInput!): OperationResult! - deployApp(input: DeployAppInput!): AppDeploymentRun! importAgentPackage(input: ImportAgentPackageInput!): AgentPackageImportResult! onboardingBootstrap(input: BootstrapOnboardingInput!): OnboardingStatus! - pollLarkAgentChannelRegistration(input: PollLarkAgentChannelRegistrationInput!): LarkAgentChannelRegistration! - pollWeChatAgentChannelPairing(input: PollWeChatAgentChannelPairingInput!): WeChatAgentChannelPairing! prewarmAgentSession(appId: ULID!, sessionId: ULID!): SessionRuntimePrewarmAck! publishAgent(input: PublishAgentInput!): Agent! - recreateSandbox(input: RuntimeStateOperationInput!): RuntimeStateOperationResult! removeSessionResource(input: RemoveSessionResourceInput!): OperationResult! renameApp(input: RenameAppInput!): App! renameOrganization(input: RenameOrganizationInput!): Organization! renameSession(input: RenameSessionInput!): Session! - resetAgentState(input: RuntimeStateOperationInput!): RuntimeStateOperationResult! - restartDriver(input: RuntimeStateOperationInput!): RuntimeStateOperationResult! revokeMcpCredential(appId: ULID!, serverId: ULID!): McpServerWithCredential! sendAgentSessionEvents(appId: ULID!, events: [AgentSessionEventInput!]!, sessionId: ULID!): AgentSessionEventBatch! setAppDefaultEnvironment(input: SetAppDefaultEnvironmentInput!): EnvironmentSummary! @@ -1292,9 +1033,7 @@ type Mutation { setMcpServerEnabled(appId: ULID!, enabled: Boolean!, serverId: ULID!): McpServerWithCredential! setSystemAgentModel(input: SetSystemAgentModelInput!): Account! startAgentRun(input: StartAgentRunInput!): AgentRunWorkflow! - startLarkAgentChannelRegistration(input: StartLarkAgentChannelRegistrationInput!): LarkAgentChannelRegistration! startMcpOAuth(input: StartMcpOAuthInput!): StartMcpOAuthPayload! - startWeChatAgentChannelPairing(input: StartWeChatAgentChannelPairingInput!): WeChatAgentChannelPairing! testVendorCredential(input: TestVendorCredentialInput!): TestVendorCredentialResult! unarchiveAgentSession(appId: ULID!, sessionId: ULID!): OperationResult! unpublishAgent(agentId: ULID!, appId: ULID!): Agent! @@ -1328,19 +1067,6 @@ type OrganizationBillingCostCard { totals: CostTotals! } -input PollLarkAgentChannelRegistrationInput { - agentId: ULID! - appId: ULID! - deviceCode: String! - domain: LarkDomain! -} - -input PollWeChatAgentChannelPairingInput { - agentId: ULID! - appId: ULID! - qrToken: String! -} - scalar PrimitiveRecord input PublishAgentInput { @@ -1351,7 +1077,6 @@ input PublishAgentInput { type Query { accessibleAgentList(appId: ULID!): [AgentSummary!]! agent(agentId: ULID!, appId: ULID!): AgentDetail! - agentChannelBindingList(agentId: ULID!, appId: ULID!): [AgentChannelBinding!]! agentCostCard(agentId: ULID!, appId: ULID!, range: CostRange!, runPurposes: [CostRunPurpose!]): AgentCostCard! agentEditorState(agentId: ULID!, appId: ULID!): AgentEditorState! agentManifest(agentId: ULID!, appId: ULID!): AgentManifestExport! @@ -1359,16 +1084,11 @@ type Query { agentSessionList(agentId: ULID!, appId: ULID!, archived: Boolean, beforeCursor: String, limit: Int, participantOnly: Boolean, type: SessionType): SessionConnection! agentSessionRetrieve(appId: ULID!, sessionId: ULID!): AgentSessionRetrieve! appCostCard(appId: ULID!, range: CostRange!, runPurposes: [CostRunPurpose!]): AppCostCard! - appDeploymentRunList(appId: ULID!, limit: Int): [AppDeploymentRun!]! - appDeploymentStatus(appId: ULID!): AppDeploymentRun appEnvironmentList(appId: ULID!): [EnvironmentSummary!]! appInfo: AppInfo! appList(organizationId: ULID!): [App!]! - appOverview(agentLimit: Int, appId: ULID!, credentialLimit: Int): AppOverview! appSkillList(appId: ULID!): [SkillSummary!]! availableAgentModels(appId: ULID!, currentModelId: String, currentVendorId: String, runtimeId: String!): [ResolvedModelEntry!]! - boundCapabilityRunProvenance(appId: ULID!, runId: ULID!): BoundCapabilityRunProvenance - controlPlaneOverview(agentLimit: Int, appLimit: Int, credentialLimit: Int): ControlPlaneOverview! environment(appId: ULID!, environmentId: ULID!): EnvironmentDetail! exportAgentPackage(agentId: ULID!, appId: ULID!): AgentPackageExport! fileList(input: FileListInput!): FileListing! @@ -1441,32 +1161,6 @@ enum RunStatus { waiting_input } -enum RuntimeStateOperation { - recreateSandbox - resetAgentState - restartDriver -} - -input RuntimeStateOperationInput { - affectedFields: [String!] - agentId: ULID! - appId: ULID! - applyActionKind: String - targetVersion: RuntimeStateTargetVersionInput -} - -type RuntimeStateOperationResult { - affectedSessionCount: Int! - agentId: ULID! - ok: Boolean! - operation: RuntimeStateOperation! -} - -input RuntimeStateTargetVersionInput { - id: ULID! - versionNumber: Int! -} - type Session { agentId: ULID! appId: ULID! @@ -1785,12 +1479,6 @@ input StartAgentRunInput { waitForRuntimeReady: Boolean } -input StartLarkAgentChannelRegistrationInput { - agentId: ULID! - appId: ULID! - domain: LarkDomain! -} - input StartMcpOAuthInput { appId: ULID! returnUrl: String @@ -1802,11 +1490,6 @@ type StartMcpOAuthPayload { flowId: ULID! } -input StartWeChatAgentChannelPairingInput { - agentId: ULID! - appId: ULID! -} - type SystemAgentModelSetting { modelId: String! vendor: String! @@ -1909,20 +1592,3 @@ type ViewerAuth { currentSecurityLevel: AuthSecurityLevel! methods: [AuthMethod!]! } - -type WeChatAgentChannelPairing { - binding: AgentChannelBinding - lastErrorCode: String - qrCodeImageSrc: String - qrToken: String - status: WeChatQrPairingStatus! -} - -enum WeChatQrPairingStatus { - confirmed - expired - failed - idle - qr_pending - scanned -} diff --git a/apps/api/src/adapters/graphql/schema/agent-schema.ts b/apps/api/src/adapters/graphql/schema/agent-schema.ts index 994d5a18..6795a153 100644 --- a/apps/api/src/adapters/graphql/schema/agent-schema.ts +++ b/apps/api/src/adapters/graphql/schema/agent-schema.ts @@ -57,7 +57,6 @@ export const agentSchema = /* GraphQL */ ` enum AgentResolutionTargetType { agent - channel environment model mcp_server @@ -71,12 +70,6 @@ export const agentSchema = /* GraphQL */ ` import } - enum RuntimeStateOperation { - recreateSandbox - resetAgentState - restartDriver - } - type AgentSkillReference { ownerName: String skillId: ULID! @@ -188,26 +181,6 @@ export const agentSchema = /* GraphQL */ ` resolution: AgentPackageResolutionReport! } - type RuntimeStateOperationResult { - affectedSessionCount: Int! - agentId: ULID! - ok: Boolean! - operation: RuntimeStateOperation! - } - - input RuntimeStateOperationInput { - affectedFields: [String!] - agentId: ULID! - applyActionKind: String - targetVersion: RuntimeStateTargetVersionInput - appId: ULID! - } - - input RuntimeStateTargetVersionInput { - id: ULID! - versionNumber: Int! - } - type AgentOwnerSummary { id: ULID! imageUrl: String diff --git a/apps/api/src/adapters/graphql/schema/app-schema.ts b/apps/api/src/adapters/graphql/schema/app-schema.ts index 76625b02..5a40833a 100644 --- a/apps/api/src/adapters/graphql/schema/app-schema.ts +++ b/apps/api/src/adapters/graphql/schema/app-schema.ts @@ -1,28 +1,4 @@ export const appSchema = /* GraphQL */ ` - enum AppOverviewBoundAgentExposure { - public_thread - } - - enum AppOverviewProviderCredentialStatus { - configured - } - - enum AppDeploymentRunStatus { - activating - building - failed - preparing - queued - submitted - submitting - success - } - - enum AppDeploymentTargetKind { - cloudflare_pages - cloudflare_worker - } - type App { createdAt: String! defaultEnvironmentId: ULID @@ -31,121 +7,11 @@ export const appSchema = /* GraphQL */ ` ownerAccountId: ULID! } - type AppDeploymentRun { - appId: ULID! - createdAt: String! - deploymentId: ULID! - errorCode: String - errorMessage: String - id: ULID! - liveUrl: String - plannedUrl: String! - sourceBranch: String! - sourceCommitSha: String! - status: AppDeploymentRunStatus! - targetKind: AppDeploymentTargetKind - updatedAt: String! - } - - type AppDeployment { - appId: ULID! - createdAt: String! - defaultBranch: String! - id: ULID! - latestRun: AppDeploymentRun - liveUrl: String - plannedUrl: String! - repoName: String! - repoOwner: String! - repoUrl: String! - updatedAt: String! - } - - type AppOverviewAgent { - appId: ULID! - description: String - id: ULID! - kind: AgentKind! - model: String! - name: String! - provider: String! - runtimeId: String! - status: AgentStatus! - updatedAt: String! - } - - type AppOverviewAgentList { - hasMore: Boolean! - items: [AppOverviewAgent!]! - limit: Int! - } - - type AppOverviewBoundAgent { - agentId: ULID! - envVar: String! - expose: AppOverviewBoundAgentExposure! - name: String! - } - - type AppOverviewProviderCredential { - appId: ULID! - hasCustomApiBase: Boolean! - id: ULID! - isDefault: Boolean! - modelCount: Int! - name: String! - status: AppOverviewProviderCredentialStatus! - vendorId: String! - } - - type AppOverviewProviderCredentialVendorCount { - count: Int! - defaultCredentialId: ULID - vendorId: String! - } - - type AppOverviewProviderCredentialList { - byVendor: [AppOverviewProviderCredentialVendorCount!]! - configuredCount: Int! - hasMore: Boolean! - items: [AppOverviewProviderCredential!]! - limit: Int! - } - - type AppOverview { - agents: AppOverviewAgentList! - app: App! - boundAgents: [AppOverviewBoundAgent!]! - deployment: AppDeployment - providerCredentials: AppOverviewProviderCredentialList! - } - - type ControlPlaneOverviewAppList { - hasMore: Boolean! - items: [AppOverview!]! - limit: Int! - } - - type ControlPlaneOverview { - activeOrganization: Organization - apps: ControlPlaneOverviewAppList! - } - input CreateAppInput { name: String! organizationId: ULID! } - input DeployAppInput { - appId: ULID! - configPath: String - repoUrl: String! - } - - input DeleteAppDeploymentInput { - appId: ULID! - } - input RenameAppInput { appId: ULID! name: String! diff --git a/apps/api/src/adapters/graphql/schema/channel-schema.ts b/apps/api/src/adapters/graphql/schema/channel-schema.ts deleted file mode 100644 index d1e06e6f..00000000 --- a/apps/api/src/adapters/graphql/schema/channel-schema.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AGENT_CHANNEL_BINDING_PROVIDERS } from "@mosoo/contracts/channel"; - -const channelProviderEnumValues = AGENT_CHANNEL_BINDING_PROVIDERS.map( - (provider) => ` ${provider}`, -).join("\n"); - -export const channelSchema = /* GraphQL */ ` - enum ChannelProvider { -${channelProviderEnumValues} - } - - enum AgentChannelBindingStatus { - active - error - } - - type AgentChannelBinding { - activityLastTriggeredAt: String - activitySessionCount7d: Int! - agentId: ULID! - createdAt: String! - displayMetadata: PrimitiveRecord! - externalBotId: String! - externalTenantId: String! - id: ULID! - lastErrorCode: String - appId: ULID! - provider: ChannelProvider! - status: AgentChannelBindingStatus! - updatedAt: String! - } - - input CreateSlackAgentChannelBindingInput { - agentId: ULID! - appLevelToken: String - botToken: String! - appId: ULID! - signingSecret: String! - threadRepliesRequireMention: Boolean - } - - input CreateLarkAgentChannelBindingInput { - agentId: ULID! - larkAppId: String! - appSecret: String! - connectionMode: LarkConnectionMode! - domain: LarkDomain! - encryptKey: String - appId: ULID! - verificationToken: String - } - - enum LarkConnectionMode { - webhook - websocket - } - - enum LarkDomain { - feishu - lark - } - - enum LarkAppRegistrationStatus { - access_denied - confirmed - expired - failed - qr_pending - slow_down - } - - type LarkAgentChannelRegistration { - appId: String - appSecret: String - deviceCode: String - domain: LarkDomain! - expireIn: Int - interval: Int - lastErrorCode: String - openId: String - qrUrl: String - status: LarkAppRegistrationStatus! - userCode: String - } - - input StartLarkAgentChannelRegistrationInput { - agentId: ULID! - domain: LarkDomain! - appId: ULID! - } - - input PollLarkAgentChannelRegistrationInput { - agentId: ULID! - deviceCode: String! - domain: LarkDomain! - appId: ULID! - } - - input CreateTelegramAgentChannelBindingInput { - agentId: ULID! - botToken: String! - appId: ULID! - webhookSecret: String! - } - - input CreateDiscordAgentChannelBindingInput { - agentId: ULID! - applicationId: String! - botToken: String! - appId: ULID! - relaySecret: String! - } - - enum WeChatQrPairingStatus { - confirmed - expired - failed - idle - qr_pending - scanned - } - - type WeChatAgentChannelPairing { - binding: AgentChannelBinding - lastErrorCode: String - qrCodeImageSrc: String - qrToken: String - status: WeChatQrPairingStatus! - } - - input StartWeChatAgentChannelPairingInput { - agentId: ULID! - appId: ULID! - } - - input PollWeChatAgentChannelPairingInput { - agentId: ULID! - appId: ULID! - qrToken: String! - } - - input DeleteAgentChannelBindingInput { - bindingId: ULID! - appId: ULID! - } -`; diff --git a/apps/api/src/adapters/graphql/schema/session-schema.ts b/apps/api/src/adapters/graphql/schema/session-schema.ts index 72d67df1..d7dc14ff 100644 --- a/apps/api/src/adapters/graphql/schema/session-schema.ts +++ b/apps/api/src/adapters/graphql/schema/session-schema.ts @@ -138,16 +138,6 @@ export const sessionSchema = /* GraphQL */ ` updatedAt: String! } - type BoundCapabilityRunProvenance { - agentId: ULID! - appId: ULID! - bindingEnv: String! - bindingName: String! - deploymentId: ULID! - deploymentRunId: ULID! - runId: ULID! - } - enum AgentSessionEventType { ${graphQLEnumValues(AGENT_SESSION_EVENT_TYPES)} } diff --git a/apps/api/src/adapters/http/create-http-app.ts b/apps/api/src/adapters/http/create-http-app.ts index 9ecd581c..e1ed58cd 100644 --- a/apps/api/src/adapters/http/create-http-app.ts +++ b/apps/api/src/adapters/http/create-http-app.ts @@ -11,20 +11,14 @@ import type { ApiGatewayEnvironment } from "../../platform/cloudflare/worker-typ import { requestLoggingMiddleware } from "./request-logging.middleware"; import { registerAccessTokenRoute } from "./routes/access-token-route"; import { registerAuthRoute } from "./routes/auth-route"; -import { registerDiscordEventsRoute } from "./routes/discord-events-route"; import { registerDriverRoute } from "./routes/driver-route"; import { registerFileRoute } from "./routes/file-route"; import { registerGraphQLRoute } from "./routes/graphql-route"; import { registerHealthRoute } from "./routes/health-route"; -import { registerLarkEventsRoute } from "./routes/lark-events-route"; -import { registerLarkGatewayInternalRoute } from "./routes/lark-gateway-internal-route"; import { registerMcpRoute } from "./routes/mcp-route"; -import { registerOwnerDebugTerminalRoute } from "./routes/owner-debug-terminal-route"; import { registerPublicApiRoute } from "./routes/public-api-route"; import { registerRootRoute } from "./routes/root-route"; import { registerSkillRoute } from "./routes/skill-route"; -import { registerSlackEventsRoute } from "./routes/slack-events-route"; -import { registerTelegramEventsRoute } from "./routes/telegram-events-route"; import { registerWorkspaceApiKeyRoute } from "./routes/workspace-api-key-route"; export function createHttpApp() { @@ -47,14 +41,8 @@ export function createHttpApp() { registerAuthRoute(publicApi); registerFileRoute(publicApi); registerMcpRoute(publicApi); - registerOwnerDebugTerminalRoute(publicApi); registerPublicApiRoute(publicApi); registerSkillRoute(publicApi); - registerDiscordEventsRoute(publicApi); - registerLarkEventsRoute(publicApi); - registerLarkGatewayInternalRoute(publicApi); - registerSlackEventsRoute(publicApi); - registerTelegramEventsRoute(publicApi); registerGraphQLRoute(publicApi); app.route(PUBLIC_API_PREFIX, publicApi); diff --git a/apps/api/src/adapters/http/request-logging.middleware.ts b/apps/api/src/adapters/http/request-logging.middleware.ts index 8ab6921c..2e30be79 100644 --- a/apps/api/src/adapters/http/request-logging.middleware.ts +++ b/apps/api/src/adapters/http/request-logging.middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { APP_AGENT_BOUND_PATH_PREFIX } from "../../modules/public-api/app-agent-capability"; import { createApiWideEvent, createRequestLogContext, @@ -9,30 +8,6 @@ import { } from "../../platform/cloudflare/logger"; import type { ApiGatewayEnvironment } from "../../platform/cloudflare/worker-types"; -/** - * A bound capability URL is a bearer secret carried in the path. Request logs - * keep the route shape (`/api/v1/bound/:token/...`) and never the token. - */ -export function redactRequestLogPath(pathname: string): string { - const prefix = `${APP_AGENT_BOUND_PATH_PREFIX}/`; - - if (!pathname.startsWith(prefix)) { - return pathname; - } - - const remainder = pathname.slice(prefix.length); - const nextSlash = remainder.indexOf("/"); - - return `${APP_AGENT_BOUND_PATH_PREFIX}/:token${nextSlash === -1 ? "" : remainder.slice(nextSlash)}`; -} - -function createRedactedRequestLogContext(request: Request) { - return { - ...createRequestLogContext(request), - path: redactRequestLogPath(new URL(request.url).pathname), - }; -} - export function requestLoggingMiddleware(): MiddlewareHandler { return async (c, next) => runWithRequestLogContext(c.req.raw, async () => { @@ -40,7 +15,7 @@ export function requestLoggingMiddleware(): MiddlewareHandler, status = 200): Response { - return Response.json(body, { status }); -} - -export function registerDiscordEventsRoute(app: Hono) { - app.post(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS.discord, async (c) => { - let bindingId: ChannelBindingId; - - try { - bindingId = toPlatformId(c.req.param("bindingId"), "Channel binding ID"); - } catch (error) { - const response = platformIdRouteErrorResponse(error, (message) => ({ - code: "invalid_request", - error: message, - ok: false, - })); - if (response !== null) { - return response; - } - throw error; - } - - const rawBody = await c.req.raw.clone().text(); - const binding = await resolveAgentChannelBindingContextById(c.env, { - bindingId, - provider: "discord", - }); - - if (!binding) { - logInfo("discord-channel-events.binding_not_found", { bindingId }); - return discordJson({ ignored: true, ok: true }); - } - - const credentials = parseDiscordCredentials(binding.credentialsJson); - const signature = await verifyDiscordRelaySignature({ - body: rawBody, - headers: c.req.raw.headers, - relaySecret: credentials.relaySecret, - }); - - if (!signature.ok) { - return discordJson( - { - code: signature.code, - error: signature.message, - ok: false, - }, - signature.status, - ); - } - - const parsed = parseDiscordGatewayDispatchEnvelope(rawBody); - - if (!parsed.ok) { - if (parsed.code === "unsupported_dispatch") { - logInfo("discord-channel-events.unsupported_dispatch_ignored", { - bindingId: binding.bindingId, - code: parsed.code, - }); - return discordJson({ ignored: true, ok: true }); - } - - return discordJson({ code: parsed.code, error: parsed.message, ok: false }, 400); - } - - const trigger = normalizeDiscordGatewayWorkTrigger(parsed.envelope, { - botUserId: binding.externalBotId, - }); - - if (!trigger) { - return discordJson({ ignored: true, ok: true }); - } - - if (binding.agentStatus !== "published") { - logInfo("discord-channel-events.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: trigger.eventId, - }); - return discordJson({ ignored: true, ok: true }); - } - - await enqueueChannelWorkTriggerCommand(c.env, { - bindingId: binding.bindingId, - provider: "discord", - requestUrl: c.req.url, - trigger, - }); - - return discordJson({ - accepted: true, - adapter: DISCORD_FIRST_PARTY_ADAPTER_MANIFEST.id, - ok: true, - }); - }); -} diff --git a/apps/api/src/adapters/http/routes/lark-events-route.ts b/apps/api/src/adapters/http/routes/lark-events-route.ts deleted file mode 100644 index ade76dc1..00000000 --- a/apps/api/src/adapters/http/routes/lark-events-route.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS } from "@mosoo/contracts/channel"; -import type { ChannelBindingId } from "@mosoo/id"; -import type { Hono } from "hono"; - -import { enqueueChannelWorkTriggerCommand } from "../../../modules/api-command/application/api-command-enqueue"; -import { resolveAgentChannelBindingContextById } from "../../../modules/channels/application/channel-binding-context"; -import { parseLarkCredentials } from "../../../modules/channels/lark/lark-credentials"; -import { - normalizeLarkWorkTrigger, - parseLarkEventsEnvelope, - readLarkEventsBody, -} from "../../../modules/channels/lark/lark-events"; -import { LARK_FIRST_PARTY_ADAPTER_MANIFEST } from "../../../modules/channels/lark/lark-first-party-adapter"; -import { verifyLarkSignature } from "../../../modules/channels/lark/lark-signing"; -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; -import { toPlatformId } from "../../../shared/platform-id"; -import { platformIdRouteErrorResponse } from "./platform-id-route-error"; - -function larkJson(body: Record, status = 200): Response { - return Response.json(body, { status }); -} - -export function registerLarkEventsRoute(app: Hono) { - app.post(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS.lark, async (c) => { - let bindingId: ChannelBindingId; - - try { - bindingId = toPlatformId(c.req.param("bindingId"), "Channel binding ID"); - } catch (error) { - const response = platformIdRouteErrorResponse(error, (message) => ({ - code: "invalid_request", - error: message, - ok: false, - })); - if (response !== null) { - return response; - } - throw error; - } - - const rawBody = await c.req.raw.clone().text(); - const binding = await resolveAgentChannelBindingContextById(c.env, { - bindingId, - provider: "lark", - }); - - if (!binding) { - logInfo("lark-channel-events.binding_not_found", { bindingId }); - return larkJson({ ignored: true, ok: true }); - } - - const credentials = parseLarkCredentials(binding.credentialsJson); - - if (credentials.connectionMode === "websocket") { - logInfo("channel.lark.webhook_received_on_websocket_binding", { - bindingId: binding.bindingId, - }); - return larkJson({ ignored: true, ok: true }); - } - - const encryptKey = credentials.encryptKey; - const verificationToken = credentials.verificationToken; - if (encryptKey === null || verificationToken === null) { - throw new Error( - "Lark webhook-mode binding had null signing fields after parseLarkCredentials.", - ); - } - - const signature = await verifyLarkSignature({ - body: rawBody, - encryptKey, - headers: c.req.raw.headers, - }); - - if (!signature.ok) { - return larkJson( - { - code: signature.code, - error: signature.message, - ok: false, - }, - signature.status, - ); - } - - const readableBody = await readLarkEventsBody({ - body: rawBody, - encryptKey, - }); - - if (!readableBody.ok) { - return larkJson({ code: readableBody.code, error: readableBody.message, ok: false }, 400); - } - - const parsed = parseLarkEventsEnvelope(readableBody.body, { - verificationToken, - }); - - if (!parsed.ok) { - if (parsed.code === "unsupported_type") { - logInfo("lark-channel-events.unsupported_event_ignored", { - bindingId: binding.bindingId, - code: parsed.code, - }); - return larkJson({ ignored: true, ok: true }); - } - - return larkJson({ code: parsed.code, error: parsed.message, ok: false }, 400); - } - - if (parsed.envelope.type === "url_verification") { - return larkJson({ challenge: parsed.envelope.challenge }); - } - - const trigger = normalizeLarkWorkTrigger(parsed.envelope); - - if (binding.agentStatus !== "published") { - logInfo("lark-channel-events.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: trigger.eventId, - }); - return larkJson({ ignored: true, ok: true }); - } - - await enqueueChannelWorkTriggerCommand(c.env, { - bindingId: binding.bindingId, - provider: "lark", - requestUrl: c.req.url, - trigger, - }); - - return larkJson({ - accepted: true, - adapter: LARK_FIRST_PARTY_ADAPTER_MANIFEST.id, - ok: true, - }); - }); -} diff --git a/apps/api/src/adapters/http/routes/lark-gateway-internal-route.ts b/apps/api/src/adapters/http/routes/lark-gateway-internal-route.ts deleted file mode 100644 index d26e32d5..00000000 --- a/apps/api/src/adapters/http/routes/lark-gateway-internal-route.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Local Lark sidecar endpoints. They are enabled only when -// MOSOO_LARK_SIDECAR_SECRET is present and every request carries it. - -import type { ChannelBindingId } from "@mosoo/id"; -import type { Hono } from "hono"; - -import { enqueueChannelWorkTriggerCommand } from "../../../modules/api-command/application/api-command-enqueue"; -import { resolveAgentChannelBindingContextById } from "../../../modules/channels/application/channel-binding-context"; -import { parseLarkCredentials } from "../../../modules/channels/lark/lark-credentials"; -import { - decodeLarkEventCallbackEnvelope, - normalizeLarkWorkTrigger, -} from "../../../modules/channels/lark/lark-events"; -import { LARK_FIRST_PARTY_ADAPTER_MANIFEST } from "../../../modules/channels/lark/lark-first-party-adapter"; -import { listPublishedWebsocketLarkBindingsForSidecar } from "../../../modules/channels/lark/lark-sidecar-registry"; -import type { LarkSidecarBindingDescriptor } from "../../../modules/channels/lark/lark-sidecar-registry"; -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; -import { toPlatformId } from "../../../shared/platform-id"; -import { matchesInternalRouteSecret } from "./internal-route-auth"; -import { platformIdRouteErrorResponse } from "./platform-id-route-error"; - -const SIDECAR_AUTH_HEADER = "x-sidecar-auth"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function sidecarSecretFromEnv(env: { MOSOO_LARK_SIDECAR_SECRET?: string }): string | null { - const value = env.MOSOO_LARK_SIDECAR_SECRET?.trim(); - return value && value.length > 0 ? value : null; -} - -function rejectUnauthenticated(): Response { - return Response.json({ error: "sidecar auth required", ok: false }, { status: 401 }); -} - -function rejectDisabled(): Response { - return Response.json( - { - error: "Lark sidecar endpoints are disabled in this environment.", - ok: false, - }, - { status: 404 }, - ); -} - -interface DescriptorPayload { - appId: string; - appSecret: string; - bindingId: string; - domain: "feishu" | "lark"; -} - -function toDescriptorPayload(record: LarkSidecarBindingDescriptor): DescriptorPayload { - return { - appId: record.credentials.appId, - appSecret: record.credentials.appSecret, - bindingId: record.bindingId, - domain: record.credentials.domain, - }; -} - -export function registerLarkGatewayInternalRoute(app: Hono) { - app.get("/v1/internal/lark-gateway/bindings", async (c) => { - const configured = sidecarSecretFromEnv(c.env); - if (configured === null) { - return rejectDisabled(); - } - - const submitted = c.req.header(SIDECAR_AUTH_HEADER); - if (!(await matchesInternalRouteSecret(submitted, configured))) { - return rejectUnauthenticated(); - } - - const descriptors = await listPublishedWebsocketLarkBindingsForSidecar(c.env); - return Response.json({ - bindings: descriptors.map(toDescriptorPayload), - ok: true, - }); - }); - - app.post("/v1/internal/lark-gateway/event/:bindingId", async (c) => { - const configured = sidecarSecretFromEnv(c.env); - if (configured === null) { - return rejectDisabled(); - } - - const submitted = c.req.header(SIDECAR_AUTH_HEADER); - if (!(await matchesInternalRouteSecret(submitted, configured))) { - return rejectUnauthenticated(); - } - - let bindingId: ChannelBindingId; - - try { - bindingId = toPlatformId(c.req.param("bindingId"), "Channel binding ID"); - } catch (error) { - const response = platformIdRouteErrorResponse(error, (message) => ({ - code: "invalid_request", - error: message, - ok: false, - })); - if (response !== null) { - return response; - } - throw error; - } - - const binding = await resolveAgentChannelBindingContextById(c.env, { - bindingId, - provider: "lark", - }); - - if (!binding) { - logInfo("lark.sidecar.binding_not_found", { bindingId }); - return Response.json({ ignored: true, ok: true }); - } - - const credentials = parseLarkCredentials(binding.credentialsJson); - if (credentials.connectionMode !== "websocket") { - logInfo("lark.sidecar.binding_not_websocket", { bindingId: binding.bindingId }); - return Response.json({ ignored: true, ok: true }); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - return Response.json({ error: "request body must be JSON", ok: false }, { status: 400 }); - } - - const envelopeCandidate = isRecord(body) ? body["envelope"] : undefined; - const decoded = decodeLarkEventCallbackEnvelope(envelopeCandidate); - - if (!decoded.ok) { - if (decoded.code === "unsupported_type") { - logInfo("lark.sidecar.unsupported_event_ignored", { - bindingId: binding.bindingId, - code: decoded.code, - }); - return Response.json({ ignored: true, ok: true }); - } - - return Response.json( - { code: decoded.code, error: decoded.message, ok: false }, - { status: 400 }, - ); - } - - if (binding.agentStatus !== "published") { - logInfo("lark.sidecar.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - }); - return Response.json({ ignored: true, ok: true }); - } - - const trigger = normalizeLarkWorkTrigger(decoded.envelope); - - await enqueueChannelWorkTriggerCommand(c.env, { - bindingId: binding.bindingId, - provider: "lark", - requestUrl: c.req.url, - trigger, - }); - - return Response.json({ - accepted: true, - adapter: LARK_FIRST_PARTY_ADAPTER_MANIFEST.id, - ok: true, - }); - }); -} diff --git a/apps/api/src/adapters/http/routes/owner-debug-terminal-route.ts b/apps/api/src/adapters/http/routes/owner-debug-terminal-route.ts deleted file mode 100644 index b1135c99..00000000 --- a/apps/api/src/adapters/http/routes/owner-debug-terminal-route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Hono } from "hono"; - -import { getViewerFromRequest } from "../../../modules/auth/application/viewer-auth.service"; -import { connectOwnerDebugTerminalWebSocket } from "../../../modules/runtime/application/owner-debug-terminal.service"; -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; -import { isApiError } from "../../../platform/errors"; - -function unauthorized(): Response { - return Response.json({ error: "Unauthorized." }, { status: 401 }); -} - -function errorResponse(error: unknown): Response { - if (isApiError(error)) { - return Response.json({ error: error.message }, { status: error.status }); - } - - if (error instanceof Error && error.message === "Agent not found.") { - return Response.json({ error: error.message }, { status: 404 }); - } - - logError("owner-debug-terminal-route.unexpected-error", createErrorLogContext(error)); - return Response.json({ error: "Owner debug terminal request failed." }, { status: 500 }); -} - -export function registerOwnerDebugTerminalRoute(app: Hono): void { - app.get("/agent/:agentId/owner-debug-terminal/ws", async (c) => { - try { - const viewer = await getViewerFromRequest(c.env, c.req.raw); - if (!viewer) { - return unauthorized(); - } - - return await connectOwnerDebugTerminalWebSocket(c.env, { - agentId: c.req.param("agentId"), - executionContext: c.executionCtx, - request: c.req.raw, - viewer, - }); - } catch (error) { - return errorResponse(error); - } - }); -} diff --git a/apps/api/src/adapters/http/routes/public-api-openapi-components.ts b/apps/api/src/adapters/http/routes/public-api-openapi-components.ts index 9efe33da..049c845e 100644 --- a/apps/api/src/adapters/http/routes/public-api-openapi-components.ts +++ b/apps/api/src/adapters/http/routes/public-api-openapi-components.ts @@ -1,14 +1,21 @@ import { PUBLIC_API_OPENAPI_SCHEMAS } from "@mosoo/contracts/public-api"; const WORKSPACE_RUN_SOURCE_SCHEMA = { + description: "Immutable source snapshot selected for this Run.", oneOf: [ { additionalProperties: false, properties: { - agentId: { type: "string" }, - agentVersionId: { type: ["string", "null"] }, - agentVersionNumber: { type: ["integer", "null"] }, - kind: { const: "agent" }, + agentId: { description: "Published Agent selected as the Run source.", type: "string" }, + agentVersionId: { + description: "Frozen Agent version identifier, when published.", + type: ["string", "null"], + }, + agentVersionNumber: { + description: "Frozen Agent version number, when published.", + type: ["integer", "null"], + }, + kind: { const: "agent", description: "Discriminator for an Agent-backed Run." }, }, required: ["agentId", "agentVersionId", "agentVersionNumber", "kind"], type: "object", @@ -16,9 +23,12 @@ const WORKSPACE_RUN_SOURCE_SCHEMA = { { additionalProperties: false, properties: { - harness: { enum: ["claude-code", "openai-codex", "opencode"] }, - kind: { const: "harness" }, - version: { type: "string" }, + harness: { + description: "Curated Harness selected as the Run source.", + enum: ["claude-code", "openai-codex", "opencode"], + }, + kind: { const: "harness", description: "Discriminator for a Harness-backed Run." }, + version: { description: "Frozen Harness version used by the Run.", type: "string" }, }, required: ["harness", "kind", "version"], type: "object", @@ -29,37 +39,60 @@ const WORKSPACE_RUN_SOURCE_SCHEMA = { const WORKSPACE_API_SCHEMAS = { HarnessCatalogEntry: { additionalProperties: false, + description: "One curated coding-agent Harness available to a Workspace.", properties: { capabilities: { additionalProperties: false, + description: "Normalized lifecycle capabilities exposed by this Harness.", properties: Object.fromEntries( ["approve", "artifacts", "cancel", "resume", "stream", "subagents"].map((name) => [ name, - { enum: ["native", "normalized", "unsupported"] }, + { + description: `Support level for the ${name} Run capability.`, + enum: ["native", "normalized", "unsupported"], + }, ]), ), required: ["approve", "artifacts", "cancel", "resume", "stream", "subagents"], type: "object", }, - defaultModel: { type: "string" }, - description: { type: "string" }, + defaultModel: { description: "Default model selected for this Harness.", type: "string" }, + description: { description: "Short explanation of the Harness use case.", type: "string" }, environment: { additionalProperties: false, + description: "Default Environment behavior for new Harness Runs.", properties: { - default: { const: "workspace" }, - repositoryRequired: { const: false }, + default: { const: "workspace", description: "Environment selection default." }, + repositoryRequired: { + const: false, + description: "Whether a repository is required to launch.", + }, }, required: ["default", "repositoryRequired"], type: "object", }, - label: { type: "string" }, - quickstart: { type: "string" }, - requiredCredentials: { items: { type: "string" }, type: "array" }, - runtimeId: { type: "string" }, - slug: { enum: ["claude-code", "openai-codex", "opencode"] }, - status: { enum: ["available", "unavailable"] }, - supportedModels: { items: { type: "string" }, type: "array" }, - version: { type: "string" }, + label: { description: "Human-readable Harness name.", type: "string" }, + quickstart: { description: "One-line command for starting this Harness.", type: "string" }, + requiredCredentials: { + description: "Credential providers required before launch.", + items: { type: "string" }, + type: "array", + }, + runtimeId: { description: "Internal runtime implementation identifier.", type: "string" }, + slug: { + description: "Stable Harness identifier accepted by the Run API.", + enum: ["claude-code", "openai-codex", "opencode"], + }, + status: { + description: "Current launch availability for this Harness.", + enum: ["available", "unavailable"], + }, + supportedModels: { + description: "Models currently offered by this Harness.", + items: { type: "string" }, + type: "array", + }, + version: { description: "Frozen Harness package version.", type: "string" }, }, required: [ "capabilities", @@ -78,12 +111,17 @@ const WORKSPACE_API_SCHEMAS = { type: "object", }, WorkspaceRunRequest: { + description: "Request to start a Run from exactly one supported source.", oneOf: [ { additionalProperties: false, properties: { - agent: { minLength: 1, type: "string" }, - input: {}, + agent: { + description: "Published Agent identifier or unambiguous Agent name.", + minLength: 1, + type: "string", + }, + input: { description: "Task input delivered to the selected Agent." }, }, required: ["agent", "input"], type: "object", @@ -91,10 +129,21 @@ const WORKSPACE_API_SCHEMAS = { { additionalProperties: false, properties: { - environment: { minLength: 1, type: "string" }, - harness: { enum: ["claude-code", "openai-codex", "opencode"] }, - input: {}, - model: { minLength: 1, type: "string" }, + environment: { + description: "Optional Environment identifier or unambiguous name.", + minLength: 1, + type: "string", + }, + harness: { + description: "Curated Harness to launch for this Run.", + enum: ["claude-code", "openai-codex", "opencode"], + }, + input: { description: "Task input delivered to the selected Harness." }, + model: { + description: "Optional supported model override for the Harness.", + minLength: 1, + type: "string", + }, }, required: ["harness", "input"], type: "object", @@ -103,32 +152,36 @@ const WORKSPACE_API_SCHEMAS = { }, WorkspaceRunResponse: { additionalProperties: false, + description: "Current state and immutable snapshots for one Workspace Run.", properties: { environment: { additionalProperties: false, + description: "Frozen Environment revision used by this Run.", properties: { - id: { type: "string" }, - name: { type: "string" }, - revisionId: { type: "string" }, + id: { description: "Environment identifier used by this Run.", type: "string" }, + name: { description: "Environment name captured for display.", type: "string" }, + revisionId: { description: "Frozen Environment revision identifier.", type: "string" }, }, required: ["id", "name", "revisionId"], type: "object", }, - id: { type: "string" }, + id: { description: "Stable identifier for this Run.", type: "string" }, links: { additionalProperties: false, + description: "Run lifecycle and result endpoint links.", properties: Object.fromEntries( ["approve", "artifacts", "cancel", "events", "result", "stream"].map((name) => [ name, - { type: "string" }, + { description: `Relative link for the Run ${name} endpoint.`, type: "string" }, ]), ), required: ["approve", "artifacts", "cancel", "events", "result", "stream"], type: "object", }, - model: { type: "string" }, + model: { description: "Frozen model selected for this Run.", type: "string" }, source: WORKSPACE_RUN_SOURCE_SCHEMA, status: { + description: "Current lifecycle status for this Run.", enum: [ "queued", "booting", @@ -140,8 +193,8 @@ const WORKSPACE_API_SCHEMAS = { "expired", ], }, - threadId: { type: "string" }, - workspaceId: { type: "string" }, + threadId: { description: "Backing Thread identifier for event history.", type: "string" }, + workspaceId: { description: "Workspace that owns this Run.", type: "string" }, }, required: [ "environment", @@ -157,11 +210,16 @@ const WORKSPACE_API_SCHEMAS = { }, WorkspaceRunResultResponse: { additionalProperties: false, + description: "Run state paired with its canonical final output.", properties: { output: { + description: "Canonical final assistant output, or null while unavailable.", oneOf: [{ $ref: "#/components/schemas/RunFinalOutput" }, { type: "null" }], }, - run: { $ref: "#/components/schemas/WorkspaceRunResponse" }, + run: { + $ref: "#/components/schemas/WorkspaceRunResponse", + description: "Latest lifecycle state for the requested Run.", + }, }, required: ["output", "run"], type: "object", diff --git a/apps/api/src/adapters/http/routes/public-api-route-support.ts b/apps/api/src/adapters/http/routes/public-api-route-support.ts index be943202..3b25564b 100644 --- a/apps/api/src/adapters/http/routes/public-api-route-support.ts +++ b/apps/api/src/adapters/http/routes/public-api-route-support.ts @@ -1,15 +1,16 @@ import type { AgentId, PlatformId, PublicThreadId } from "@mosoo/id"; import type { Context } from "hono"; -import { readBearerToken } from "../../../modules/auth/application/personal-access-token.service"; +import { + authenticatePersonalAccessToken, + readBearerToken, +} from "../../../modules/auth/application/personal-access-token.service"; +import type { PersonalAccessTokenCaller } from "../../../modules/auth/application/personal-access-token.service"; import { authenticatePublicApiCaller, readPublicApiBearerToken, } from "../../../modules/auth/application/public-api-caller.service"; -import type { - DeploymentCapabilityPublicApiCaller, - PublicApiCaller, -} from "../../../modules/auth/application/public-api-caller.service"; +import type { PublicApiCaller } from "../../../modules/auth/application/public-api-caller.service"; import { authenticateWorkspaceApiKey, readWorkspaceApiKey, @@ -17,13 +18,6 @@ import { import type { WorkspaceApiKeyCaller } from "../../../modules/auth/application/workspace-api-key.service"; import { FileControlError } from "../../../modules/files/application/file-control-errors"; import { - admitDeploymentCapability, - DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE, - deploymentCapabilityRateLimitKey, - toDeploymentCapabilityCaller, -} from "../../../modules/public-api/deployment-capability-caller.service"; -import { - publicAgentNotExposed, publicInternalError, publicIdempotencyConflict, publicInvalidJson, @@ -40,7 +34,6 @@ import { readPublicApiIdempotencyKey, } from "../../../modules/public-api/public-api-idempotency.service"; import { enforcePublicApiRateLimit } from "../../../modules/public-api/public-api-rate-limit.service"; -import { SessionRunCreationGuardRejectedError } from "../../../modules/runtime/application/session-run.service"; import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; import { API_ERROR_CODE, isApiError } from "../../../platform/errors"; @@ -50,25 +43,16 @@ import { mapFileControlErrorToPublicApiError } from "./public-api-file-error-map type PublicApiRouteContext = Context; +interface PublicApiAuthenticatedOperation { + caller: PersonalAccessTokenCaller; +} + interface PublicApiThreadOperation { caller: PublicApiCaller; } type RouteValue = T | (() => T); -/** - * How a route resolves its caller. The default reads the owner Access Token - * bearer header; bound capability routes resolve the deployment identity from - * the capability token in their path instead. - */ -export type PublicApiCallerResolver = (c: PublicApiRouteContext) => Promise; - -export interface PublicApiCallerOptions { - /** Normalized idempotency route; defaults to the request pathname. */ - idempotencyRoute?: string | undefined; - resolveCaller?: PublicApiCallerResolver | undefined; -} - interface PublicApiJsonErrorResponse { body: { error: { @@ -84,29 +68,16 @@ function resolveRequiredRouteValue(value: RouteValue): T { return typeof value === "function" ? (value as () => T)() : value; } -/** Rate-limit bucket for a caller: Access Tokens by token id, capabilities by App + Agent. */ -function publicApiRateLimitKey(caller: PublicApiCaller): string { - return caller.kind === "access_token" - ? caller.tokenId - : deploymentCapabilityRateLimitKey(caller.capability); -} - -/** - * Idempotency subject for a caller. A deployment capability shares one subject - * across the revisions of its Deployment, so a retry after a redeploy replays. - */ -function publicApiIdempotencySubjectId(caller: PublicApiCaller): PlatformId { - return caller.kind === "access_token" ? caller.tokenId : caller.capability.deploymentId; -} - -async function requireAccessTokenCaller(c: PublicApiRouteContext): Promise { +async function requireAccessTokenCaller( + c: PublicApiRouteContext, +): Promise { const token = readBearerToken(c.req.raw); if (!isTruthy(token)) { throw publicUnauthenticated(); } - const caller = await authenticatePublicApiCaller(c.env.DB, token); + const caller = await authenticatePersonalAccessToken(c.env.DB, token); if (!caller) { throw publicUnauthenticated("Access Token is invalid or revoked."); @@ -115,6 +86,14 @@ async function requireAccessTokenCaller(c: PublicApiRouteContext): Promise { + const caller = await requireAccessTokenCaller(c); + await enforcePublicApiRateLimit(c.env.DB, caller.tokenId); + return caller; +} + async function requireRateLimitedWorkspaceApiKeyCaller( c: PublicApiRouteContext, ): Promise { @@ -150,41 +129,14 @@ async function requirePublicApiCaller(c: PublicApiRouteContext): Promise { - const caller = await resolveCaller(c); - await enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); + const caller = await requirePublicApiCaller(c); + await enforcePublicApiRateLimit(c.env.DB, caller.tokenId); return caller; } -/** - * Resolve the deployment-scoped identity carried by a bound capability URL - * (`/bound/:token/...`). Verification, Agent servability, Deployment authority, - * and owner resolution all happen before any route logic runs. - */ -export async function requireDeploymentCapabilityCaller( - c: PublicApiRouteContext, -): Promise { - const admission = await admitDeploymentCapability(c.env, c.req.param("token") ?? "", Date.now()); - - return toDeploymentCapabilityCaller(admission); -} - -/** - * Idempotency reservations are keyed by route. A bound capability URL embeds - * the per-revision token in its path, so normalize it away: the same - * Idempotency-Key from a redeployed Worker must replay the original response - * instead of failing as a different request. - */ -export function deploymentCapabilityIdempotencyRoute(c: PublicApiRouteContext): string { - const token = c.req.param("token") ?? ""; - const pathname = new URL(c.req.url).pathname; - - return token.length === 0 ? pathname : pathname.replace(`/bound/${token}`, "/bound/:token"); -} - function errorHeaders(error: PublicApiError): HeadersInit { if (error.retryAfterSeconds === null) { return {}; @@ -236,12 +188,6 @@ function toErrorResponseDetails(error: unknown): PublicApiJsonErrorResponse { return toErrorResponseDetails(publicReadinessBlocked(error.message)); } - // A deployment capability's Run insert repeats the Deployment authority - // condition; losing that race means the capability was revoked mid-request. - if (error instanceof SessionRunCreationGuardRejectedError) { - return toErrorResponseDetails(publicAgentNotExposed(DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE)); - } - if (error instanceof SyntaxError) { return toErrorResponseDetails(publicInvalidJson()); } @@ -304,7 +250,6 @@ async function runPublicApiIdempotentJson( c: PublicApiRouteContext, input: { bodyHash: string | null; - idempotencyRoute?: string | undefined; idempotencySubjectId: PlatformId; beforeOperation?: (() => Promise) | undefined; operation: (idempotencyKey: string | null) => Promise; @@ -320,12 +265,11 @@ async function runPublicApiIdempotentJson( return Response.json(await input.operation(null), { status: input.status }); } - const route = input.idempotencyRoute ?? new URL(c.req.url).pathname; let reservation = await beginPublicApiIdempotency(c.env.DB, { bodyHash: input.bodyHash, idempotencyKey, method: c.req.raw.method, - route, + route: new URL(c.req.url).pathname, tokenId: input.idempotencySubjectId, }); @@ -353,7 +297,7 @@ async function runPublicApiIdempotentJson( logError("public-api.idempotency_recovery_completion_failed", { ...createErrorLogContext(error), reservationId: reservation.reservationId, - route, + route: new URL(c.req.url).pathname, tokenId: input.idempotencySubjectId, }); } @@ -373,7 +317,7 @@ async function runPublicApiIdempotentJson( bodyHash: input.bodyHash, idempotencyKey, method: c.req.raw.method, - route, + route: new URL(c.req.url).pathname, tokenId: input.idempotencySubjectId, }); @@ -410,7 +354,7 @@ async function runPublicApiIdempotentJson( logError("public-api.idempotency_error_completion_failed", { ...createErrorLogContext(completionError), reservationId: reservation.reservationId, - route, + route: new URL(c.req.url).pathname, tokenId: input.idempotencySubjectId, }); }); @@ -434,7 +378,7 @@ async function runPublicApiIdempotentJson( logError("public-api.idempotency_completion_failed", { ...createErrorLogContext(error), reservationId: reservation.reservationId, - route, + route: new URL(c.req.url).pathname, tokenId: input.idempotencySubjectId, }); } @@ -444,15 +388,11 @@ async function runPublicApiIdempotentJson( export async function runPublicApiAuthenticatedJson( c: PublicApiRouteContext, - operation: (caller: PublicApiCaller) => Promise, + operation: (caller: PersonalAccessTokenCaller) => Promise, status = 200, - options: PublicApiCallerOptions = {}, ): Promise { try { - const caller = await requireRateLimitedCaller( - c, - options.resolveCaller ?? requireAccessTokenCaller, - ); + const caller = await requireRateLimitedAccessTokenCaller(c); return Response.json(await operation(caller), { status }); } catch (error) { return toErrorResponse(error); @@ -461,14 +401,10 @@ export async function runPublicApiAuthenticatedJson( export async function runPublicApiAuthenticatedResponse( c: PublicApiRouteContext, - operation: (caller: PublicApiCaller) => Promise, - options: PublicApiCallerOptions = {}, + operation: (caller: PersonalAccessTokenCaller) => Promise, ): Promise { try { - const caller = await requireRateLimitedCaller( - c, - options.resolveCaller ?? requireAccessTokenCaller, - ); + const caller = await requireRateLimitedAccessTokenCaller(c); return await operation(caller); } catch (error) { return toErrorResponse(error); @@ -502,36 +438,34 @@ export async function runWorkspaceApiAuthenticatedResponse( export async function runPublicApiSessionMutation( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { bodyHash?: (prepared: Prepared) => string | null; operation: ( - input: PublicApiThreadOperation & { + input: PublicApiAuthenticatedOperation & { prepared: Prepared; threadId: PublicThreadId; }, ) => Promise; - prepare?: (input: PublicApiThreadOperation) => Promise; + prepare?: (input: PublicApiAuthenticatedOperation) => Promise; status?: number | undefined; threadId: RouteValue; }, ): Promise { try { - const caller = await (input.resolveCaller ?? requireAccessTokenCaller)(c); + const caller = await requireAccessTokenCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); - const operationInput: PublicApiThreadOperation = { caller }; + const operationInput: PublicApiAuthenticatedOperation = { caller }; const prepared = input.prepare ? await input.prepare(operationInput) : (undefined as Prepared); const status = input.status ?? 200; const operation = async (_idempotencyKey: string | null) => input.operation({ ...operationInput, prepared, threadId }); - const beforeOperation = () => - enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); + const beforeOperation = () => enforcePublicApiRateLimit(c.env.DB, caller.tokenId); if (input.bodyHash) { return await runPublicApiIdempotentJson(c, { bodyHash: input.bodyHash(prepared), beforeOperation, - idempotencyRoute: input.idempotencyRoute, - idempotencySubjectId: publicApiIdempotencySubjectId(caller), + idempotencySubjectId: caller.tokenId, operation, status, }); @@ -546,14 +480,14 @@ export async function runPublicApiSessionMutation( export async function runPublicApiThreadReadJson( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { operation: (input: PublicApiThreadOperation & { threadId: PublicThreadId }) => Promise; status?: number | undefined; threadId: RouteValue; }, ): Promise { try { - const caller = await requireRateLimitedCaller(c, input.resolveCaller ?? requirePublicApiCaller); + const caller = await requireRateLimitedPublicApiCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); const status = input.status ?? 200; @@ -565,7 +499,7 @@ export async function runPublicApiThreadReadJson( export async function runPublicApiThreadReadResponse( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { operation: ( input: PublicApiThreadOperation & { threadId: PublicThreadId }, ) => Promise; @@ -573,7 +507,7 @@ export async function runPublicApiThreadReadResponse( }, ): Promise { try { - const caller = await requireRateLimitedCaller(c, input.resolveCaller ?? requirePublicApiCaller); + const caller = await requireRateLimitedPublicApiCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); return await input.operation({ caller, threadId }); @@ -584,9 +518,8 @@ export async function runPublicApiThreadReadResponse( export async function runPublicApiThreadMutation( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { - /** The target Agent; a resolver may derive it from the admitted caller (bound capability). */ - agentId: AgentId | ((caller: PublicApiCaller) => AgentId); + input: { + agentId: RouteValue; bodyHash?: (prepared: Prepared) => string | null; operation: ( input: PublicApiThreadOperation & { @@ -607,8 +540,8 @@ export async function runPublicApiThreadMutation( }, ): Promise { try { - const caller = await (input.resolveCaller ?? requirePublicApiCaller)(c); - const agentId = typeof input.agentId === "function" ? input.agentId(caller) : input.agentId; + const caller = await requirePublicApiCaller(c); + const agentId = resolveRequiredRouteValue(input.agentId); const operationInput: PublicApiThreadOperation = { caller }; const prepared = input.prepare ? await input.prepare(operationInput) : (undefined as Prepared); const status = input.status ?? 200; @@ -618,15 +551,13 @@ export async function runPublicApiThreadMutation( ? async (idempotencyKey: string) => input.recover?.({ ...operationInput, agentId, idempotencyKey, prepared }) ?? null : undefined; - const beforeOperation = () => - enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); + const beforeOperation = () => enforcePublicApiRateLimit(c.env.DB, caller.tokenId); if (input.bodyHash) { return await runPublicApiIdempotentJson(c, { bodyHash: input.bodyHash(prepared), beforeOperation, - idempotencyRoute: input.idempotencyRoute, - idempotencySubjectId: publicApiIdempotencySubjectId(caller), + idempotencySubjectId: caller.tokenId, operation, persistOperationErrors: true, recover, diff --git a/apps/api/src/adapters/http/routes/public-api-route.ts b/apps/api/src/adapters/http/routes/public-api-route.ts index 4e47876c..df465149 100644 --- a/apps/api/src/adapters/http/routes/public-api-route.ts +++ b/apps/api/src/adapters/http/routes/public-api-route.ts @@ -1,23 +1,16 @@ import { PUBLIC_API_VERSION_PREFIX } from "@mosoo/contracts/public-api"; -import type { AgentId, PublicThreadId } from "@mosoo/id"; +import type { PublicThreadId } from "@mosoo/id"; import { getHarnessCatalogEntry, listHarnessCatalog } from "@mosoo/runtime-catalog"; import { Hono } from "hono"; import type { Context } from "hono"; -import type { PublicApiCaller } from "../../../modules/auth/application/public-api-caller.service"; -import { parseBoundAgentCallBody } from "../../../modules/public-api/app-agent-bound-call"; -import { renderBoundAgentCallError } from "../../../modules/public-api/app-agent-bound-errors"; +import type { PersonalAccessTokenCaller } from "../../../modules/auth/application/personal-access-token.service"; import { publicInvalidRequest } from "../../../modules/public-api/public-api-errors"; -import { - hashPublicApiIdempotencyBody, - readPublicApiIdempotencyKey, -} from "../../../modules/public-api/public-api-idempotency.service"; +import { hashPublicApiIdempotencyBody } from "../../../modules/public-api/public-api-idempotency.service"; import { listAgentApiEndpointThreads } from "../../../modules/public-api/public-thread-session-query.service"; import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; import { createPublicApiOpenApiDocument } from "./public-api-openapi"; import { - deploymentCapabilityIdempotencyRoute, - requireDeploymentCapabilityCaller, runPublicApiAuthenticatedJson, runPublicApiAuthenticatedResponse, runPublicApiSessionMutation, @@ -27,16 +20,14 @@ import { runWorkspaceApiAuthenticatedJson, runWorkspaceApiAuthenticatedResponse, } from "./public-api-route-support"; -import type { PublicApiCallerOptions } from "./public-api-route-support"; import { parseFileContentDisposition, parseOptionalBoolean, parseAgentIdParam, parseFileIdParam, - parseRunIdParam, parseThreadIdParam, parseThreadEventsLimit, - readBoundAgentCallRequestBody, + parseRunIdParam, readCreateThreadRequest, readSendEventsRequest, } from "./public-thread-api-request"; @@ -52,54 +43,6 @@ interface PublicAgentFileUploadRequest { } type PublicThreadFileService = Awaited>; -/** - * The injected capability URL path within `/api/v1`; the full public prefix is - * `APP_AGENT_BOUND_PATH_PREFIX` in `app-agent-capability.ts`. - */ -const BOUND_CAPABILITY_ROUTE_BASE = "/bound/:token"; - -/** - * One Public Thread surface, two ways to address it. Access Token routes take - * the Agent from the path and the caller from the bearer header; bound - * capability routes mount the same operations under the injected capability - * URL, take the Agent from the verified claims, and resolve the deployment- - * scoped caller from the token in the path. - */ -interface PublicThreadRouteScope { - /** Path prefix for Agent-addressed operations (create thread, upload file, list threads). */ - agentBase: string; - /** Resolves the target Agent after the caller is admitted (path param or capability claim). */ - agentId: (c: PublicApiRouteContext) => (caller: PublicApiCaller) => AgentId; - /** Path prefix for Thread- and file-addressed operations. */ - base: string; - options: (c: PublicApiRouteContext) => PublicApiCallerOptions; -} - -function deploymentCapabilityAgentId(caller: PublicApiCaller): AgentId { - if (caller.kind !== "deployment_capability") { - throw new Error("Bound capability routes require a deployment capability caller."); - } - - return caller.capability.agentId; -} - -const ACCESS_TOKEN_SCOPE: PublicThreadRouteScope = { - agentBase: "/agents/:agentId", - agentId: (c) => () => parseAgentIdParam(c.req.param("agentId") ?? ""), - base: "", - options: () => ({}), -}; - -const BOUND_CAPABILITY_SCOPE: PublicThreadRouteScope = { - agentBase: BOUND_CAPABILITY_ROUTE_BASE, - agentId: () => deploymentCapabilityAgentId, - base: BOUND_CAPABILITY_ROUTE_BASE, - options: (c) => ({ - idempotencyRoute: deploymentCapabilityIdempotencyRoute(c), - resolveCaller: requireDeploymentCapabilityCaller, - }), -}; - async function loadPublicThreadCommandService() { return import("../../../modules/public-api/public-thread-api-command.service"); } @@ -112,19 +55,14 @@ async function loadPublicThreadFileService() { return import("../../../modules/public-api/public-thread-file-api.service"); } -async function loadBoundAgentAskService() { - return import("../../../modules/public-api/app-agent-bound-ask.service"); -} - async function loadWorkspaceRunService() { return import("../../../modules/public-api/workspace-run-api.service"); } async function runPublicThreadFileRoute( c: PublicApiRouteContext, - scope: PublicThreadRouteScope, operation: (input: { - caller: PublicApiCaller; + caller: PersonalAccessTokenCaller; service: PublicThreadFileService; threadId: PublicThreadId; }) => Promise, @@ -139,7 +77,6 @@ async function runPublicThreadFileRoute( threadId: parseThreadIdParam(c.req.param("threadId") ?? ""), }), status, - scope.options(c), ); } @@ -166,19 +103,130 @@ async function readPublicAgentFileUploadRequest( return { file }; } -/** - * Thread lifecycle, observation, and file routes shared by both scopes. The - * destructive owner operations (archive, unarchive, delete) stay Access Token - * only — see `registerAccessTokenOnlyRoutes`. - */ -function registerPublicThreadRoutes( - v1: Hono, - scope: PublicThreadRouteScope, -): void { - v1.post(`${scope.agentBase}/threads`, async (c) => { +export function registerPublicApiRoute(app: Hono) { + const v1 = new Hono(); + + v1.get("/openapi.json", (c) => c.json(createPublicApiOpenApiDocument(new URL(c.req.url).origin))); + + v1.get("/harnesses", (c) => c.json({ harnesses: listHarnessCatalog() })); + + v1.get("/harnesses/:slug", (c) => { + const harness = getHarnessCatalogEntry(c.req.param("slug")); + + return harness === null + ? c.json({ error: { code: "not_found", message: "Harness was not found." } }, 404) + : c.json({ harness }); + }); + + v1.post("/runs", (c) => + runWorkspaceApiAuthenticatedJson( + c, + async (caller) => { + const service = await loadWorkspaceRunService(); + return service.startWorkspaceRun({ + bindings: c.env, + caller, + executionContext: c.executionCtx, + input: await readCreateWorkspaceRunRequest(c), + requestUrl: c.req.url, + }); + }, + 201, + ), + ); + + v1.get("/runs/:runId", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.retrieveWorkspaceRun(c.env.DB, caller, parseRunIdParam(c.req.param("runId"))); + }), + ); + + v1.get("/runs/:runId/events", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.listWorkspaceRunEvents({ + caller, + database: c.env.DB, + limit: parseThreadEventsLimit(c.req.query("limit")), + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.get("/runs/:runId/events/stream", (c) => + runWorkspaceApiAuthenticatedResponse(c, async (caller) => { + const service = await loadWorkspaceRunService(); + const stream = await service.streamWorkspaceRunEvents({ + bindings: c.env, + caller, + limit: parseThreadEventsLimit(c.req.query("limit")), + runId: parseRunIdParam(c.req.param("runId")), + signal: c.req.raw.signal, + }); + + return new Response(stream, { + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no", + }, + }); + }), + ); + + v1.get("/runs/:runId/result", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.retrieveWorkspaceRunResult({ + caller, + database: c.env.DB, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.get("/runs/:runId/artifacts", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.listWorkspaceRunArtifacts({ + bindings: c.env, + caller, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.post("/runs/:runId/approve", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + const approval = await readWorkspaceRunApprovalRequest(c); + return service.approveWorkspaceRun({ + bindings: c.env, + caller, + decision: approval.decision, + executionContext: c.executionCtx, + requestId: approval.requestId, + requestUrl: c.req.url, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.post("/runs/:runId/cancel", (c) => + runWorkspaceApiAuthenticatedJson(c, async (caller) => { + const service = await loadWorkspaceRunService(); + return service.cancelWorkspaceRun({ + bindings: c.env, + caller, + runId: parseRunIdParam(c.req.param("runId")), + }); + }), + ); + + v1.post("/agents/:agentId/threads", async (c) => { return runPublicApiThreadMutation(c, { - ...scope.options(c), - agentId: scope.agentId(c), + agentId: () => parseAgentIdParam(c.req.param("agentId")), bodyHash: (prepared) => prepared.bodyHash, operation: async ({ agentId, caller, idempotencyKey, prepared }) => { const { createPublicThread } = await loadPublicThreadService(); @@ -215,10 +263,9 @@ function registerPublicThreadRoutes( }); }); - v1.post(`${scope.agentBase}/files`, async (c) => + v1.post("/agents/:agentId/files", async (c) => runPublicApiThreadMutation(c, { - ...scope.options(c), - agentId: scope.agentId(c), + agentId: () => parseAgentIdParam(c.req.param("agentId")), operation: async ({ agentId, caller, prepared }) => { const service = await loadPublicThreadFileService(); return service.createPublicAgentFile(c.env, caller, { @@ -231,22 +278,8 @@ function registerPublicThreadRoutes( }), ); - v1.get(`${scope.agentBase}/threads`, async (c) => - runPublicApiAuthenticatedJson( - c, - async (caller) => - listAgentApiEndpointThreads(c.env.DB, caller, { - agentId: scope.agentId(c)(caller), - archived: parseOptionalBoolean(c.req.query("archived")), - }), - 200, - scope.options(c), - ), - ); - - v1.get(`${scope.base}/threads/:threadId`, async (c) => + v1.get("/threads/:threadId", async (c) => runPublicApiThreadReadJson(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { retrievePublicThread } = await loadPublicThreadService(); return retrievePublicThread({ @@ -259,9 +292,8 @@ function registerPublicThreadRoutes( }), ); - v1.get(`${scope.base}/threads/:threadId/events`, async (c) => + v1.get("/threads/:threadId/events", async (c) => runPublicApiThreadReadJson(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { listPublicThreadEvents } = await loadPublicThreadService(); return listPublicThreadEvents({ @@ -275,9 +307,8 @@ function registerPublicThreadRoutes( }), ); - v1.get(`${scope.base}/threads/:threadId/events/stream`, async (c) => + v1.get("/threads/:threadId/events/stream", async (c) => runPublicApiThreadReadResponse(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { createPublicThreadEventStream } = await loadPublicThreadService(); const stream = await createPublicThreadEventStream({ @@ -301,15 +332,52 @@ function registerPublicThreadRoutes( }), ); - v1.post(`${scope.base}/threads/:threadId/events`, async (c) => { + v1.get("/agents/:agentId/threads", async (c) => + runPublicApiAuthenticatedJson(c, async (caller) => + listAgentApiEndpointThreads(c.env.DB, caller.viewer, { + agentId: parseAgentIdParam(c.req.param("agentId")), + archived: parseOptionalBoolean(c.req.query("archived")), + }), + ), + ); + + v1.get("/files/:fileId/content", async (c) => + runPublicApiAuthenticatedResponse(c, async (caller) => { + const service = await loadPublicThreadFileService(); + return service.downloadPublicThreadFileContent(c.env, caller.viewer, { + disposition: parseFileContentDisposition(c.req.query("disposition")), + fileId: parseFileIdParam(c.req.param("fileId")), + }); + }), + ); + + v1.get("/files/:fileId", async (c) => + runPublicApiAuthenticatedJson(c, async (caller) => { + const service = await loadPublicThreadFileService(); + return service.retrievePublicFile( + c.env, + caller.viewer, + parseFileIdParam(c.req.param("fileId")), + ); + }), + ); + + v1.delete("/files/:fileId", async (c) => + runPublicApiAuthenticatedJson(c, async (caller) => { + const service = await loadPublicThreadFileService(); + await service.deletePublicFile(c.env, caller.viewer, parseFileIdParam(c.req.param("fileId"))); + return { ok: true }; + }), + ); + + v1.post("/threads/:threadId/events", async (c) => { return runPublicApiSessionMutation(c, { - ...scope.options(c), bodyHash: (prepared) => prepared.bodyHash, operation: async ({ caller, prepared, threadId }) => { const { sendPublicThreadSessionEvents } = await loadPublicThreadCommandService(); return sendPublicThreadSessionEvents({ bindings: c.env, - caller, + caller: caller.viewer, executionContext: c.executionCtx, input: prepared.body, requestUrl: c.req.url, @@ -327,55 +395,13 @@ function registerPublicThreadRoutes( }); }); - v1.get(`${scope.base}/threads/:threadId/files`, async (c) => - runPublicThreadFileRoute(c, scope, async ({ caller, service, threadId }) => - service.listPublicThreadFiles(c.env, caller, threadId), - ), - ); - - v1.get(`${scope.base}/files/:fileId/content`, async (c) => - runPublicApiAuthenticatedResponse( - c, - async (caller) => { - const service = await loadPublicThreadFileService(); - return service.downloadPublicThreadFileContent(c.env, caller, { - disposition: parseFileContentDisposition(c.req.query("disposition")), - fileId: parseFileIdParam(c.req.param("fileId")), - }); - }, - scope.options(c), - ), - ); - - v1.get(`${scope.base}/files/:fileId`, async (c) => - runPublicApiAuthenticatedJson( - c, - async (caller) => { - const service = await loadPublicThreadFileService(); - return service.retrievePublicFile(c.env, caller, parseFileIdParam(c.req.param("fileId"))); - }, - 200, - scope.options(c), - ), - ); -} - -function registerAccessTokenOnlyRoutes(v1: Hono): void { - v1.delete("/files/:fileId", async (c) => - runPublicApiAuthenticatedJson(c, async (caller) => { - const service = await loadPublicThreadFileService(); - await service.deletePublicFile(c.env, caller, parseFileIdParam(c.req.param("fileId"))); - return { ok: true }; - }), - ); - v1.post("/threads/:threadId/archive", async (c) => { return runPublicApiSessionMutation(c, { operation: async ({ caller, threadId }) => { const { archivePublicThreadSession } = await loadPublicThreadCommandService(); await archivePublicThreadSession({ bindings: c.env, - caller, + caller: caller.viewer, threadId, }); return { ok: true }; @@ -389,7 +415,7 @@ function registerAccessTokenOnlyRoutes(v1: Hono): void { operation: async ({ caller, threadId }) => { const { unarchivePublicThreadSession } = await loadPublicThreadCommandService(); await unarchivePublicThreadSession({ - caller, + caller: caller.viewer, database: c.env.DB, threadId, }); @@ -405,7 +431,7 @@ function registerAccessTokenOnlyRoutes(v1: Hono): void { const { deletePublicThreadSession } = await loadPublicThreadCommandService(); await deletePublicThreadSession({ bindings: c.env, - caller, + caller: caller.viewer, threadId, }); return { ok: true }; @@ -414,167 +440,21 @@ function registerAccessTokenOnlyRoutes(v1: Hono): void { }); }); + v1.get("/threads/:threadId/files", async (c) => + runPublicThreadFileRoute(c, async ({ caller, service, threadId }) => + service.listPublicThreadFiles(c.env, caller.viewer, threadId), + ), + ); + v1.delete("/threads/:threadId/files/:fileId", async (c) => - runPublicThreadFileRoute(c, ACCESS_TOKEN_SCOPE, async ({ caller, service, threadId }) => { - await service.deletePublicThreadFile(c.env, caller, { + runPublicThreadFileRoute(c, async ({ caller, service, threadId }) => { + await service.deletePublicThreadFile(c.env, caller.viewer, { fileId: parseFileIdParam(c.req.param("fileId")), threadId, }); return { ok: true }; }), ); -} - -export function registerPublicApiRoute(app: Hono) { - const v1 = new Hono(); - - v1.get("/openapi.json", (c) => c.json(createPublicApiOpenApiDocument(new URL(c.req.url).origin))); - - v1.get("/harnesses", (c) => c.json({ harnesses: listHarnessCatalog() })); - - v1.get("/harnesses/:slug", (c) => { - const harness = getHarnessCatalogEntry(c.req.param("slug")); - - return harness === null - ? c.json({ error: { code: "not_found", message: "Harness was not found." } }, 404) - : c.json({ harness }); - }); - - v1.post("/runs", (c) => - runWorkspaceApiAuthenticatedJson( - c, - async (caller) => { - const service = await loadWorkspaceRunService(); - return service.startWorkspaceRun({ - bindings: c.env, - caller, - executionContext: c.executionCtx, - input: await readCreateWorkspaceRunRequest(c), - requestUrl: c.req.url, - }); - }, - 201, - ), - ); - - v1.get("/runs/:runId", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - return service.retrieveWorkspaceRun(c.env.DB, caller, parseRunIdParam(c.req.param("runId"))); - }), - ); - - v1.get("/runs/:runId/events", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - return service.listWorkspaceRunEvents({ - caller, - database: c.env.DB, - limit: parseThreadEventsLimit(c.req.query("limit")), - runId: parseRunIdParam(c.req.param("runId")), - }); - }), - ); - - v1.get("/runs/:runId/events/stream", (c) => - runWorkspaceApiAuthenticatedResponse(c, async (caller) => { - const service = await loadWorkspaceRunService(); - const stream = await service.streamWorkspaceRunEvents({ - bindings: c.env, - caller, - limit: parseThreadEventsLimit(c.req.query("limit")), - runId: parseRunIdParam(c.req.param("runId")), - signal: c.req.raw.signal, - }); - - return new Response(stream, { - headers: { - "Cache-Control": "no-store", - "Content-Type": "text/event-stream; charset=utf-8", - "X-Accel-Buffering": "no", - }, - }); - }), - ); - - v1.get("/runs/:runId/result", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - return service.retrieveWorkspaceRunResult({ - caller, - database: c.env.DB, - runId: parseRunIdParam(c.req.param("runId")), - }); - }), - ); - - v1.get("/runs/:runId/artifacts", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - return service.listWorkspaceRunArtifacts({ - bindings: c.env, - caller, - runId: parseRunIdParam(c.req.param("runId")), - }); - }), - ); - - v1.post("/runs/:runId/approve", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - const approval = await readWorkspaceRunApprovalRequest(c); - return service.approveWorkspaceRun({ - bindings: c.env, - caller, - decision: approval.decision, - executionContext: c.executionCtx, - requestId: approval.requestId, - requestUrl: c.req.url, - runId: parseRunIdParam(c.req.param("runId")), - }); - }), - ); - - v1.post("/runs/:runId/cancel", (c) => - runWorkspaceApiAuthenticatedJson(c, async (caller) => { - const service = await loadWorkspaceRunService(); - return service.cancelWorkspaceRun({ - bindings: c.env, - caller, - runId: parseRunIdParam(c.req.param("runId")), - }); - }), - ); - - // The blocking bound-agent ask: POST the injected capability URL itself. - v1.post(BOUND_CAPABILITY_ROUTE_BASE, async (c) => { - try { - const body = await readBoundAgentCallRequestBody(c); - const { createBoundAgentThreadAndWait } = await loadBoundAgentAskService(); - const result = await createBoundAgentThreadAndWait({ - bindings: c.env, - executionContext: c.executionCtx, - idempotencyKey: readPublicApiIdempotencyKey(c.req.raw), - input: parseBoundAgentCallBody(body), - requestUrl: c.req.url, - token: c.req.param("token") ?? "", - }); - - return Response.json(result, { status: 200 }); - } catch (error) { - const rendered = renderBoundAgentCallError(error); - return Response.json(rendered.body, { status: rendered.status }); - } - }); - - // The Public Thread and file workflow, addressed by the same capability URL: - // upload attachments, create and continue Threads, observe Runs, and download - // artifacts — all scoped to the App, Agent binding, and Deployment that - // minted the capability, without any owner Access Token. - registerPublicThreadRoutes(v1, BOUND_CAPABILITY_SCOPE); - - registerPublicThreadRoutes(v1, ACCESS_TOKEN_SCOPE); - registerAccessTokenOnlyRoutes(v1); app.route(PUBLIC_API_VERSION_PREFIX, v1); } diff --git a/apps/api/src/adapters/http/routes/slack-events-route.ts b/apps/api/src/adapters/http/routes/slack-events-route.ts deleted file mode 100644 index 70bb33e0..00000000 --- a/apps/api/src/adapters/http/routes/slack-events-route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS } from "@mosoo/contracts/channel"; -import type { Hono } from "hono"; - -import { enqueueChannelWorkTriggerCommand } from "../../../modules/api-command/application/api-command-enqueue"; -import { resolveSlackChannelBindingContext } from "../../../modules/channels/application/slack-channel-session.service"; -import { - normalizeSlackWorkTrigger, - parseSlackEventsEnvelope, -} from "../../../modules/channels/slack/slack-events"; -import { SLACK_FIRST_PARTY_ADAPTER_MANIFEST } from "../../../modules/channels/slack/slack-first-party-adapter"; -import { verifySlackSignature } from "../../../modules/channels/slack/slack-signing"; -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; - -function slackJson(body: Record, status = 200): Response { - return Response.json(body, { status }); -} - -function getSlackTenantId(trigger: { enterpriseId: string | null; teamId: string | null }) { - return trigger.teamId ?? trigger.enterpriseId; -} - -export function registerSlackEventsRoute(app: Hono) { - app.post(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS.slack, async (c) => { - const rawBody = await c.req.raw.clone().text(); - const parsed = parseSlackEventsEnvelope(rawBody); - - if (!parsed.ok) { - if (parsed.code === "missing_event_id" || parsed.code === "unsupported_type") { - logInfo( - parsed.code === "missing_event_id" - ? "slack-channel-events.missing_event_id" - : "slack-channel-events.unsupported_event_ignored", - { code: parsed.code }, - ); - return slackJson({ ignored: true, ok: true }); - } - - return slackJson({ error: parsed.message, ok: false }, 400); - } - - if (parsed.envelope.type === "url_verification") { - return c.text(parsed.envelope.challenge); - } - - const trigger = normalizeSlackWorkTrigger(parsed.envelope); - - if (!trigger) { - return slackJson({ ignored: true, ok: true }); - } - - if (!trigger.botUserId) { - logInfo("slack-channel-events.missing_bot_user_id", { - eventId: trigger.eventId, - teamId: trigger.teamId, - }); - return slackJson({ ignored: true, ok: true }); - } - - const tenantId = getSlackTenantId(trigger); - - if (!tenantId) { - logInfo("slack-channel-events.missing_tenant_id", { - botUserId: trigger.botUserId, - eventId: trigger.eventId, - }); - return slackJson({ ignored: true, ok: true }); - } - - const binding = await resolveSlackChannelBindingContext(c.env, { - externalBotId: trigger.botUserId, - externalTenantId: tenantId, - }); - - if (!binding) { - logInfo("slack-channel-events.binding_not_found", { - botUserId: trigger.botUserId, - eventId: trigger.eventId, - teamId: trigger.teamId, - }); - return slackJson({ ignored: true, ok: true }); - } - - const signature = await verifySlackSignature({ - body: rawBody, - headers: c.req.raw.headers, - signingSecret: binding.credentials.signingSecret, - }); - - if (!signature.ok) { - return slackJson( - { - code: signature.code, - error: signature.message, - ok: false, - }, - signature.status, - ); - } - - if (binding.agentStatus !== "published") { - logInfo("slack-channel-events.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: trigger.eventId, - teamId: trigger.teamId, - }); - return slackJson({ ignored: true, ok: true }); - } - - await enqueueChannelWorkTriggerCommand(c.env, { - bindingId: binding.bindingId, - provider: "slack", - requestUrl: c.req.url, - trigger, - }); - - return slackJson({ - accepted: true, - adapter: SLACK_FIRST_PARTY_ADAPTER_MANIFEST.id, - ok: true, - }); - }); -} diff --git a/apps/api/src/adapters/http/routes/telegram-events-route.ts b/apps/api/src/adapters/http/routes/telegram-events-route.ts deleted file mode 100644 index 85a2f9f4..00000000 --- a/apps/api/src/adapters/http/routes/telegram-events-route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS } from "@mosoo/contracts/channel"; -import type { ChannelBindingId } from "@mosoo/id"; -import type { Hono } from "hono"; - -import { enqueueChannelWorkTriggerCommand } from "../../../modules/api-command/application/api-command-enqueue"; -import { resolveAgentChannelBindingContextById } from "../../../modules/channels/application/channel-binding-context"; -import { parseTelegramCredentials } from "../../../modules/channels/telegram/telegram-credentials"; -import { - normalizeTelegramWorkTrigger, - parseTelegramUpdateEnvelope, -} from "../../../modules/channels/telegram/telegram-events"; -import { TELEGRAM_FIRST_PARTY_ADAPTER_MANIFEST } from "../../../modules/channels/telegram/telegram-first-party-adapter"; -import { verifyTelegramWebhookSecret } from "../../../modules/channels/telegram/telegram-signing"; -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; -import { toPlatformId } from "../../../shared/platform-id"; -import { platformIdRouteErrorResponse } from "./platform-id-route-error"; - -function telegramJson(body: Record, status = 200): Response { - return Response.json(body, { status }); -} - -export function registerTelegramEventsRoute(app: Hono) { - app.post(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS.telegram, async (c) => { - let bindingId: ChannelBindingId; - - try { - bindingId = toPlatformId(c.req.param("bindingId"), "Channel binding ID"); - } catch (error) { - const response = platformIdRouteErrorResponse(error, (message) => ({ - code: "invalid_request", - error: message, - ok: false, - })); - if (response !== null) { - return response; - } - throw error; - } - - const binding = await resolveAgentChannelBindingContextById(c.env, { - bindingId, - provider: "telegram", - }); - - if (!binding) { - logInfo("telegram-channel-events.binding_not_found", { bindingId }); - return telegramJson({ ignored: true, ok: true }); - } - - const credentials = parseTelegramCredentials(binding.credentialsJson); - const secret = verifyTelegramWebhookSecret({ - headers: c.req.raw.headers, - webhookSecret: credentials.webhookSecret, - }); - - if (!secret.ok) { - return telegramJson( - { - code: secret.code, - error: secret.message, - ok: false, - }, - secret.status, - ); - } - - const rawBody = await c.req.raw.clone().text(); - const parsed = parseTelegramUpdateEnvelope(rawBody); - - if (!parsed.ok) { - if (parsed.code === "missing_message") { - logInfo("telegram-channel-events.unsupported_update_ignored", { - bindingId: binding.bindingId, - code: parsed.code, - }); - return telegramJson({ ignored: true, ok: true }); - } - - return telegramJson({ error: parsed.message, ok: false }, 400); - } - - const trigger = normalizeTelegramWorkTrigger(parsed.envelope); - - if (!trigger) { - return telegramJson({ ignored: true, ok: true }); - } - - if (binding.agentStatus !== "published") { - logInfo("telegram-channel-events.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: trigger.eventId, - }); - return telegramJson({ ignored: true, ok: true }); - } - - await enqueueChannelWorkTriggerCommand(c.env, { - bindingId: binding.bindingId, - provider: "telegram", - requestUrl: c.req.url, - trigger, - }); - - return telegramJson({ - accepted: true, - adapter: TELEGRAM_FIRST_PARTY_ADAPTER_MANIFEST.id, - ok: true, - }); - }); -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e221d7fb..7eda5a82 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,8 @@ import { arktypeWorkerConfigInitialized } from "./platform/arktype-worker-config"; import { createApiWorker } from "./platform/cloudflare/create-api-worker"; -export { ChannelConnection } from "./adapters/durable-objects/channel-connection.do"; export { ContainerProxy } from "./adapters/durable-objects/sandbox-container-proxy"; export { DriverConnection } from "./adapters/durable-objects/driver-connection.do"; +export { ChannelConnection } from "./adapters/durable-objects/retired-channel-connection.do"; export { Sandbox } from "./adapters/durable-objects/sandbox.do"; export { Session } from "./adapters/durable-objects/session.do"; diff --git a/apps/api/src/modules/agents/application/agent-command.service.ts b/apps/api/src/modules/agents/application/agent-command.service.ts index ff81a35d..ee58a3d1 100644 --- a/apps/api/src/modules/agents/application/agent-command.service.ts +++ b/apps/api/src/modules/agents/application/agent-command.service.ts @@ -115,7 +115,10 @@ export async function createAgent( description: input.description ?? null, environmentId, id: agentId, - kind: input.kind, + // The Harness product has one isolation policy: every saved Agent is a + // reusable Cattle Run configuration. Keep the legacy input field only as + // a compatibility boundary while refusing to create Pet state. + kind: "cattle", model: input.model, name: input.name, ownerId: viewer.id, @@ -136,7 +139,7 @@ export async function createAgent( properties: { agent_id: agentId, app_id: appId, - agent_kind: input.kind, + agent_kind: "cattle", provider: input.provider, runtime_id: runtimeId, }, diff --git a/apps/api/src/modules/agents/graphql/agent-graphql.ts b/apps/api/src/modules/agents/graphql/agent-graphql.ts index acadadb2..bda01239 100644 --- a/apps/api/src/modules/agents/graphql/agent-graphql.ts +++ b/apps/api/src/modules/agents/graphql/agent-graphql.ts @@ -3,11 +3,6 @@ import type { AgentId, AppId } from "@mosoo/id"; import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; import { agentGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { - recreateSandbox, - resetAgentState, - restartDriver, -} from "../../runtime/application/runtime-state-operations.service"; import { createAgent, deleteAgent, @@ -50,10 +45,6 @@ interface PublishAgentArgs { input: Parameters[2]; } -interface RuntimeStateOperationArgs { - input: Parameters[2]; -} - interface UpdateAgentConfigArgs { input: Parameters[2]; } @@ -85,12 +76,6 @@ export const agentGraphQLModule = { importAgentPackage(context.bindings, context.viewer, args.input), publishAgent: async (_parent, args: PublishAgentArgs, context) => publishAgent(context.bindings, context.viewer, args.input), - recreateSandbox: async (_parent, args: RuntimeStateOperationArgs, context) => - recreateSandbox(context.bindings, context.viewer, args.input), - resetAgentState: async (_parent, args: RuntimeStateOperationArgs, context) => - resetAgentState(context.bindings, context.viewer, args.input), - restartDriver: async (_parent, args: RuntimeStateOperationArgs, context) => - restartDriver(context.bindings, context.viewer, args.input), unpublishAgent: async (_parent, args: AppAgentIdArgs, context) => unpublishAgent(context.bindings.DB, context.viewer, { agentId: parseAgentId(args.agentId), diff --git a/apps/api/src/modules/api-command/application/api-command-enqueue.ts b/apps/api/src/modules/api-command/application/api-command-enqueue.ts index 3adc5ae4..802f6456 100644 --- a/apps/api/src/modules/api-command/application/api-command-enqueue.ts +++ b/apps/api/src/modules/api-command/application/api-command-enqueue.ts @@ -1,48 +1,12 @@ -import type { AppDeploymentRunId } from "@mosoo/id"; - import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; import { admitApiCommand, enqueueApiCommand } from "./api-command-ledger"; import type { ApiCommandAdmission, EnqueueApiCommandInput } from "./api-command-ledger"; import type { - AppDeploymentRunDispatchCommandPayload, - ChannelWorkTriggerCommandPayload, CostLedgerReconciliationCommandPayload, ScheduledMaintenanceCommandPayload, SessionRunDispatchCommandPayload, } from "./api-command-payload"; -export const APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX = "app_deployment_run_dispatch:" as const; - -export function createAppDeploymentRunDispatchDedupeKey(runId: AppDeploymentRunId): string { - return `${APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX}${runId}`; -} - -export async function enqueueAppDeploymentRunDispatchCommand( - bindings: Pick, - payload: AppDeploymentRunDispatchCommandPayload, -): Promise { - await enqueueApiCommand(bindings, { - dedupeKey: createAppDeploymentRunDispatchDedupeKey(payload.appDeploymentRunId), - kind: "app_deployment_run_dispatch", - payload, - }); -} - -function createChannelWorkTriggerDedupeKey(input: ChannelWorkTriggerCommandPayload): string { - return `channel_work_trigger:${input.provider}:${input.bindingId}:${input.trigger.eventId}`; -} - -export async function enqueueChannelWorkTriggerCommand( - bindings: Pick, - payload: ChannelWorkTriggerCommandPayload, -): Promise { - await enqueueApiCommand(bindings, { - dedupeKey: createChannelWorkTriggerDedupeKey(payload), - kind: "channel_work_trigger", - payload, - }); -} - export async function enqueueCostLedgerReconciliationCommand( bindings: Pick, payload: CostLedgerReconciliationCommandPayload, diff --git a/apps/api/src/modules/api-command/application/api-command-payload.ts b/apps/api/src/modules/api-command/application/api-command-payload.ts index e202161e..f627dea6 100644 --- a/apps/api/src/modules/api-command/application/api-command-payload.ts +++ b/apps/api/src/modules/api-command/application/api-command-payload.ts @@ -1,28 +1,14 @@ import type { ApiCommandKind } from "@mosoo/db"; import { parsePlatformId } from "@mosoo/id"; -import type { - AccountId, - AppDeploymentRunId, - ChannelBindingId, - FileId, - AppId, - SessionId, - SessionRunId, -} from "@mosoo/id"; +import type { AccountId, AppId, FileId, SessionId, SessionRunId } from "@mosoo/id"; import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import type { DiscordWorkTrigger } from "../../channels/discord/discord-events"; -import type { LarkWorkTrigger } from "../../channels/lark/lark-events"; -import type { SlackWorkTrigger } from "../../channels/slack/slack-events"; -import type { TelegramWorkTrigger } from "../../channels/telegram/telegram-events"; import type { CostLedgerReconciliationCursor, CostLedgerReconciliationMode, } from "../../cost/application/cost-ledger-reconciliation.service"; type ApiCommandPayload = - | AppDeploymentRunDispatchCommandPayload - | ChannelWorkTriggerCommandPayload | CostLedgerReconciliationCommandPayload | EnvironmentPackageArtifactBuildCommandPayload | ScheduledMaintenanceCommandPayload @@ -30,34 +16,6 @@ type ApiCommandPayload = type JsonRecord = Record; -export type ChannelWorkTriggerProvider = "discord" | "lark" | "slack" | "telegram"; - -export type ChannelWorkTriggerCommandPayload = - | { - bindingId: ChannelBindingId; - provider: "discord"; - requestUrl: string; - trigger: DiscordWorkTrigger; - } - | { - bindingId: ChannelBindingId; - provider: "lark"; - requestUrl: string; - trigger: LarkWorkTrigger; - } - | { - bindingId: ChannelBindingId; - provider: "slack"; - requestUrl: string; - trigger: SlackWorkTrigger; - } - | { - bindingId: ChannelBindingId; - provider: "telegram"; - requestUrl: string; - trigger: TelegramWorkTrigger; - }; - export interface ScheduledMaintenanceCommandPayload { scheduledTime: number; } @@ -68,10 +26,6 @@ export interface CostLedgerReconciliationCommandPayload { scheduledTime: number; } -export interface AppDeploymentRunDispatchCommandPayload { - appDeploymentRunId: AppDeploymentRunId; -} - export interface EnvironmentPackageArtifactBuildCommandPayload { appId: AppId; artifactAbi: string; @@ -167,20 +121,6 @@ function readInteger(record: JsonRecord, field: string, label: string): number { return value; } -function readOptionalInteger(record: JsonRecord, field: string, label: string): number | null { - const value = record[field]; - - if (value === null || value === undefined) { - return null; - } - - if (typeof value !== "number" || !Number.isSafeInteger(value)) { - throw new ApiCommandPayloadError(`${label}.${field} must be an integer or null.`); - } - - return value; -} - function readStringArray(record: JsonRecord, field: string, label: string): string[] { const value = record[field]; @@ -239,214 +179,6 @@ function parseSessionRunDispatchPayload(value: unknown): SessionRunDispatchComma }; } -function readSlackTrigger(value: unknown): SlackWorkTrigger { - const record = requireRecord(value, "channel_work_trigger payload.trigger"); - const triggerType = readNonEmptyString( - record, - "triggerType", - "channel_work_trigger payload.trigger", - ); - - if ( - triggerType !== "app_mention" && - triggerType !== "channel_thread_message" && - triggerType !== "dm_message" - ) { - throw new ApiCommandPayloadError( - "channel_work_trigger payload.trigger.triggerType is invalid.", - ); - } - - return { - botUserId: readOptionalString(record, "botUserId", "channel_work_trigger payload.trigger"), - channelId: readNonEmptyString(record, "channelId", "channel_work_trigger payload.trigger"), - enterpriseId: readOptionalString( - record, - "enterpriseId", - "channel_work_trigger payload.trigger", - ), - eventId: readNonEmptyString(record, "eventId", "channel_work_trigger payload.trigger"), - isEnterpriseInstall: readBoolean( - record, - "isEnterpriseInstall", - "channel_work_trigger payload.trigger", - ), - messageTs: readNonEmptyString(record, "messageTs", "channel_work_trigger payload.trigger"), - requiresExistingSession: readBoolean( - record, - "requiresExistingSession", - "channel_work_trigger payload.trigger", - ), - teamId: readOptionalString(record, "teamId", "channel_work_trigger payload.trigger"), - text: readString(record, "text", "channel_work_trigger payload.trigger"), - threadTs: readNonEmptyString(record, "threadTs", "channel_work_trigger payload.trigger"), - triggerType, - userId: readNonEmptyString(record, "userId", "channel_work_trigger payload.trigger"), - }; -} - -function readTelegramTrigger(value: unknown): TelegramWorkTrigger { - const record = requireRecord(value, "channel_work_trigger payload.trigger"); - - return { - chatId: readNonEmptyString(record, "chatId", "channel_work_trigger payload.trigger"), - chatTitle: readOptionalString(record, "chatTitle", "channel_work_trigger payload.trigger"), - chatType: readOptionalString(record, "chatType", "channel_work_trigger payload.trigger"), - eventId: readNonEmptyString(record, "eventId", "channel_work_trigger payload.trigger"), - externalActorId: readNonEmptyString( - record, - "externalActorId", - "channel_work_trigger payload.trigger", - ), - externalMessageId: readNonEmptyString( - record, - "externalMessageId", - "channel_work_trigger payload.trigger", - ), - externalThreadId: readNonEmptyString( - record, - "externalThreadId", - "channel_work_trigger payload.trigger", - ), - messageId: readInteger(record, "messageId", "channel_work_trigger payload.trigger"), - messageThreadId: readOptionalInteger( - record, - "messageThreadId", - "channel_work_trigger payload.trigger", - ), - text: readNonEmptyString(record, "text", "channel_work_trigger payload.trigger"), - userDisplayName: readOptionalString( - record, - "userDisplayName", - "channel_work_trigger payload.trigger", - ), - userId: readOptionalString(record, "userId", "channel_work_trigger payload.trigger"), - username: readOptionalString(record, "username", "channel_work_trigger payload.trigger"), - }; -} - -function readDiscordTrigger(value: unknown): DiscordWorkTrigger { - const record = requireRecord(value, "channel_work_trigger payload.trigger"); - - return { - authorDisplayName: readOptionalString( - record, - "authorDisplayName", - "channel_work_trigger payload.trigger", - ), - authorId: readNonEmptyString(record, "authorId", "channel_work_trigger payload.trigger"), - channelId: readNonEmptyString(record, "channelId", "channel_work_trigger payload.trigger"), - channelType: readOptionalInteger(record, "channelType", "channel_work_trigger payload.trigger"), - eventId: readNonEmptyString(record, "eventId", "channel_work_trigger payload.trigger"), - externalActorId: readNonEmptyString( - record, - "externalActorId", - "channel_work_trigger payload.trigger", - ), - externalMessageId: readNonEmptyString( - record, - "externalMessageId", - "channel_work_trigger payload.trigger", - ), - externalThreadId: readNonEmptyString( - record, - "externalThreadId", - "channel_work_trigger payload.trigger", - ), - guildId: readOptionalString(record, "guildId", "channel_work_trigger payload.trigger"), - messageId: readNonEmptyString(record, "messageId", "channel_work_trigger payload.trigger"), - text: readNonEmptyString(record, "text", "channel_work_trigger payload.trigger"), - }; -} - -function readLarkTrigger(value: unknown): LarkWorkTrigger { - const record = requireRecord(value, "channel_work_trigger payload.trigger"); - - return { - chatId: readNonEmptyString(record, "chatId", "channel_work_trigger payload.trigger"), - chatType: readOptionalString(record, "chatType", "channel_work_trigger payload.trigger"), - eventId: readNonEmptyString(record, "eventId", "channel_work_trigger payload.trigger"), - externalActorId: readNonEmptyString( - record, - "externalActorId", - "channel_work_trigger payload.trigger", - ), - externalMessageId: readNonEmptyString( - record, - "externalMessageId", - "channel_work_trigger payload.trigger", - ), - externalThreadId: readNonEmptyString( - record, - "externalThreadId", - "channel_work_trigger payload.trigger", - ), - messageId: readNonEmptyString(record, "messageId", "channel_work_trigger payload.trigger"), - parentId: readOptionalString(record, "parentId", "channel_work_trigger payload.trigger"), - rootId: readOptionalString(record, "rootId", "channel_work_trigger payload.trigger"), - senderOpenId: readNonEmptyString( - record, - "senderOpenId", - "channel_work_trigger payload.trigger", - ), - senderType: readNonEmptyString(record, "senderType", "channel_work_trigger payload.trigger"), - senderUnionId: readOptionalString( - record, - "senderUnionId", - "channel_work_trigger payload.trigger", - ), - senderUserId: readOptionalString( - record, - "senderUserId", - "channel_work_trigger payload.trigger", - ), - tenantKey: readNonEmptyString(record, "tenantKey", "channel_work_trigger payload.trigger"), - text: readString(record, "text", "channel_work_trigger payload.trigger"), - }; -} - -function readProvider(record: JsonRecord): ChannelWorkTriggerProvider { - const provider = readNonEmptyString(record, "provider", "channel_work_trigger payload"); - - if ( - provider !== "discord" && - provider !== "lark" && - provider !== "slack" && - provider !== "telegram" - ) { - throw new ApiCommandPayloadError("channel_work_trigger payload.provider is invalid."); - } - - return provider; -} - -function parseChannelWorkTriggerPayload(value: unknown): ChannelWorkTriggerCommandPayload { - const record = requireRecord(value, "channel_work_trigger payload"); - const provider = readProvider(record); - const base = { - bindingId: parsePlatformId( - record["bindingId"], - "channel_work_trigger payload.bindingId", - ), - requestUrl: readNonEmptyString(record, "requestUrl", "channel_work_trigger payload"), - }; - - switch (provider) { - case "discord": { - return { ...base, provider, trigger: readDiscordTrigger(record["trigger"]) }; - } - case "lark": { - return { ...base, provider, trigger: readLarkTrigger(record["trigger"]) }; - } - case "slack": { - return { ...base, provider, trigger: readSlackTrigger(record["trigger"]) }; - } - case "telegram": { - return { ...base, provider, trigger: readTelegramTrigger(record["trigger"]) }; - } - } -} - function parseScheduledMaintenancePayload(value: unknown): ScheduledMaintenanceCommandPayload { const record = requireRecord(value, "scheduled_maintenance payload"); @@ -477,24 +209,7 @@ function parseCostLedgerReconciliationPayload( throw new ApiCommandPayloadError(`${label}.scheduledTime must be a valid timestamp.`); } - return { - cursor, - mode, - scheduledTime, - }; -} - -function parseAppDeploymentRunDispatchPayload( - value: unknown, -): AppDeploymentRunDispatchCommandPayload { - const record = requireRecord(value, "app_deployment_run_dispatch payload"); - - return { - appDeploymentRunId: parsePlatformId( - record["appDeploymentRunId"], - "app_deployment_run_dispatch payload.appDeploymentRunId", - ), - }; + return { cursor, mode, scheduledTime }; } function parseEnvironmentPackageArtifactBuildPayload( @@ -502,18 +217,21 @@ function parseEnvironmentPackageArtifactBuildPayload( ): EnvironmentPackageArtifactBuildCommandPayload { const label = "environment_package_artifact_build payload"; const record = requireRecord(value, label); - const inputDigest = readNonEmptyString(record, "inputDigest", label); const packageEntries = record["packages"]; + if (!Array.isArray(packageEntries)) { throw new ApiCommandPayloadError(`${label}.packages must be an array.`); } + const packages: EnvironmentPackageArtifactBuildCommandPayload["packages"] = packageEntries.map( (entry, index) => { const packageRecord = requireRecord(entry, `${label}.packages[${index}]`); const manager = readNonEmptyString(packageRecord, "manager", `${label}.packages[${index}]`); + if (manager !== "npm" && manager !== "pip") { throw new ApiCommandPayloadError(`${label}.packages[${index}].manager is unsupported.`); } + return { manager, packages: readStringArray(packageRecord, "packages", `${label}.packages[${index}]`), @@ -524,7 +242,7 @@ function parseEnvironmentPackageArtifactBuildPayload( return { appId: parsePlatformId(record["appId"], `${label}.appId`), artifactAbi: readNonEmptyString(record, "artifactAbi", label), - inputDigest, + inputDigest: readNonEmptyString(record, "inputDigest", label), packages, }; } @@ -544,23 +262,13 @@ export function parseApiCommandPayload( const parsed = parsePayloadJson(payloadJson); switch (kind) { - case "app_deployment_run_dispatch": { - return parseAppDeploymentRunDispatchPayload(parsed); - } - case "channel_work_trigger": { - return parseChannelWorkTriggerPayload(parsed); - } - case "cost_ledger_reconciliation": { + case "cost_ledger_reconciliation": return parseCostLedgerReconciliationPayload(parsed); - } - case "environment_package_artifact_build": { + case "environment_package_artifact_build": return parseEnvironmentPackageArtifactBuildPayload(parsed); - } - case "scheduled_maintenance": { + case "scheduled_maintenance": return parseScheduledMaintenancePayload(parsed); - } - case "session_run_dispatch": { + case "session_run_dispatch": return parseSessionRunDispatchPayload(parsed); - } } } diff --git a/apps/api/src/modules/api-command/application/api-command-policy.ts b/apps/api/src/modules/api-command/application/api-command-policy.ts deleted file mode 100644 index 29ddd098..00000000 --- a/apps/api/src/modules/api-command/application/api-command-policy.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS = 3; - -export const APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE = - "deployment_dispatch_retry_exhausted"; - -export function createAppDeploymentDispatchRetryExhaustedMessage(input: { - attemptCount: number; - lastErrorMessage: string | null; -}): string { - const detail = input.lastErrorMessage?.trim(); - - if (detail !== undefined && detail.length > 0) { - return `Deployment dispatch failed after ${input.attemptCount} attempts: ${detail}`; - } - - return `Deployment dispatch failed after ${input.attemptCount} attempts.`; -} diff --git a/apps/api/src/modules/api-command/application/api-command-processor.ts b/apps/api/src/modules/api-command/application/api-command-processor.ts index cb9d954b..bb6cdd48 100644 --- a/apps/api/src/modules/api-command/application/api-command-processor.ts +++ b/apps/api/src/modules/api-command/application/api-command-processor.ts @@ -1,34 +1,11 @@ -import { apiCommandsTable, appDeploymentRunsTable } from "@mosoo/db"; +import { apiCommandsTable } from "@mosoo/db"; import type { ApiCommandId } from "@mosoo/db"; -import type { AppDeploymentRunId } from "@mosoo/id"; -import { parsePlatformId } from "@mosoo/id"; -import { and, eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../../platform/db/drizzle"; import { currentTimestampMs } from "../../../time"; -import { dispatchAppDeploymentRun } from "../../apps/application/app-deployment-executor.service"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../../apps/domain/app-deployment-lifecycle"; -import { cleanupOrphanChannelBindingCredentialSecrets } from "../../channels/application/agent-channel-binding-maintenance.service"; -import { resolveAgentChannelBindingContextById } from "../../channels/application/channel-binding-context"; -import { createChannelFinalDeliveryScheduler } from "../../channels/application/channel-final-delivery.service"; -import { createChannelSessionClient } from "../../channels/application/channel-session-command-client"; -import { runDiscordGatewayConnectionMaintenance } from "../../channels/application/discord-gateway-connection-maintenance.service"; -import { runLarkLongConnectionMaintenance } from "../../channels/application/lark-long-connection-maintenance.service"; -import { - createSlackAdapterConfig, - createSlackChannelSessionClient, - resolveSlackChannelBindingContextById, -} from "../../channels/application/slack-channel-session.service"; -import { runWeChatPollingOwnerMaintenance } from "../../channels/application/wechat-polling-owner-maintenance.service"; -import { parseDiscordCredentials } from "../../channels/discord/discord-credentials"; -import { processDiscordWorkTrigger } from "../../channels/discord/discord-first-party-adapter"; -import { parseLarkCredentials } from "../../channels/lark/lark-credentials"; -import { processLarkWorkTrigger } from "../../channels/lark/lark-first-party-adapter"; -import { processSlackWorkTrigger } from "../../channels/slack/slack-first-party-adapter"; -import { parseTelegramCredentials } from "../../channels/telegram/telegram-credentials"; -import { processTelegramWorkTrigger } from "../../channels/telegram/telegram-first-party-adapter"; import { parseCostLedgerReconciliationActivationMode, reconcileCostLedgerPage, @@ -37,10 +14,7 @@ import { runUsageDailyRollup } from "../../cost/application/cost-rollup.service" import { buildEnvironmentPackageArtifact } from "../../environments/application/environment-package-artifact-build.service"; import { dispatchQueuedSessionRun } from "../../runtime/application/session-runs/dispatch-queued-run.service"; import { runSandboxMaintenance } from "../../runtime/infrastructure/runtime-subject-lifecycle/runtime-subject-maintenance.service"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX, - enqueueCostLedgerReconciliationCommand, -} from "./api-command-enqueue"; +import { enqueueCostLedgerReconciliationCommand } from "./api-command-enqueue"; import { API_COMMAND_LEASE_RENEWAL_INTERVAL_MS, claimApiCommand, @@ -55,18 +29,11 @@ import { parseApiCommandMessage } from "./api-command-message"; import type { ApiCommandMessage } from "./api-command-message"; import { ApiCommandPayloadError, parseApiCommandPayload } from "./api-command-payload"; import type { - AppDeploymentRunDispatchCommandPayload, - ChannelWorkTriggerCommandPayload, CostLedgerReconciliationCommandPayload, EnvironmentPackageArtifactBuildCommandPayload, ScheduledMaintenanceCommandPayload, SessionRunDispatchCommandPayload, } from "./api-command-payload"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - createAppDeploymentDispatchRetryExhaustedMessage, -} from "./api-command-policy"; const API_COMMAND_RETRY_DELAY_SECONDS = 30; @@ -104,13 +71,7 @@ async function processScheduledMaintenanceCommand( payload: ScheduledMaintenanceCommandPayload, ): Promise { const scheduledAt = new Date(payload.scheduledTime); - const tasks: Promise[] = [ - runSandboxMaintenance(bindings), - runDiscordGatewayConnectionMaintenance(bindings, scheduledAt), - cleanupOrphanChannelBindingCredentialSecrets(bindings, scheduledAt), - runLarkLongConnectionMaintenance(bindings, scheduledAt), - runWeChatPollingOwnerMaintenance(bindings, scheduledAt, { executionContext: null }), - ]; + const tasks: Promise[] = [runSandboxMaintenance(bindings)]; if (shouldRunUsageDailyRollup(scheduledAt)) { tasks.push(runUsageDailyRollup(bindings, scheduledAt)); @@ -167,221 +128,6 @@ async function processCostLedgerReconciliationCommand( }); } -async function processSlackChannelWorkTrigger( - bindings: ApiBindings, - payload: Extract, -): Promise { - const binding = await resolveSlackChannelBindingContextById(bindings, { - bindingId: payload.bindingId, - }); - - if (!binding) { - logInfo("api-command.channel_work_trigger.binding_not_found", { - bindingId: payload.bindingId, - provider: payload.provider, - }); - return; - } - - if (binding.agentStatus !== "published") { - logInfo("api-command.channel_work_trigger.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: payload.trigger.eventId, - provider: payload.provider, - }); - return; - } - - await processSlackWorkTrigger({ - config: createSlackAdapterConfig({ - binding, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }), - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(bindings), - sessionClient: createSlackChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: payload.requestUrl, - }), - trigger: payload.trigger, - }); -} - -async function processTelegramChannelWorkTrigger( - bindings: ApiBindings, - payload: Extract, -): Promise { - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: payload.bindingId, - provider: payload.provider, - }); - - if (!binding) { - logInfo("api-command.channel_work_trigger.binding_not_found", { - bindingId: payload.bindingId, - provider: payload.provider, - }); - return; - } - - if (binding.agentStatus !== "published") { - logInfo("api-command.channel_work_trigger.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: payload.trigger.eventId, - provider: payload.provider, - }); - return; - } - - const credentials = parseTelegramCredentials(binding.credentialsJson); - - await processTelegramWorkTrigger({ - config: { - agentId: binding.agentId, - bindingId: binding.bindingId, - botToken: credentials.botToken, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }, - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(bindings), - sessionClient: createChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: payload.requestUrl, - }), - trigger: payload.trigger, - }); -} - -async function processDiscordChannelWorkTrigger( - bindings: ApiBindings, - payload: Extract, -): Promise { - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: payload.bindingId, - provider: payload.provider, - }); - - if (!binding) { - logInfo("api-command.channel_work_trigger.binding_not_found", { - bindingId: payload.bindingId, - provider: payload.provider, - }); - return; - } - - if (binding.agentStatus !== "published") { - logInfo("api-command.channel_work_trigger.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: payload.trigger.eventId, - provider: payload.provider, - }); - return; - } - - const credentials = parseDiscordCredentials(binding.credentialsJson); - const result = await processDiscordWorkTrigger({ - config: { - agentId: binding.agentId, - bindingId: binding.bindingId, - botToken: credentials.botToken, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }, - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(bindings), - sessionClient: createChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: payload.requestUrl, - }), - trigger: payload.trigger, - }); - - if (!result.ok) { - const error = new Error("Discord work trigger processing failed."); - error.name = result.code; - throw error; - } -} - -async function processLarkChannelWorkTrigger( - bindings: ApiBindings, - payload: Extract, -): Promise { - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: payload.bindingId, - provider: payload.provider, - }); - - if (!binding) { - logInfo("api-command.channel_work_trigger.binding_not_found", { - bindingId: payload.bindingId, - provider: payload.provider, - }); - return; - } - - if (binding.agentStatus !== "published") { - logInfo("api-command.channel_work_trigger.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: payload.trigger.eventId, - provider: payload.provider, - }); - return; - } - - const credentials = parseLarkCredentials(binding.credentialsJson); - - await processLarkWorkTrigger({ - config: { - agentId: binding.agentId, - appId: credentials.appId, - appSecret: credentials.appSecret, - bindingId: binding.bindingId, - connectionMode: credentials.connectionMode, - domain: credentials.domain, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }, - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(bindings), - sessionClient: createChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: payload.requestUrl, - }), - trigger: payload.trigger, - }); -} - -async function processChannelWorkTriggerCommand( - bindings: ApiBindings, - payload: ChannelWorkTriggerCommandPayload, -): Promise { - switch (payload.provider) { - case "discord": { - await processDiscordChannelWorkTrigger(bindings, payload); - return; - } - case "lark": { - await processLarkChannelWorkTrigger(bindings, payload); - return; - } - case "slack": { - await processSlackChannelWorkTrigger(bindings, payload); - return; - } - case "telegram": { - await processTelegramChannelWorkTrigger(bindings, payload); - return; - } - } -} - async function processSessionRunDispatchCommand( bindings: ApiBindings, payload: SessionRunDispatchCommandPayload, @@ -403,99 +149,6 @@ async function processSessionRunDispatchCommand( }); } -async function failActiveAppDeploymentRun( - bindings: ApiBindings, - runId: AppDeploymentRunId, - input: { errorCode: string; errorMessage: string; nowMs: number }, -): Promise { - await getAppDatabase(bindings.DB) - .update(appDeploymentRunsTable) - .set({ - errorCode: input.errorCode, - errorMessage: input.errorMessage, - status: "failed", - updatedAt: input.nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -async function failAppDeploymentRunFromPayloadJson( - bindings: ApiBindings, - input: { - errorCode: string; - errorMessage: string; - fallbackDedupeKey?: string; - nowMs: number; - payloadJson: string; - }, - logEventName?: string, -): Promise { - try { - const runId = readAppDeploymentRunIdFromPayload(input); - - if (runId === null) { - return; - } - - await failActiveAppDeploymentRun(bindings, runId, input); - } catch (error) { - if (logEventName === undefined) { - throw error; - } - - logError(logEventName, { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - } -} - -function readAppDeploymentRunIdFromPayload(input: { - fallbackDedupeKey?: string; - payloadJson: string; -}): AppDeploymentRunId | null { - try { - return ( - parseApiCommandPayload( - "app_deployment_run_dispatch", - input.payloadJson, - ) as AppDeploymentRunDispatchCommandPayload - ).appDeploymentRunId; - } catch (error) { - logError("api-command.app_deployment_run_payload_invalid", { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - } - - if (input.fallbackDedupeKey === undefined) { - return null; - } - - if (!input.fallbackDedupeKey.startsWith(APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX)) { - return null; - } - - try { - return parsePlatformId( - input.fallbackDedupeKey.slice(APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX.length), - "app deployment run dispatch dedupe key", - ); - } catch (error) { - logError("api-command.app_deployment_run_dedupe_invalid", { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - return null; - } -} - async function processClaimedApiCommand( bindings: ApiBindings, claim: ApiCommandClaim, @@ -504,40 +157,27 @@ async function processClaimedApiCommand( const payload = parseApiCommandPayload(claim.kind, claim.payloadJson); switch (claim.kind) { - case "app_deployment_run_dispatch": { - await dispatchAppDeploymentRun(bindings, payload as AppDeploymentRunDispatchCommandPayload); - return; - } - case "channel_work_trigger": { - await processChannelWorkTriggerCommand(bindings, payload as ChannelWorkTriggerCommandPayload); - return; - } - case "cost_ledger_reconciliation": { + case "cost_ledger_reconciliation": await processCostLedgerReconciliationCommand( bindings, payload as CostLedgerReconciliationCommandPayload, processedAtMs, ); return; - } - case "environment_package_artifact_build": { + case "environment_package_artifact_build": await buildEnvironmentPackageArtifact( bindings, payload as EnvironmentPackageArtifactBuildCommandPayload, ); return; - } - case "scheduled_maintenance": { + case "scheduled_maintenance": await processScheduledMaintenanceCommand( bindings, payload as ScheduledMaintenanceCommandPayload, ); return; - } - case "session_run_dispatch": { + case "session_run_dispatch": await processSessionRunDispatchCommand(bindings, payload as SessionRunDispatchCommandPayload); - return; - } } } @@ -611,11 +251,10 @@ export async function processApiCommandMessage( } const ownerId = createClaimOwnerId(message); - const startMs = nowMs(); const claim = await claimApiCommand({ commandId, database: bindings.DB, - nowMs: startMs, + nowMs: nowMs(), ownerId, }); @@ -645,55 +284,6 @@ export async function processApiCommandMessage( kind: claim.kind, }); - const appDeploymentRunHasTerminalError = - claim.kind === "app_deployment_run_dispatch" && - (error instanceof ApiCommandPayloadError || - (error instanceof Error && - (error.name === "AppDeploymentDetectionError" || - error.name === "AppDeploymentNonRetryableError"))); - const appDeploymentRunRetryExhausted = - claim.kind === "app_deployment_run_dispatch" && - !appDeploymentRunHasTerminalError && - claim.attemptCount >= APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS; - const shouldFailAppDeploymentRun = - appDeploymentRunHasTerminalError || appDeploymentRunRetryExhausted; - - if (shouldFailAppDeploymentRun) { - const failedAtMs = nowMs(); - const failureCode = appDeploymentRunRetryExhausted - ? APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE - : errorCode; - const failureMessage = appDeploymentRunRetryExhausted - ? createAppDeploymentDispatchRetryExhaustedMessage({ - attemptCount: claim.attemptCount, - lastErrorMessage: errorMessage, - }) - : errorMessage; - - await failAppDeploymentRunFromPayloadJson( - bindings, - { - errorCode: failureCode, - errorMessage: failureMessage, - fallbackDedupeKey: claim.dedupeKey, - nowMs: failedAtMs, - payloadJson: claim.payloadJson, - }, - "api-command.app_deployment_run_fail_failed", - ); - - await markApiCommandFailed({ - commandId, - database: bindings.DB, - errorCode: failureCode, - errorMessage: failureMessage, - nowMs: failedAtMs, - ownerId, - }); - message.ack(); - return; - } - if (error instanceof ApiCommandPayloadError) { await markApiCommandFailed({ commandId, @@ -726,35 +316,17 @@ export async function processApiCommandDeadLetterMessage( ): Promise { try { const { commandId } = parseApiCommandMessage(message.body); - const deadLetteredAtMs = nowMs(); const command = (await getAppDatabase(bindings.DB) .select({ - dedupeKey: apiCommandsTable.dedupeKey, kind: apiCommandsTable.kind, lastErrorCode: apiCommandsTable.lastErrorCode, lastErrorMessage: apiCommandsTable.lastErrorMessage, - payloadJson: apiCommandsTable.payloadJson, }) .from(apiCommandsTable) .where(eq(apiCommandsTable.id, commandId)) .limit(1) .get()) ?? null; - - if (command?.kind === "app_deployment_run_dispatch") { - await failAppDeploymentRunFromPayloadJson( - bindings, - { - errorCode: "queue_dead_lettered", - errorMessage: "Deployment dispatch reached the queue dead-letter consumer.", - fallbackDedupeKey: command.dedupeKey, - nowMs: deadLetteredAtMs, - payloadJson: command.payloadJson, - }, - "api-command.app_deployment_run_dead_letter_fail_failed", - ); - } - const preserveArtifactFailure = command?.kind === "environment_package_artifact_build"; await markApiCommandDeadLettered({ @@ -768,7 +340,7 @@ export async function processApiCommandDeadLetterMessage( preserveArtifactFailure && command.lastErrorMessage ? command.lastErrorMessage : "API command reached the queue dead-letter consumer.", - nowMs: deadLetteredAtMs, + nowMs: nowMs(), }); } catch (error) { logError("api-command.dead_letter_invalid", { diff --git a/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts b/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts deleted file mode 100644 index 0c5ca780..00000000 --- a/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { AppDeploymentAgentBinding } from "./app-deployment-detector"; - -/** - * Resolve `.mosoo.toml [[agents]]` bindings against an App's agents at deploy - * time, failing fast if any binding cannot be satisfied (PM decision #3, - * docs/prd/app-deployment.md "Agent Binding Wedge"): a deploy ships nothing - * unless every bound agent exists and is published. - * - * Pure on purpose — the caller supplies the App's agents (name + published - * flag, derived from status + liveDeploymentVersionId) so this is unit-testable - * without the database. - */ - -export type AppAgentBindingResolutionErrorCode = - | "deployment_agent_not_found" - | "deployment_agent_not_published"; - -export class AppAgentBindingResolutionError extends Error { - readonly code: AppAgentBindingResolutionErrorCode; - - constructor(code: AppAgentBindingResolutionErrorCode, message: string) { - super(message); - this.name = "AppAgentBindingResolutionError"; - this.code = code; - } -} - -export interface ResolvableAppAgent { - id: string; - name: string; - published: boolean; -} - -export interface ResolvedAppAgentBinding { - agentId: string; - envVar: string; - expose: "public_thread"; - name: string; -} - -export function resolveAppAgentBindings( - bindings: readonly AppDeploymentAgentBinding[], - agents: readonly ResolvableAppAgent[], -): ResolvedAppAgentBinding[] { - const agentsByName = new Map(agents.map((agent) => [agent.name, agent])); - - return bindings.map((binding) => { - const agent = agentsByName.get(binding.name); - - if (agent === undefined) { - throw new AppAgentBindingResolutionError( - "deployment_agent_not_found", - `Bound agent "${binding.name}" was not found in this App.`, - ); - } - - if (!agent.published) { - throw new AppAgentBindingResolutionError( - "deployment_agent_not_published", - `Bound agent "${binding.name}" is not published. Publish it, then re-run deploy.`, - ); - } - - return { - agentId: agent.id, - envVar: binding.env, - expose: binding.expose, - name: binding.name, - }; - }); -} diff --git a/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts b/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts deleted file mode 100644 index e29bc6d3..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { agentsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { and, desc, eq, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; - -interface DeploymentAgentCapabilityAuthority { - appId: AppId; - binding: { - env: string; - expose: "public_thread"; - name: string; - }; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; -} - -export type DeploymentAgentCapabilityAuthorityRejection = - | "binding_removed" - | "deployment_deleted" - | "deployment_not_activated" - | "deployment_not_found" - | "deployment_plan_invalid" - | "deployment_revision_replaced"; - -export type DeploymentAgentCapabilityAuthorityResult = - | { authorized: true } - | { authorized: false; reason: DeploymentAgentCapabilityAuthorityRejection }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function containsBoundAgentBinding( - planJson: string, - binding: DeploymentAgentCapabilityAuthority["binding"], -): "absent" | "invalid" | "present" { - try { - const plan = JSON.parse(planJson); - - if (!isRecord(plan) || !Array.isArray(plan["agentBindings"])) { - return "invalid"; - } - - return plan["agentBindings"].some( - (candidate) => - isRecord(candidate) && - candidate["env"] === binding.env && - candidate["expose"] === binding.expose && - candidate["name"] === binding.name, - ) - ? "present" - : "absent"; - } catch { - return "invalid"; - } -} - -/** - * Verifies that a bound capability still belongs to the active Deployment and - * its latest successful binding revision. Failed deployment attempts leave the - * previous successful revision authoritative. - */ -export async function getDeploymentAgentCapabilityAuthority( - database: D1Database, - input: DeploymentAgentCapabilityAuthority, -): Promise { - const deployment = - (await getAppDatabase(database) - .select({ - deletedAt: appDeploymentsTable.deletedAt, - id: appDeploymentsTable.id, - }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, input.deploymentId), - eq(appDeploymentsTable.appId, input.appId), - ), - ) - .limit(1) - .get()) ?? null; - - if (deployment === null) { - return { authorized: false, reason: "deployment_not_found" }; - } - - if (deployment.deletedAt !== null) { - return { authorized: false, reason: "deployment_deleted" }; - } - - const currentSuccessfulRun = - (await getAppDatabase(database) - .select({ - id: appDeploymentRunsTable.id, - planJson: appDeploymentRunsTable.planJson, - }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, input.appId), - eq(appDeploymentRunsTable.deploymentId, input.deploymentId), - eq(appDeploymentRunsTable.status, "success"), - ), - ) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null; - - if (currentSuccessfulRun === null) { - return { authorized: false, reason: "deployment_not_activated" }; - } - - if (currentSuccessfulRun.planJson === null) { - return { authorized: false, reason: "deployment_plan_invalid" }; - } - - const binding = containsBoundAgentBinding(currentSuccessfulRun.planJson, input.binding); - - if (binding === "invalid") { - return { authorized: false, reason: "deployment_plan_invalid" }; - } - - if (currentSuccessfulRun.id !== input.deploymentRunId) { - return { - authorized: false, - reason: binding === "present" ? "deployment_revision_replaced" : "binding_removed", - }; - } - - return binding === "present" - ? { authorized: true } - : { authorized: false, reason: "binding_removed" }; -} - -/** - * Adds the same revocation boundary to the statement that inserts a billable - * Run. The earlier read gives useful rejection reasons; this condition closes - * the race where deletion or a successful replacement commits before the Run - * insert. The already-verified binding plan is immutable once its run is - * successful, so the current successful run ID is the revision fence here. - */ -export function createDeploymentAgentCapabilityRunCreationGuard( - input: DeploymentAgentCapabilityAuthority & { agentId: AgentId }, -): SQL { - return sql` - EXISTS ( - SELECT 1 - FROM ${appDeploymentsTable} - INNER JOIN ${agentsTable} - ON ${agentsTable.id} = ${input.agentId} - WHERE ${appDeploymentsTable.id} = ${input.deploymentId} - AND ${appDeploymentsTable.appId} = ${input.appId} - AND ${appDeploymentsTable.deletedAt} IS NULL - AND ${agentsTable.appId} = ${input.appId} - AND ${agentsTable.name} = ${input.binding.name} - AND ${agentsTable.status} = 'published' - AND ${agentsTable.liveDeploymentVersionId} IS NOT NULL - AND ${input.deploymentRunId} = ( - SELECT ${appDeploymentRunsTable.id} - FROM ${appDeploymentRunsTable} - WHERE ${appDeploymentRunsTable.appId} = ${input.appId} - AND ${appDeploymentRunsTable.deploymentId} = ${input.deploymentId} - AND ${appDeploymentRunsTable.status} = 'success' - ORDER BY ${appDeploymentRunsTable.id} DESC - LIMIT 1 - ) - ) - `; -} diff --git a/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts b/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts deleted file mode 100644 index 87d6670b..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts +++ /dev/null @@ -1,406 +0,0 @@ -import Cloudflare from "cloudflare"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; - -type CloudflareWorkerVersion = Awaited< - ReturnType ->; - -export interface CloudflarePagesProjectInput { - branch: string; - projectName: string; -} - -export interface CloudflarePagesDomainInput { - hostname: string; - projectName: string; -} - -export interface CloudflareWorkerModuleInput { - compatibilityDate: string; - mainModuleName: string; - scriptContent: string; - scriptName: string; - /** Plain-text env vars injected into the Worker (e.g. agent thread URLs). */ - vars: Record; -} - -export interface CloudflareWorkerDeploymentResult { - deploymentId: string | null; - versionId: string | null; -} - -export interface CloudflarePagesDomainResult { - status: string | null; -} - -export type CloudflareDeploymentResourceTargetKind = - | "cloudflare_pages" - | "cloudflare_pages_domain" - | "cloudflare_worker" - | "cloudflare_worker_domain" - | "cloudflare_worker_route"; - -export interface CloudflareDeploymentResourceDeleteFailure { - error: unknown; - resourceName: string; - targetKind: CloudflareDeploymentResourceTargetKind; -} - -export interface CloudflareDeploymentClient { - deletePagesDomain(input: CloudflarePagesDomainInput): Promise; - deletePagesProject(input: { projectName: string }): Promise; - deleteWorkerDomain(input: { hostname: string }): Promise; - deleteWorkerRoute(input: { hostname: string }): Promise; - deleteWorkerScript(input: { scriptName: string }): Promise; - deployWorkerModule(input: CloudflareWorkerModuleInput): Promise; - ensurePagesDomain(input: CloudflarePagesDomainInput): Promise; - ensurePagesProject(input: CloudflarePagesProjectInput): Promise<{ projectId: string | null }>; - ensureWorkerDomain(input: { hostname: string; scriptName: string }): Promise; - ensureWorkerRoute(input: { hostname: string; scriptName: string }): Promise; - getLatestPagesDeployment(input: { - projectName: string; - }): Promise<{ deploymentId: string | null; url: string | null }>; -} - -export type CloudflareClientBindings = Pick< - ApiBindings, - "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_API_TOKEN" | "CLOUDFLARE_ZONE_ID" ->; - -function toStatus(error: unknown, status: number): boolean { - return ( - typeof error === "object" && - error !== null && - "status" in error && - Reflect.get(error, "status") === status - ); -} - -export function logCloudflareDeploymentResourceDeleteFailures( - eventName: string, - failures: readonly CloudflareDeploymentResourceDeleteFailure[], -): void { - for (const failure of failures) { - logError(eventName, { - ...createErrorLogContext(failure.error), - resourceName: failure.resourceName, - targetKind: failure.targetKind, - }); - } -} - -export function createCloudflareDeploymentClient( - bindings: CloudflareClientBindings, -): CloudflareDeploymentClient { - const client = new Cloudflare({ apiToken: bindings.CLOUDFLARE_API_TOKEN }); - const accountId = bindings.CLOUDFLARE_ACCOUNT_ID; - const zoneId = bindings.CLOUDFLARE_ZONE_ID; - - return { - async deletePagesDomain(input) { - try { - await client.pages.projects.domains.delete(input.hostname, { - account_id: accountId, - project_name: input.projectName, - }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deletePagesProject(input) { - try { - await client.pages.projects.delete(input.projectName, { account_id: accountId }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deleteWorkerDomain(input) { - const domain = await findWorkerDomain(client, accountId, input.hostname); - - if (domain?.id === undefined) { - return; - } - - await client.workers.domains.delete(domain.id, { account_id: accountId }); - }, - async deleteWorkerRoute(input) { - const pattern = workerRoutePattern(input.hostname); - const route = await findWorkerRoute(client, zoneId, pattern); - - if (route?.id === undefined) { - return; - } - - await client.workers.routes.delete(route.id, { zone_id: zoneId }); - }, - async deleteWorkerScript(input) { - try { - await client.workers.scripts.delete(input.scriptName, { account_id: accountId }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deployWorkerModule(input) { - const scriptPath = `/accounts/${accountId}/workers/scripts/${encodeURIComponent(input.scriptName)}`; - const createVersion = async (): Promise => - ( - await client.post<{ result: CloudflareWorkerVersion }>(`${scriptPath}/versions`, { - body: createWorkerModuleUpload(input), - }) - ).result; - let version; - - try { - version = await createVersion(); - } catch (error) { - if (!toCloudflareCode(error, 10007)) { - throw error; - } - - await client.put(scriptPath, { - body: createWorkerModuleUpload(input), - }); - version = await createVersion(); - } - const versionId = version.id ?? null; - - if (versionId === null) { - throw new Error("Cloudflare Worker version response did not include an id."); - } - - const deployment = await client.workers.scripts.deployments.create(input.scriptName, { - account_id: accountId, - strategy: "percentage", - versions: [{ percentage: 100, version_id: versionId }], - }); - - return { - deploymentId: deployment.id ?? null, - versionId, - }; - }, - async ensurePagesDomain(input) { - try { - const domain = await client.pages.projects.domains.create(input.projectName, { - account_id: accountId, - name: input.hostname, - }); - - return { status: domain.status ?? null }; - } catch (error) { - if (!toStatus(error, 409)) { - throw error; - } - - const domain = await client.pages.projects.domains.get(input.hostname, { - account_id: accountId, - project_name: input.projectName, - }); - - return { status: domain.status ?? null }; - } - }, - async ensurePagesProject(input) { - try { - const project = await client.pages.projects.create({ - account_id: accountId, - name: input.projectName, - production_branch: input.branch, - }); - - return { projectId: project.id ?? null }; - } catch (error) { - if (!toStatus(error, 409)) { - throw error; - } - - const project = await client.pages.projects.get(input.projectName, { - account_id: accountId, - }); - - return { projectId: project.id ?? null }; - } - }, - async ensureWorkerDomain(input) { - const existingDomain = await findWorkerDomain(client, accountId, input.hostname); - - if (existingDomain !== null && existingDomain.service === input.scriptName) { - return; - } - - await client.workers.domains.update({ - account_id: accountId, - hostname: input.hostname, - service: input.scriptName, - zone_id: zoneId, - }); - }, - async ensureWorkerRoute(input) { - const pattern = workerRoutePattern(input.hostname); - const existingRoute = await findWorkerRoute(client, zoneId, pattern); - - if (existingRoute !== null) { - if (existingRoute.script !== input.scriptName && existingRoute.id !== undefined) { - await client.workers.routes.update(existingRoute.id, { - pattern, - script: input.scriptName, - zone_id: zoneId, - }); - } - - return; - } - - await client.workers.routes.create({ - pattern, - script: input.scriptName, - zone_id: zoneId, - }); - }, - async getLatestPagesDeployment(input) { - const deployments = client.pages.projects.deployments.list(input.projectName, { - account_id: accountId, - per_page: 1, - }); - - for await (const deployment of deployments) { - return { deploymentId: deployment.id ?? null, url: deployment.url ?? null }; - } - - return { deploymentId: null, url: null }; - }, - }; -} - -export async function deleteCloudflareDeploymentResources( - cloudflareClient: CloudflareDeploymentClient, - input: { hostname: string; resourceName: string }, -): Promise { - const failures = await Promise.all([ - deleteCloudflareDeploymentResource("cloudflare_pages_domain", input.resourceName, () => - cloudflareClient.deletePagesDomain({ - hostname: input.hostname, - projectName: input.resourceName, - }), - ), - deleteCloudflareDeploymentResource("cloudflare_pages", input.resourceName, () => - cloudflareClient.deletePagesProject({ projectName: input.resourceName }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker_domain", input.hostname, () => - cloudflareClient.deleteWorkerDomain({ hostname: input.hostname }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker_route", input.resourceName, () => - cloudflareClient.deleteWorkerRoute({ hostname: input.hostname }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker", input.resourceName, () => - cloudflareClient.deleteWorkerScript({ scriptName: input.resourceName }), - ), - ]); - - return failures.filter( - (failure): failure is CloudflareDeploymentResourceDeleteFailure => failure !== null, - ); -} - -async function deleteCloudflareDeploymentResource( - targetKind: CloudflareDeploymentResourceTargetKind, - resourceName: string, - runDelete: () => Promise, -): Promise { - try { - await runDelete(); - return null; - } catch (error) { - return { error, resourceName, targetKind }; - } -} - -function workerRoutePattern(hostname: string): string { - return `${hostname}/*`; -} - -export function createWorkerModuleUpload(input: CloudflareWorkerModuleInput): FormData { - const upload = new FormData(); - const file = new File([input.scriptContent], input.mainModuleName, { - type: "application/javascript+module", - }); - const metadata = { - bindings: Object.entries(input.vars).map(([name, text]) => ({ - name, - text, - type: "plain_text" as const, - })), - compatibility_date: input.compatibilityDate, - main_module: input.mainModuleName, - }; - - upload.append("metadata", JSON.stringify(metadata)); - upload.append(input.mainModuleName, file); - - return upload; -} - -function toCloudflareCode(error: unknown, code: number): boolean { - if (typeof error !== "object" || error === null) { - return false; - } - - if ("code" in error && Reflect.get(error, "code") === code) { - return true; - } - - const cause = Reflect.get(error, "error"); - - if (typeof cause === "object" && cause !== null && Reflect.get(cause, "code") === code) { - return true; - } - - const errors = Reflect.get(error, "errors"); - - return ( - Array.isArray(errors) && - errors.some( - (entry) => typeof entry === "object" && entry !== null && Reflect.get(entry, "code") === code, - ) - ); -} - -async function findWorkerDomain( - client: Cloudflare, - accountId: string, - hostname: string, -): Promise<{ id?: string; service?: string } | null> { - const domains = client.workers.domains.list({ account_id: accountId }); - - for await (const domain of domains) { - if (domain.hostname === hostname) { - return domain; - } - } - - return null; -} - -async function findWorkerRoute( - client: Cloudflare, - zoneId: string, - pattern: string, -): Promise<{ id?: string; script?: string } | null> { - const routes = client.workers.routes.list({ zone_id: zoneId }); - - for await (const route of routes) { - if (route.pattern === pattern) { - return route; - } - } - - return null; -} diff --git a/apps/api/src/modules/apps/application/app-deployment-detector.ts b/apps/api/src/modules/apps/application/app-deployment-detector.ts deleted file mode 100644 index 80de1a29..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-detector.ts +++ /dev/null @@ -1,936 +0,0 @@ -import type { AppDeploymentTargetKind } from "@mosoo/db"; -import type { ParseError } from "jsonc-parser"; -import { parse as parseJsonc } from "jsonc-parser"; -import { parse as parseToml, stringify } from "smol-toml"; - -export type AppDeploymentPackageManager = "bun" | "none" | "npm" | "pnpm" | "yarn"; -export type AppDeploymentTargetMode = "static_assets" | "worker_module" | "worker_with_assets"; -export type AppDeploymentDetectionErrorCode = - | "deployment_config_required" - | "deployment_shape_unsupported"; - -export interface AppDeploymentAgentBinding { - env: string; - expose: "public_thread"; - name: string; -} - -export interface AppDeploymentPlan { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - generatedWranglerConfig: string; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - outputDir: string | null; - packageManager: AppDeploymentPackageManager; - routesFallback: string | null; - rootDir: string; - targetKind: AppDeploymentTargetKind; - targetMode: AppDeploymentTargetMode; - warnings: string[]; - workerEntry: string | null; -} - -export interface AppDeploymentRepositorySnapshot { - files: Readonly>; -} - -export interface AppDeploymentDetectionOptions { - resourceName: string; -} - -interface PackageJson { - dependencies: Readonly>; - devDependencies: Readonly>; - optionalDependencies: Readonly>; - packageManager: string | null; - peerDependencies: Readonly>; - scripts: Readonly>; -} - -interface MosooConfig { - agents: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - outputDir: string | null; - routesFallback: string | null; - rootDir: string; - workerEntry: string | null; - wranglerConfigPath: string | null; - type: "static" | "worker"; -} - -interface RepositoryFiles { - has(path: string): boolean; - read(path: string): string | null; -} - -export const APP_DEPLOYMENT_COMPATIBILITY_DATE = "2026-06-26"; -const WORKER_JS_ENTRY_PATTERN = /\.(?:mjs|js)$/u; - -export class AppDeploymentDetectionError extends Error { - readonly code: AppDeploymentDetectionErrorCode; - - constructor(code: AppDeploymentDetectionErrorCode, message: string) { - super(message); - this.name = "AppDeploymentDetectionError"; - this.code = code; - } -} - -export function detectAppDeploymentPlan( - snapshot: AppDeploymentRepositorySnapshot, - options: AppDeploymentDetectionOptions, -): AppDeploymentPlan { - const files = createRepositoryFiles(snapshot.files); - const mosooConfig = files.read(".mosoo.toml"); - const resourceName = normalizeResourceName(options.resourceName); - - if (mosooConfig !== null) { - return detectFromMosooConfig(files, mosooConfig, resourceName); - } - - return detectFromRepository(files, ".", resourceName); -} - -function detectFromMosooConfig( - files: RepositoryFiles, - source: string, - resourceName: string, -): AppDeploymentPlan { - const config = parseMosooConfig(source); - const packageJson = readPackageJson(files, config.rootDir); - const packageManager = detectPackageManager(files, config.rootDir, packageJson); - const installCommand = - config.installCommand ?? installCommandFor(packageManager, files, config.rootDir); - const buildCommand = config.buildCommand ?? buildCommandFor(packageManager, packageJson); - - if (config.type === "static") { - if (config.agents.length > 0) { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - "agent bindings ([[agents]]) require a worker deployment", - ); - } - - const outputDir = - config.outputDir ?? - fail("deployment_config_required", "static deployment requires build.output"); - - return pagesPlan({ - agentBindings: config.agents, - buildCommand, - installCommand, - mosooConfigPath: ".mosoo.toml", - outputDir, - packageManager, - resourceName, - routesFallback: config.routesFallback, - rootDir: config.rootDir, - }); - } - - const workerEntry = - config.workerEntry ?? - readWranglerMain(files, config.rootDir, config.wranglerConfigPath) ?? - fail("deployment_config_required", "worker deployment requires worker.entry"); - - if (config.routesFallback !== null) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "routes.fallback is only supported for static deployment", - ); - } - - return workerPlan({ - agentBindings: config.agents, - buildCommand, - installCommand, - mosooConfigPath: ".mosoo.toml", - packageManager, - resourceName, - rootDir: config.rootDir, - workerEntry, - }); -} - -function detectFromRepository( - files: RepositoryFiles, - rootDir: string, - resourceName: string, -): AppDeploymentPlan { - const packageJson = readPackageJson(files, rootDir); - const packageManager = detectPackageManager(files, rootDir, packageJson); - const wranglerMain = readWranglerMain(files, rootDir); - - if (wranglerMain !== null) { - return workerPlan({ - agentBindings: [], - buildCommand: buildCommandFor(packageManager, packageJson), - installCommand: installCommandFor(packageManager, files, rootDir), - mosooConfigPath: null, - packageManager, - resourceName, - rootDir, - workerEntry: wranglerMain, - }); - } - - if (packageJson === null) { - if (files.has("index.html")) { - return pagesPlan({ - agentBindings: [], - buildCommand: null, - installCommand: null, - mosooConfigPath: null, - outputDir: ".", - packageManager: "none", - resourceName, - routesFallback: null, - rootDir, - }); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "repository does not match a supported deployment shape", - ); - } - - if (hasDependency(packageJson, "vite")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "dist", resourceName); - } - - if (hasDependency(packageJson, "astro")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "dist", resourceName); - } - - if (hasDependency(packageJson, "@docusaurus/core")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "build", resourceName); - } - - if (hasDependency(packageJson, "next")) { - if (isNextStaticExport(files, rootDir, packageJson)) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "out", resourceName); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "Next.js deployment requires explicit static export", - ); - } - - if (files.has(pathInRoot(rootDir, "index.html")) && packageJson.scripts["build"] === undefined) { - return pagesPlan({ - agentBindings: [], - buildCommand: null, - installCommand: null, - mosooConfigPath: null, - outputDir: ".", - packageManager: "none", - resourceName, - routesFallback: null, - rootDir, - }); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "repository does not match a supported deployment shape", - ); -} - -function packagePagesPlan( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson, - packageManager: AppDeploymentPackageManager, - outputDir: string, - resourceName: string, -): AppDeploymentPlan { - const buildCommand = - buildCommandFor(packageManager, packageJson) ?? - fail("deployment_config_required", "static framework deployment requires scripts.build"); - - return pagesPlan({ - agentBindings: [], - buildCommand, - installCommand: installCommandFor(packageManager, files, rootDir), - mosooConfigPath: null, - outputDir, - packageManager, - resourceName, - routesFallback: null, - rootDir, - }); -} - -function pagesPlan(input: { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - outputDir: string; - packageManager: AppDeploymentPackageManager; - resourceName: string; - routesFallback: string | null; - rootDir: string; -}): AppDeploymentPlan { - return { - agentBindings: input.agentBindings, - buildCommand: input.buildCommand, - generatedWranglerConfig: stringify({ - compatibility_date: APP_DEPLOYMENT_COMPATIBILITY_DATE, - name: input.resourceName, - pages_build_output_dir: input.outputDir, - }), - installCommand: input.installCommand, - mosooConfigPath: input.mosooConfigPath, - outputDir: input.outputDir, - packageManager: input.packageManager, - routesFallback: input.routesFallback, - rootDir: input.rootDir, - targetKind: "cloudflare_pages", - targetMode: "static_assets", - warnings: [], - workerEntry: null, - }; -} - -function workerPlan(input: { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - packageManager: AppDeploymentPackageManager; - resourceName: string; - rootDir: string; - workerEntry: string; -}): AppDeploymentPlan { - if (!WORKER_JS_ENTRY_PATTERN.test(input.workerEntry)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "worker.entry must point to a JavaScript module file", - ); - } - - return { - agentBindings: input.agentBindings, - buildCommand: input.buildCommand, - generatedWranglerConfig: stringify({ - compatibility_date: APP_DEPLOYMENT_COMPATIBILITY_DATE, - main: input.workerEntry, - name: input.resourceName, - }), - installCommand: input.installCommand, - mosooConfigPath: input.mosooConfigPath, - outputDir: null, - packageManager: input.packageManager, - routesFallback: null, - rootDir: input.rootDir, - targetKind: "cloudflare_worker", - targetMode: "worker_module", - warnings: [], - workerEntry: input.workerEntry, - }; -} - -function createRepositoryFiles(files: Readonly>): RepositoryFiles { - const normalized = new Map(); - - for (const [path, content] of Object.entries(files)) { - normalized.set(normalizePath(path), content); - } - - return { - has(path) { - return normalized.has(normalizePath(path)); - }, - read(path) { - return normalized.get(normalizePath(path)) ?? null; - }, - }; -} - -function parseMosooConfig(source: string): MosooConfig { - const value = parseTomlObject(source, ".mosoo.toml"); - requireAllowedKeys( - value, - ["agents", "build", "deploy", "name", "root", "routes", "schema", "type", "worker"], - ".mosoo.toml", - ); - - readSchemaVersion(value); - - const deploy = readTable(value, "deploy", ".mosoo.toml"); - requireAllowedKeys(deploy, ["adapter", "wrangler"], ".mosoo.toml deploy"); - const deployAdapter = value["deploy"] === undefined ? null : readDeployAdapter(deploy); - const wranglerConfigPath = normalizeOptionalRelativePath( - readOptionalString(deploy, "wrangler", ".mosoo.toml deploy"), - "deploy.wrangler", - ); - - const type = resolveDeploymentType( - readOptionalString(value, "type", ".mosoo.toml"), - deployAdapter, - ); - - const build = readTable(value, "build", ".mosoo.toml"); - const worker = readTable(value, "worker", ".mosoo.toml"); - const routes = readTable(value, "routes", ".mosoo.toml"); - const routesFallback = normalizeOptionalRelativePath( - readOptionalString(routes, "fallback", ".mosoo.toml routes"), - "routes.fallback", - ); - - requireAllowedKeys(build, ["command", "install", "output"], ".mosoo.toml build"); - requireAllowedKeys(worker, ["entry"], ".mosoo.toml worker"); - requireAllowedKeys(routes, ["fallback"], ".mosoo.toml routes"); - readOptionalString(value, "name", ".mosoo.toml"); - - return { - agents: readAgentBindings(value), - buildCommand: readOptionalString(build, "command", ".mosoo.toml build"), - installCommand: readOptionalString(build, "install", ".mosoo.toml build"), - outputDir: normalizeOptionalRelativePath( - readOptionalString(build, "output", ".mosoo.toml build"), - "build.output", - ), - rootDir: normalizeRelativePath(readOptionalString(value, "root", ".mosoo.toml") ?? ".", "root"), - routesFallback, - type, - workerEntry: normalizeOptionalRelativePath( - readOptionalString(worker, "entry", ".mosoo.toml worker"), - "worker.entry", - ), - wranglerConfigPath, - }; -} - -function readSchemaVersion(value: Readonly>): void { - const schema = value["schema"]; - - if (schema === undefined) { - return; - } - - if (typeof schema !== "number" || !Number.isInteger(schema)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - ".mosoo.toml schema must be an integer", - ); - } - - if (schema !== 1) { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml schema must be 1", - ); - } -} - -function readDeployAdapter(deploy: Readonly>): "cloudflare-workers" { - const adapter = readRequiredString(deploy, "adapter", ".mosoo.toml deploy"); - - if (adapter !== "cloudflare-workers") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml deploy.adapter must be cloudflare-workers", - ); - } - - return "cloudflare-workers"; -} - -function resolveDeploymentType( - flatType: string | null, - deployAdapter: "cloudflare-workers" | null, -): "static" | "worker" { - if (flatType !== null) { - if (flatType !== "static" && flatType !== "worker") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml type must be static or worker", - ); - } - - return flatType; - } - - if (deployAdapter === "cloudflare-workers") { - return "worker"; - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - ".mosoo.toml must declare type or [deploy].adapter", - ); -} - -function readPackageJson(files: RepositoryFiles, rootDir: string): PackageJson | null { - const path = pathInRoot(rootDir, "package.json"); - const content = files.read(path); - - if (content === null) { - return null; - } - - const value = parseJsonObject(content, path); - - return { - dependencies: readStringRecord(value, "dependencies", path), - devDependencies: readStringRecord(value, "devDependencies", path), - optionalDependencies: readStringRecord(value, "optionalDependencies", path), - packageManager: readOptionalString(value, "packageManager", path), - peerDependencies: readStringRecord(value, "peerDependencies", path), - scripts: readStringRecord(value, "scripts", path), - }; -} - -function readWranglerMain( - files: RepositoryFiles, - rootDir: string, - configPath: string | null = null, -): string | null { - const candidates = - configPath === null ? ["wrangler.toml", "wrangler.json", "wrangler.jsonc"] : [configPath]; - - for (const file of candidates) { - const main = readWranglerMainFromFile(files, pathInRoot(rootDir, file)); - - if (main !== null) return main; - } - - return null; -} - -function readWranglerMainFromFile(files: RepositoryFiles, path: string): string | null { - const content = files.read(path); - - if (content === null) { - return null; - } - - return readWranglerConfigMain(() => { - const value = path.endsWith(".toml") - ? parseTomlObject(content, path) - : parseJsonObject(content, path); - - return normalizeOptionalRelativePath(readOptionalString(value, "main", path), "main"); - }); -} - -function readWranglerConfigMain(readMain: () => string | null): string | null { - try { - return readMain(); - } catch { - return null; - } -} - -function detectPackageManager( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson | null, -): AppDeploymentPackageManager { - if (files.has(pathInRoot(rootDir, "bun.lock")) || files.has(pathInRoot(rootDir, "bun.lockb"))) { - return "bun"; - } - - if (files.has(pathInRoot(rootDir, "pnpm-lock.yaml"))) { - return "pnpm"; - } - - if (files.has(pathInRoot(rootDir, "yarn.lock"))) { - return "yarn"; - } - - if ( - files.has(pathInRoot(rootDir, "package-lock.json")) || - files.has(pathInRoot(rootDir, "npm-shrinkwrap.json")) - ) { - return "npm"; - } - - if (packageJson?.packageManager?.startsWith("bun@")) { - return "bun"; - } - - if (packageJson?.packageManager?.startsWith("pnpm@")) { - return "pnpm"; - } - - if (packageJson?.packageManager?.startsWith("yarn@")) { - return "yarn"; - } - - if (packageJson !== null) { - return "npm"; - } - - return "none"; -} - -function installCommandFor( - packageManager: AppDeploymentPackageManager, - files: RepositoryFiles, - rootDir: string, -): string | null { - switch (packageManager) { - case "bun": - return files.has(pathInRoot(rootDir, "bun.lock")) || - files.has(pathInRoot(rootDir, "bun.lockb")) - ? "bun install --frozen-lockfile" - : "bun install"; - case "npm": - return files.has(pathInRoot(rootDir, "package-lock.json")) ? "npm ci" : "npm install"; - case "pnpm": - return files.has(pathInRoot(rootDir, "pnpm-lock.yaml")) - ? "pnpm install --frozen-lockfile" - : "pnpm install"; - case "yarn": - return files.has(pathInRoot(rootDir, "yarn.lock")) - ? "yarn install --frozen-lockfile" - : "yarn install"; - case "none": - return null; - } -} - -function buildCommandFor( - packageManager: AppDeploymentPackageManager, - packageJson: PackageJson | null, -): string | null { - if (packageJson?.scripts["build"] === undefined || packageManager === "none") { - return null; - } - - switch (packageManager) { - case "bun": - return "bun run build"; - case "npm": - return "npm run build"; - case "pnpm": - return "pnpm run build"; - case "yarn": - return "yarn build"; - } -} - -function isNextStaticExport( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson, -): boolean { - const buildScript = packageJson.scripts["build"] ?? ""; - - if (buildScript.includes("next export")) { - return true; - } - - for (const file of ["next.config.js", "next.config.mjs", "next.config.ts"]) { - const content = files.read(pathInRoot(rootDir, file)); - - if (content !== null && /\boutput\s*:\s*["'`]export["'`]/u.test(content)) { - return true; - } - } - - return false; -} - -function hasDependency(packageJson: PackageJson, name: string): boolean { - return ( - packageJson.dependencies[name] !== undefined || - packageJson.devDependencies[name] !== undefined || - packageJson.optionalDependencies[name] !== undefined || - packageJson.peerDependencies[name] !== undefined - ); -} - -function parseJsonObject(content: string, path: string): Record { - const errors: ParseError[] = []; - let value: unknown; - - try { - if (path.endsWith(".jsonc")) { - value = parseJsonc(content, errors, { allowTrailingComma: true }); - } else { - value = JSON.parse(content); - } - } catch { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid JSON`, - ); - } - - if (errors.length > 0) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid JSONC`, - ); - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be an object`, - ); - } - - return value; -} - -function parseTomlObject(content: string, path: string): Record { - let value: unknown; - - try { - value = parseToml(content); - } catch { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid TOML`, - ); - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError("deployment_config_required", `${path} must be a table`); - } - - return value; -} - -function readStringRecord( - source: Readonly>, - key: string, - path: string, -): Readonly> { - const value = source[key]; - - if (value === undefined) { - return {}; - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be an object`, - ); - } - - const result: Record = {}; - - for (const [recordKey, recordValue] of Object.entries(value)) { - if (typeof recordValue !== "string") { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key}.${recordKey} must be a string`, - ); - } - - result[recordKey] = recordValue; - } - - return result; -} - -function readTable( - source: Readonly>, - key: string, - path: string, -): Readonly> { - const value = source[key]; - - if (value === undefined) { - return {}; - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be a table`, - ); - } - - return value; -} - -function readTableArray( - source: Readonly>, - key: string, - path: string, -): readonly Record[] { - const value = source[key]; - - if (value === undefined) { - return []; - } - - if (!Array.isArray(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be an array of tables`, - ); - } - - return value.map((entry, index) => { - if (!isRecord(entry)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key}[${index}] must be a table`, - ); - } - - return entry; - }); -} - -function readAgentBindings(value: Readonly>): AppDeploymentAgentBinding[] { - const bindings = readTableArray(value, "agents", ".mosoo.toml").map( - (entry, index): AppDeploymentAgentBinding => { - const path = `.mosoo.toml agents[${index}]`; - requireAllowedKeys(entry, ["env", "expose", "name"], path); - - if (readRequiredString(entry, "expose", path) !== "public_thread") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - `${path}.expose must be public_thread`, - ); - } - - return { - env: readRequiredString(entry, "env", path), - expose: "public_thread", - name: readRequiredString(entry, "name", path), - }; - }, - ); - - const seenNames = new Set(); - const seenEnvs = new Set(); - - for (const binding of bindings) { - if (seenNames.has(binding.name)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `.mosoo.toml agents.name "${binding.name}" is duplicated`, - ); - } - - seenNames.add(binding.name); - - if (seenEnvs.has(binding.env)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `.mosoo.toml agents.env "${binding.env}" is duplicated`, - ); - } - - seenEnvs.add(binding.env); - } - - return bindings; -} - -function readRequiredString( - source: Readonly>, - key: string, - path: string, -): string { - return ( - readOptionalString(source, key, path) ?? - fail("deployment_config_required", `${path}.${key} is required`) - ); -} - -function readOptionalString( - source: Readonly>, - key: string, - path: string, -): string | null { - const value = source[key]; - - if (value === undefined) { - return null; - } - - if (typeof value !== "string" || value.trim() === "") { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be a non-empty string`, - ); - } - - return value; -} - -function requireAllowedKeys( - source: Readonly>, - allowedKeys: readonly string[], - path: string, -): void { - for (const key of Object.keys(source)) { - if (!allowedKeys.includes(key)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} is not supported`, - ); - } - } -} - -function normalizeOptionalRelativePath(path: string | null, field: string): string | null { - if (path === null) { - return null; - } - - return normalizeRelativePath(path, field); -} - -function normalizeResourceName(value: string): string { - const name = value.trim(); - - if (name.length === 0) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "deployment resource name is required", - ); - } - - return name; -} - -function normalizeRelativePath(path: string, field: string): string { - const rawPath = path.replaceAll("\\", "/"); - const parts = rawPath.split("/").filter((part) => part !== "" && part !== "."); - - if (rawPath.startsWith("/") || rawPath.includes("\0") || parts.includes("..")) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${field} must stay inside the repository`, - ); - } - - return parts.length === 0 ? "." : parts.join("/"); -} - -function normalizePath(path: string): string { - return path - .replaceAll("\\", "/") - .split("/") - .filter((part) => part !== "" && part !== ".") - .join("/"); -} - -function pathInRoot(rootDir: string, path: string): string { - return rootDir === "." ? path : `${rootDir}/${path}`; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function fail(code: AppDeploymentDetectionErrorCode, message: string): never { - throw new AppDeploymentDetectionError(code, message); -} diff --git a/apps/api/src/modules/apps/application/app-deployment-executor.service.ts b/apps/api/src/modules/apps/application/app-deployment-executor.service.ts deleted file mode 100644 index 87ca0bb5..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-executor.service.ts +++ /dev/null @@ -1,1015 +0,0 @@ -import type { AppDeploymentRunStatus } from "@mosoo/contracts/app"; -import type { AppDeploymentRunRow, AppDeploymentRow } from "@mosoo/db"; -import { appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppDeploymentRunId } from "@mosoo/id"; -import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import { listAppOwnerAgentRows } from "../../agents/application/agent-repository"; -import { boundAgentUrl, mintAppAgentCapabilityToken } from "../../public-api/app-agent-capability"; -import { - destroyRuntimeSubjectContainer, - getRuntimeSubjectKeepAliveHandle, -} from "../../runtime/infrastructure/runtime-subject-lifecycle/runtime-subject-platform"; -import type { - ExecutionSessionHandle, - SandboxHandle, -} from "../../runtime/infrastructure/sandbox-handles"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../domain/app-deployment-lifecycle"; -import { - AppAgentBindingResolutionError, - resolveAppAgentBindings, -} from "./app-agent-binding-resolution"; -import type { ResolvableAppAgent } from "./app-agent-binding-resolution"; -import type { CloudflareDeploymentClient } from "./app-deployment-cloudflare-client"; -import { - createCloudflareDeploymentClient, - deleteCloudflareDeploymentResources, - logCloudflareDeploymentResourceDeleteFailures, -} from "./app-deployment-cloudflare-client"; -import { - APP_DEPLOYMENT_COMPATIBILITY_DATE, - detectAppDeploymentPlan, -} from "./app-deployment-detector"; -import type { AppDeploymentPlan, AppDeploymentRepositorySnapshot } from "./app-deployment-detector"; - -interface AppDeploymentDispatchContext { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; -} - -interface PreparedAppDeploymentRepository { - repoDir: string; - snapshot: AppDeploymentRepositorySnapshot; -} - -interface AppDeploymentDeployResult { - externalDeploymentId: string | null; - externalProjectId: string | null; - externalVersionId: string | null; - url: string; -} - -export interface AppDeploymentBuildRunner { - build(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - }): Promise; - cleanup?(): Promise; - deploy(input: { - deployment: AppDeploymentRow; - envVars: Record; - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - run: AppDeploymentRunRow; - }): Promise; - prepare(input: { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; - }): Promise; -} - -export interface DispatchAppDeploymentRunOptions { - cloudflareClient?: CloudflareDeploymentClient; - runner?: AppDeploymentBuildRunner; -} - -export class AppDeploymentNonRetryableError extends Error { - constructor(message: string) { - super(message); - this.name = "AppDeploymentNonRetryableError"; - } -} - -const SNAPSHOT_FILE_NAMES = new Set([ - ".mosoo.toml", - "bun.lock", - "bun.lockb", - "index.html", - "next.config.js", - "next.config.mjs", - "next.config.ts", - "npm-shrinkwrap.json", - "package-lock.json", - "package.json", - "pnpm-lock.yaml", - "wrangler.json", - "wrangler.jsonc", - "wrangler.toml", - "yarn.lock", -]); -const WORKER_JS_ENTRY_PATTERN = /\.(?:mjs|js)$/u; -export function appDeploymentBuildSandboxId(runId: AppDeploymentRunId): string { - return `${runId}-build`; -} - -export function appDeploymentDeploySandboxId(runId: AppDeploymentRunId): string { - return `${runId}-deploy`; -} - -function quoteShellArg(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function deploymentHostname(deployment: AppDeploymentRow, domain: string): string { - return `${deployment.mosooSubdomain}.${domain}`; -} - -function deploymentUrl(deployment: AppDeploymentRow, domain: string): string { - return `https://${deploymentHostname(deployment, domain)}`; -} - -function isActiveRunStatus(status: AppDeploymentRunStatus): boolean { - return (ACTIVE_APP_DEPLOYMENT_RUN_STATUSES as readonly AppDeploymentRunStatus[]).includes(status); -} - -function commandFailureMessage( - result: { exitCode: number; stderr: string; stdout: string; success: boolean }, - label: string, -): string | null { - if (result.success && result.exitCode === 0) { - return null; - } - - return result.stderr.trim() || result.stdout.trim() || `${label} failed.`; -} - -function assertSuccessfulCommand( - result: { exitCode: number; stderr: string; stdout: string; success: boolean }, - label: string, -): void { - const message = commandFailureMessage(result, label); - - if (message !== null) { - throw new Error(message); - } -} - -async function execChecked( - session: ExecutionSessionHandle, - command: string, - label: string, - options: { retryable?: boolean } = {}, -): Promise { - const message = commandFailureMessage( - await session.exec(`sh -lc ${quoteShellArg(command)}`), - label, - ); - - if (message === null) { - return; - } - - if (options.retryable === false) { - throw new AppDeploymentNonRetryableError(message); - } - - throw new Error(message); -} - -function assertSelfContainedWorkerModule(scriptContent: string): void { - if (/^\s*import\s/mu.test(scriptContent) || /\bimport\s*\(/u.test(scriptContent)) { - throw new AppDeploymentNonRetryableError( - "Worker deployment only supports self-contained JavaScript modules in the first cut.", - ); - } -} - -function assertRequestedMosooConfigPresent( - run: AppDeploymentRunRow, - snapshot: AppDeploymentRepositorySnapshot, -): void { - if (run.mosooConfigJson === null) { - return; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(run.mosooConfigJson); - } catch { - throw new AppDeploymentNonRetryableError("App deployment config metadata is invalid."); - } - - if ( - typeof parsed !== "object" || - parsed === null || - Reflect.get(parsed, "configPath") !== ".mosoo.toml" - ) { - throw new AppDeploymentNonRetryableError("App deployment config metadata is invalid."); - } - - if (snapshot.files[".mosoo.toml"] === undefined) { - throw new AppDeploymentNonRetryableError("Requested .mosoo.toml was not found."); - } -} - -async function assertControlledWranglerAvailable(sandbox: SandboxHandle): Promise { - await execChecked( - sandbox, - "command -v wrangler >/dev/null && wrangler --version >/dev/null", - "Controlled Wrangler availability", - { retryable: false }, - ); -} - -export async function destroyAppDeploymentRunSandboxesBestEffort( - bindings: ApiBindings, - runId: AppDeploymentRunId, -): Promise { - await Promise.all([ - destroyDeploymentSandboxBestEffort( - bindings, - appDeploymentBuildSandboxId(runId), - "app-deployment.build_sandbox_destroy_failed", - ), - destroyDeploymentSandboxBestEffort( - bindings, - appDeploymentDeploySandboxId(runId), - "app-deployment.deploy_sandbox_destroy_failed", - ), - ]); -} - -async function destroyDeploymentSandboxBestEffort( - bindings: ApiBindings, - sandboxId: string, - eventName: string, -): Promise { - try { - await destroyRuntimeSubjectContainer(bindings, sandboxId); - } catch (error) { - logError(eventName, { - ...createErrorLogContext(error), - sandboxId, - }); - } -} - -async function readCurrentDispatchContext( - database: D1Database, - runId: AppDeploymentRunId, -): Promise { - const run = - (await getAppDatabase(database) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, runId)) - .limit(1) - .get()) ?? null; - - if (run === null || !isActiveRunStatus(run.status)) { - return null; - } - - const deployment = - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, run.deploymentId), - eq(appDeploymentsTable.latestRunId, run.id), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - return deployment === null ? null : { deployment, run }; -} - -async function updateRunStatus( - database: D1Database, - runId: AppDeploymentRunId, - status: Extract< - AppDeploymentRunStatus, - "activating" | "building" | "preparing" | "submitted" | "submitting" - >, -): Promise { - const result = await getAppDatabase(database) - .update(appDeploymentRunsTable) - .set({ status, updatedAt: currentTimestampMs() }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(result) > 0; -} - -async function storeDeploymentPlan(input: { - database: D1Database; - plan: AppDeploymentPlan; - runId: AppDeploymentRunId; - targetName: string; -}): Promise { - const targetKind = input.plan.targetKind; - const result = await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - generatedWranglerConfigJson: JSON.stringify({ toml: input.plan.generatedWranglerConfig }), - planJson: JSON.stringify(input.plan), - targetKind, - targetProjectName: targetKind === "cloudflare_pages" ? input.targetName : null, - targetScriptName: targetKind === "cloudflare_worker" ? input.targetName : null, - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(result) > 0; -} - -async function failDeploymentRunIfActive(input: { - database: D1Database; - errorCode: string; - errorMessage: string; - runId: AppDeploymentRunId; -}): Promise { - await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - errorCode: input.errorCode, - errorMessage: input.errorMessage, - status: "failed", - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -async function completeDeploymentRun(input: { - database: D1Database; - deployment: AppDeploymentRow; - result: AppDeploymentDeployResult; - run: AppDeploymentRunRow; -}): Promise { - const nowMs = currentTimestampMs(); - const deploymentUpdate = await getAppDatabase(input.database) - .update(appDeploymentsTable) - .set({ - lastSuccessfulUrl: input.result.url, - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentsTable.id, input.deployment.id), - eq(appDeploymentsTable.latestRunId, input.run.id), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .run(); - - if (getD1ChangeCount(deploymentUpdate) === 0) { - return false; - } - - const runUpdate = await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - errorCode: null, - errorMessage: null, - externalDeploymentId: input.result.externalDeploymentId, - externalProjectId: input.result.externalProjectId, - externalVersionId: input.result.externalVersionId, - status: "success", - updatedAt: nowMs, - url: input.result.url, - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.run.id), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(runUpdate) > 0; -} - -async function shouldCompensateDeletedDeployment(input: { - database: D1Database; - deployment: AppDeploymentRow; -}): Promise { - const deletedDeployment = - (await getAppDatabase(input.database) - .select({ id: appDeploymentsTable.id }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, input.deployment.id), - isNotNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - if (deletedDeployment === null) { - return false; - } - - const replacement = - (await getAppDatabase(input.database) - .select({ id: appDeploymentsTable.id }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.appId, input.deployment.appId), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - return replacement === null; -} - -async function compensateDeletedDeploymentResources(input: { - bindings: ApiBindings; - cloudflareClient: CloudflareDeploymentClient | null; - deployment: AppDeploymentRow; -}): Promise { - if ( - !(await shouldCompensateDeletedDeployment({ - database: input.bindings.DB, - deployment: input.deployment, - })) - ) { - return; - } - - const deleteFailures = await deleteCloudflareDeploymentResources( - input.cloudflareClient ?? createCloudflareDeploymentClient(input.bindings), - { - hostname: deploymentHostname(input.deployment, input.bindings.MOSOO_APP_DEPLOYMENT_DOMAIN), - resourceName: input.deployment.mosooSubdomain, - }, - ); - - if (deleteFailures.length > 0) { - logCloudflareDeploymentResourceDeleteFailures( - "app-deployment.cloudflare_delete_after_deletion_failed", - deleteFailures, - ); - } -} - -function shouldIncludeSnapshotPath(path: string): boolean { - const fileName = path.split("/").at(-1) ?? path; - - return SNAPSHOT_FILE_NAMES.has(fileName); -} - -async function readRepositorySnapshot( - sandbox: SandboxHandle, - repoDir: string, -): Promise { - const listResult = await sandbox.exec( - `sh -lc ${quoteShellArg(`cd ${quoteShellArg(repoDir)} && find . -type f -print | sort`)}`, - ); - - assertSuccessfulCommand(listResult, "Repository file listing"); - - const files: Record = {}; - const paths = listResult.stdout - .split("\n") - .map((line) => line.trim().replace(/^\.\//u, "")) - .filter((path) => path.length > 0 && shouldIncludeSnapshotPath(path)); - - await Promise.all( - paths.map(async (path) => { - files[path] = (await sandbox.readFile(`${repoDir}/${path}`, { encoding: "utf8" })).content; - }), - ); - - return { files }; -} - -function pagesRoutesFallbackCommands(plan: AppDeploymentPlan, outputDir: string): string[] { - if (plan.routesFallback === null) { - return []; - } - - return [ - `printf '%s\\n' ${quoteShellArg(`/* /${plan.routesFallback} 200`)} > ${quoteShellArg( - `${outputDir}/_redirects`, - )}`, - ]; -} - -async function createPagesArtifactArchive(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - buildSandbox: SandboxHandle; - workDir: string; -}): Promise { - if (input.plan.outputDir === null) { - throw new AppDeploymentNonRetryableError("Pages deployment plan is missing outputDir."); - } - - const archivePath = `${input.workDir}/artifact.tar`; - const outputDir = `${input.prepared.repoDir}/${input.plan.rootDir}/${input.plan.outputDir}`; - await execChecked( - input.buildSandbox, - [ - `rm -f ${quoteShellArg(archivePath)}`, - ...pagesRoutesFallbackCommands(input.plan, outputDir), - `cd ${quoteShellArg(outputDir)}`, - `find . -type f -print0 | tar --null --no-recursion -cf ${quoteShellArg(archivePath)} -T -`, - ].join(" && "), - "Pages artifact archive", - { retryable: false }, - ); - - return (await input.buildSandbox.readFile(archivePath, { encoding: "base64" })).content; -} - -async function extractPagesArtifactArchive(input: { - archiveBase64: string; - deploySandbox: SandboxHandle; - workDir: string; -}): Promise<{ artifactDir: string; deployDir: string }> { - const artifactDir = `${input.workDir}/artifact`; - const archiveBase64Path = `${input.workDir}/artifact.tar.b64`; - const archivePath = `${input.workDir}/artifact.tar`; - const deployDir = `${input.workDir}/deploy`; - - await execChecked( - input.deploySandbox, - [ - `rm -rf ${quoteShellArg(input.workDir)}`, - `mkdir -p ${quoteShellArg(artifactDir)} ${quoteShellArg(deployDir)}`, - ].join(" && "), - "Pages deploy workspace", - ); - await input.deploySandbox.writeFile(archiveBase64Path, input.archiveBase64); - await execChecked( - input.deploySandbox, - [ - `base64 -d ${quoteShellArg(archiveBase64Path)} > ${quoteShellArg(archivePath)}`, - `tar -xf ${quoteShellArg(archivePath)} -C ${quoteShellArg(artifactDir)}`, - ].join(" && "), - "Pages artifact extraction", - ); - - return { artifactDir, deployDir }; -} - -class SandboxAppDeploymentBuildRunner implements AppDeploymentBuildRunner { - readonly #bindings: ApiBindings; - readonly #cloudflareClient: CloudflareDeploymentClient; - #buildSandbox: SandboxHandle | null = null; - #buildWorkDir: string | null = null; - #runId: AppDeploymentRunId | null = null; - - constructor(bindings: ApiBindings, cloudflareClient: CloudflareDeploymentClient) { - this.#bindings = bindings; - this.#cloudflareClient = cloudflareClient; - } - - async prepare(input: { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; - }): Promise { - const sandbox = await getRuntimeSubjectKeepAliveHandle( - this.#bindings, - appDeploymentBuildSandboxId(input.run.id), - ); - const workDir = `/tmp/mosoo-app-deployment-build-${input.run.id}`; - const repoDir = `${workDir}/repo`; - const cloneCommand = [ - `rm -rf ${quoteShellArg(workDir)}`, - `mkdir -p ${quoteShellArg(workDir)}`, - `git clone --no-tags --depth 1 ${quoteShellArg(input.deployment.repoUrl)} ${quoteShellArg(repoDir)}`, - `cd ${quoteShellArg(repoDir)}`, - `git fetch --no-tags --depth 1 origin ${quoteShellArg(input.run.sourceCommitSha)}`, - `git checkout --detach ${quoteShellArg(input.run.sourceCommitSha)}`, - ].join(" && "); - - await sandbox.setKeepAlive(true); - await execChecked(sandbox, cloneCommand, "Repository clone"); - - this.#buildSandbox = sandbox; - this.#buildWorkDir = workDir; - this.#runId = input.run.id; - - return { - repoDir, - snapshot: await readRepositorySnapshot(sandbox, repoDir), - }; - } - - async build(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - }): Promise { - const sandbox = this.#requireBuildSandbox(); - const commands = [input.plan.installCommand, input.plan.buildCommand].filter( - (command): command is string => command !== null, - ); - - if (commands.length === 0) { - return; - } - - const buildSession = await sandbox.createSession({ - cwd: `${input.prepared.repoDir}/${input.plan.rootDir}`, - }); - - await execChecked( - buildSession, - ["unset CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_ZONE_ID", ...commands].join( - " && ", - ), - "App deployment build", - { retryable: false }, - ); - } - - async deploy(input: { - deployment: AppDeploymentRow; - envVars: Record; - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - run: AppDeploymentRunRow; - }): Promise { - const buildSandbox = this.#requireBuildSandbox(); - const buildWorkDir = this.#requireBuildWorkDir(); - const targetName = input.deployment.mosooSubdomain; - const domain = this.#bindings.MOSOO_APP_DEPLOYMENT_DOMAIN; - const hostname = deploymentHostname(input.deployment, domain); - - if (input.plan.targetKind === "cloudflare_pages") { - const project = await this.#cloudflareClient.ensurePagesProject({ - branch: input.run.sourceBranch, - projectName: targetName, - }); - const archiveBase64 = await createPagesArtifactArchive({ - buildSandbox, - plan: input.plan, - prepared: input.prepared, - workDir: buildWorkDir, - }); - await this.#destroyBuildSandbox(); - - const deploySandbox = await getRuntimeSubjectKeepAliveHandle( - this.#bindings, - appDeploymentDeploySandboxId(input.run.id), - ); - const deployWorkDir = `/tmp/mosoo-app-deployment-deploy-${input.run.id}`; - await deploySandbox.setKeepAlive(true); - await assertControlledWranglerAvailable(deploySandbox); - const { artifactDir, deployDir } = await extractPagesArtifactArchive({ - archiveBase64, - deploySandbox, - workDir: deployWorkDir, - }); - const deploySession = await deploySandbox.createSession({ - cwd: deployDir, - env: { - CLOUDFLARE_ACCOUNT_ID: this.#bindings.CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_API_TOKEN: this.#bindings.CLOUDFLARE_API_TOKEN, - }, - }); - - await execChecked( - deploySession, - [ - "wrangler", - "pages", - "deploy", - quoteShellArg(artifactDir), - "--project-name", - quoteShellArg(targetName), - "--branch", - quoteShellArg(input.run.sourceBranch), - ].join(" "), - "Cloudflare Pages deploy", - ); - - const [latestDeployment, domainResult] = await Promise.all([ - this.#cloudflareClient.getLatestPagesDeployment({ - projectName: targetName, - }), - this.#cloudflareClient.ensurePagesDomain({ - hostname, - projectName: targetName, - }), - ]); - const url = - domainResult.status === "active" - ? deploymentUrl(input.deployment, domain) - : latestDeployment.url; - - if (url === null) { - throw new Error("Cloudflare Pages deployment response did not include a live URL."); - } - - return { - externalDeploymentId: latestDeployment.deploymentId, - externalProjectId: project.projectId, - externalVersionId: null, - url, - }; - } - - if (input.plan.workerEntry === null) { - throw new AppDeploymentNonRetryableError("Worker deployment plan is missing workerEntry."); - } - - if (!WORKER_JS_ENTRY_PATTERN.test(input.plan.workerEntry)) { - throw new AppDeploymentNonRetryableError( - "Worker deployment requires a JavaScript module entry.", - ); - } - - const mainModuleName = input.plan.workerEntry.split("/").at(-1) ?? input.plan.workerEntry; - const scriptContent = ( - await buildSandbox.readFile( - `${input.prepared.repoDir}/${input.plan.rootDir}/${input.plan.workerEntry}`, - { - encoding: "utf8", - }, - ) - ).content; - assertSelfContainedWorkerModule(scriptContent); - await this.#destroyBuildSandbox(); - - const worker = await this.#cloudflareClient.deployWorkerModule({ - compatibilityDate: APP_DEPLOYMENT_COMPATIBILITY_DATE, - mainModuleName, - scriptContent, - scriptName: targetName, - vars: input.envVars, - }); - await this.#cloudflareClient.ensureWorkerRoute({ - hostname, - scriptName: targetName, - }); - await this.#cloudflareClient.ensureWorkerDomain({ - hostname, - scriptName: targetName, - }); - - return { - externalDeploymentId: worker.deploymentId, - externalProjectId: null, - externalVersionId: worker.versionId, - url: deploymentUrl(input.deployment, domain), - }; - } - - async cleanup(): Promise { - if (this.#runId === null) { - return; - } - - await destroyAppDeploymentRunSandboxesBestEffort(this.#bindings, this.#runId); - this.#buildSandbox = null; - this.#buildWorkDir = null; - } - - async #destroyBuildSandbox(): Promise { - if (this.#runId === null || this.#buildSandbox === null) { - return; - } - - await destroyDeploymentSandboxBestEffort( - this.#bindings, - appDeploymentBuildSandboxId(this.#runId), - "app-deployment.build_sandbox_destroy_failed", - ); - this.#buildSandbox = null; - this.#buildWorkDir = null; - } - - #requireBuildSandbox(): SandboxHandle { - if (this.#buildSandbox === null) { - throw new Error("App deployment sandbox was not prepared."); - } - - return this.#buildSandbox; - } - - #requireBuildWorkDir(): string { - if (this.#buildWorkDir === null) { - throw new Error("App deployment work directory was not prepared."); - } - - return this.#buildWorkDir; - } -} - -// Long-lived: the injected URL lives with the deployed Worker and is revoked by -// deleting the deployment (which destroys the Worker) plus the ask endpoint's -// re-check that the agent is still published. See docs/prd/app-deployment.md. -const APP_AGENT_CAPABILITY_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000; - -// Resolve `.mosoo.toml [[agents]]` bindings to published agents and mint one -// self-authorizing capability URL per binding (fail-fast on an unpublished or -// missing agent). Returns the env var map injected into the deployed Worker. -async function resolveDeploymentEnvVars( - bindings: ApiBindings, - deployment: AppDeploymentRow, - run: AppDeploymentRunRow, - plan: AppDeploymentPlan, -): Promise> { - if (plan.agentBindings.length === 0) { - return {}; - } - - const agentRows = await listAppOwnerAgentRows(bindings.DB, { - appId: deployment.appId, - viewerId: deployment.ownerAccountId, - }); - const resolvable: ResolvableAppAgent[] = agentRows.map((agent) => ({ - id: agent.id, - name: agent.name, - published: agent.status === "published" && agent.liveDeploymentVersionId !== null, - })); - const resolved = resolveAppAgentBindings(plan.agentBindings, resolvable); - const expiresAtMs = currentTimestampMs() + APP_AGENT_CAPABILITY_TTL_MS; - const envVars: Record = {}; - - for (const binding of resolved) { - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, { - agentId: parsePlatformId(binding.agentId, "bound Agent ID"), - appId: deployment.appId, - binding: { - env: binding.envVar, - expose: binding.expose, - name: binding.name, - }, - deploymentId: deployment.id, - deploymentRunId: run.id, - exp: expiresAtMs, - }); - envVars[binding.envVar] = boundAgentUrl(bindings.WEB_ORIGIN, token); - } - - return envVars; -} - -export async function dispatchAppDeploymentRun( - bindings: ApiBindings, - input: { appDeploymentRunId: AppDeploymentRunId }, - options: DispatchAppDeploymentRunOptions = {}, -): Promise { - let context = await readCurrentDispatchContext(bindings.DB, input.appDeploymentRunId); - - if (context === null) { - return; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "preparing"))) { - return; - } - - const cloudflareClient = - options.cloudflareClient ?? - (options.runner === undefined ? createCloudflareDeploymentClient(bindings) : null); - const runner = - options.runner ?? - new SandboxAppDeploymentBuildRunner( - bindings, - cloudflareClient ?? createCloudflareDeploymentClient(bindings), - ); - let externallyAttemptedDeployment: AppDeploymentRow | null = null; - - try { - const prepared = await runner.prepare(context); - const targetName = context.deployment.mosooSubdomain; - assertRequestedMosooConfigPresent(context.run, prepared.snapshot); - const plan = detectAppDeploymentPlan(prepared.snapshot, { resourceName: targetName }); - - if ( - !(await storeDeploymentPlan({ - database: bindings.DB, - plan, - runId: context.run.id, - targetName, - })) - ) { - return; - } - - let envVars: Record; - try { - envVars = await resolveDeploymentEnvVars(bindings, context.deployment, context.run, plan); - } catch (error) { - if (error instanceof AppAgentBindingResolutionError) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: error.code, - errorMessage: error.message, - runId: context.run.id, - }); - return; - } - throw error; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "building"))) { - return; - } - - await runner.build({ plan, prepared }); - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "submitting"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_submission_lost", - errorMessage: "Deployment built but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - context = await readCurrentDispatchContext(bindings.DB, input.appDeploymentRunId); - - if (context === null) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_context_lost", - errorMessage: "Deployment context was lost after build.", - runId: input.appDeploymentRunId, - }); - return; - } - - externallyAttemptedDeployment = context.deployment; - const result = await runner.deploy({ ...context, envVars, plan, prepared }); - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "submitted"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_submission_lost", - errorMessage: "Deployment submitted externally but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "activating"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_activation_lost", - errorMessage: "Deployment activated externally but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - const completed = await completeDeploymentRun({ - database: bindings.DB, - deployment: context.deployment, - result, - run: context.run, - }); - - if (!completed) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_completion_lost", - errorMessage: "Deployment completed externally but the App deployment row changed.", - runId: context.run.id, - }); - } - } finally { - if (externallyAttemptedDeployment !== null) { - try { - await compensateDeletedDeploymentResources({ - bindings, - cloudflareClient, - deployment: externallyAttemptedDeployment, - }); - } catch (error) { - logError("app-deployment.cloudflare_delete_after_deletion_check_failed", { - ...createErrorLogContext(error), - deploymentId: externallyAttemptedDeployment.id, - runId: input.appDeploymentRunId, - }); - } - } - - await runner.cleanup?.(); - } -} diff --git a/apps/api/src/modules/apps/application/app-deployment.service.ts b/apps/api/src/modules/apps/application/app-deployment.service.ts deleted file mode 100644 index b87cc9fa..00000000 --- a/apps/api/src/modules/apps/application/app-deployment.service.ts +++ /dev/null @@ -1,843 +0,0 @@ -import type { - AppDeployment, - AppDeploymentRun, - DeleteAppDeploymentInput, - DeployAppInput, -} from "@mosoo/contracts/app"; -import type { ApiCommandId, AppDeploymentRunRow, AppDeploymentRow } from "@mosoo/db"; -import { apiCommandsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import type { AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { createPlatformId } from "@mosoo/id"; -import { and, desc, eq, inArray, isNull } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { API_ERROR_CODE, createApiError, validationError } from "../../../platform/errors"; -import { currentTimestampMs, toIsoString } from "../../../time"; -import { - createAppDeploymentRunDispatchDedupeKey, - enqueueAppDeploymentRunDispatchCommand, -} from "../../api-command/application/api-command-enqueue"; -import { API_COMMAND_LEASE_MS } from "../../api-command/application/api-command-ledger"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - createAppDeploymentDispatchRetryExhaustedMessage, -} from "../../api-command/application/api-command-policy"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../domain/app-deployment-lifecycle"; -import { - createCloudflareDeploymentClient, - deleteCloudflareDeploymentResources, - logCloudflareDeploymentResourceDeleteFailures, -} from "./app-deployment-cloudflare-client"; -import type { - CloudflareClientBindings, - CloudflareDeploymentClient, -} from "./app-deployment-cloudflare-client"; -import { destroyAppDeploymentRunSandboxesBestEffort } from "./app-deployment-executor.service"; -import { ensureAppOwnership } from "./app.service"; -import { normalizeLimit } from "./normalize-limit"; - -type AppDeploymentBindings = Pick< - ApiBindings, - "API_COMMAND_QUEUE" | "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" ->; -type AppDeploymentDeleteBindings = Pick< - AppDeploymentBindings, - "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" -> & - CloudflareClientBindings & - Partial>; - -export type AppDeploymentReadBindings = Pick< - AppDeploymentBindings, - "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" ->; - -interface AppDeploymentServiceOptions { - cloudflareClient?: CloudflareDeploymentClient; - fetch?: typeof fetch; - nowMs?: () => number; -} - -type JsonRecord = Record; - -const RUN_LIST_LIMITS = { defaultLimit: 20, maxLimit: 50 }; - -export async function readAppDeploymentForOwnedApp( - bindings: AppDeploymentReadBindings, - appId: AppId, -): Promise { - const deployment = await readActiveDeployment(bindings.DB, appId); - - if (deployment === null) { - return null; - } - - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const latestRun = await readLatestDeploymentRun(bindings.DB, appId); - - return toAppDeployment(deployment, latestRun, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function getAppDeployment( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - return readAppDeploymentForOwnedApp(bindings, appId); -} - -export async function getAppDeploymentStatus( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const run = await readLatestDeploymentRun(bindings.DB, appId); - - if (run === null) { - return null; - } - - const deployment = await readDeploymentById(bindings.DB, run.deploymentId); - - return toAppDeploymentRun(run, deployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function listAppDeploymentRuns( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, - limit?: number | null, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const runLimit = normalizeLimit(limit, "limit", RUN_LIST_LIMITS); - const runs = await getAppDatabase(bindings.DB) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.appId, appId)) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(runLimit) - .all(); - - if (runs.length === 0) { - return []; - } - - const deploymentsById = await readDeploymentsByIds(bindings.DB, [ - ...new Set(runs.map((run) => run.deploymentId)), - ]); - - return runs.map((run) => { - const deployment = deploymentsById.get(run.deploymentId); - - if (deployment === undefined) { - throw new Error("App deployment row could not be loaded."); - } - - return toAppDeploymentRun(run, deployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); - }); -} - -export async function deployApp( - bindings: AppDeploymentBindings, - viewer: AuthenticatedViewer, - input: DeployAppInput, - options: AppDeploymentServiceOptions = {}, -): Promise { - const configPath = normalizeConfigPath(input.configPath); - const app = await ensureAppOwnership(bindings.DB, viewer.id, input.appId); - const repository = await resolveGitHubRepository( - input.repoUrl, - options.fetch ?? globalThis.fetch, - ); - const activeRun = await readActiveDeploymentRun(bindings.DB, input.appId, { - recoverMissingDispatch: true, - }); - - if (activeRun !== null) { - throw validationError("An App deployment run is already active."); - } - - const nowMs = options.nowMs?.() ?? currentTimestampMs(); - const existingDeployment = await readActiveDeployment(bindings.DB, input.appId); - const deployment = - existingDeployment ?? - ({ - appId: input.appId, - createdAt: nowMs, - defaultBranch: repository.defaultBranch, - deletedAt: null, - id: createPlatformId(), - lastSuccessfulUrl: null, - latestRunId: null, - mosooSubdomain: createMosooSubdomain(input.appId), - ownerAccountId: app.ownerAccountId, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - sourceKind: "github_public", - updatedAt: nowMs, - } satisfies AppDeploymentRow); - const runId = createPlatformId(); - - if (existingDeployment === null) { - const insertDeploymentResult = await getAppDatabase(bindings.DB) - .insert(appDeploymentsTable) - .values(deployment) - .onConflictDoNothing() - .run(); - - if (getD1ChangeCount(insertDeploymentResult) === 0) { - throw validationError("An App deployment is already active."); - } - } else { - await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ - defaultBranch: repository.defaultBranch, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - updatedAt: nowMs, - }) - .where(eq(appDeploymentsTable.id, deployment.id)) - .run(); - } - - const insertRunResult = await getAppDatabase(bindings.DB) - .insert(appDeploymentRunsTable) - .values({ - appId: input.appId, - createdAt: nowMs, - deploymentId: deployment.id, - errorCode: null, - errorMessage: null, - externalDeploymentId: null, - externalProjectId: null, - externalVersionId: null, - generatedWranglerConfigJson: null, - id: runId, - mosooConfigJson: configPath === null ? null : JSON.stringify({ configPath }), - planJson: null, - sourceBranch: repository.defaultBranch, - sourceCommitSha: repository.sourceCommitSha, - status: "queued", - targetKind: null, - targetProjectName: null, - targetScriptName: null, - updatedAt: nowMs, - url: null, - }) - .onConflictDoNothing() - .run(); - - if (getD1ChangeCount(insertRunResult) === 0) { - throw validationError("An App deployment run is already active."); - } - - let linkRunResult: D1Result; - - try { - linkRunResult = await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ latestRunId: runId, updatedAt: nowMs }) - .where(and(eq(appDeploymentsTable.id, deployment.id), isNull(appDeploymentsTable.deletedAt))) - .run(); - } catch (error) { - await markDeploymentRunFailed(bindings.DB, runId, "deployment_run_link_failed", error, nowMs); - throw error; - } - - if (getD1ChangeCount(linkRunResult) === 0) { - await markDeploymentRunFailed( - bindings.DB, - runId, - "deployment_deleted", - new Error("Deployment was deleted before the run was linked."), - nowMs, - ); - throw validationError("App deployment was deleted."); - } - - try { - await enqueueAppDeploymentRunDispatchCommand(bindings, { - appDeploymentRunId: runId, - }); - } catch (error) { - await markDeploymentRunFailed(bindings.DB, runId, "deployment_queue_failed", error, nowMs); - throw error; - } - - const currentDeployment: AppDeploymentRow = { - ...deployment, - defaultBranch: repository.defaultBranch, - latestRunId: runId, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - updatedAt: nowMs, - }; - const run: AppDeploymentRunRow = { - appId: input.appId, - createdAt: nowMs, - deploymentId: deployment.id, - errorCode: null, - errorMessage: null, - externalDeploymentId: null, - externalProjectId: null, - externalVersionId: null, - generatedWranglerConfigJson: null, - id: runId, - mosooConfigJson: configPath === null ? null : JSON.stringify({ configPath }), - planJson: null, - sourceBranch: repository.defaultBranch, - sourceCommitSha: repository.sourceCommitSha, - status: "queued", - targetKind: null, - targetProjectName: null, - targetScriptName: null, - updatedAt: nowMs, - url: null, - }; - - return toAppDeploymentRun(run, currentDeployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function deleteAppDeployment( - bindings: AppDeploymentDeleteBindings, - viewer: AuthenticatedViewer, - input: DeleteAppDeploymentInput, - options: AppDeploymentServiceOptions = {}, -): Promise<{ ok: true }> { - await ensureAppOwnership(bindings.DB, viewer.id, input.appId); - - const deployment = await readActiveDeployment(bindings.DB, input.appId); - - if (deployment === null) { - return { ok: true }; - } - - const activeRunIds = await readActiveDeploymentRunIds(bindings.DB, input.appId); - const nowMs = currentTimestampMs(); - - await getAppDatabase(bindings.DB) - .update(appDeploymentRunsTable) - .set({ - errorCode: "deployment_deleted", - errorMessage: "Deployment was deleted.", - status: "failed", - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.appId, input.appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - await destroyActiveDeploymentRunSandboxes(bindings, activeRunIds); - - const deleteFailures = await deleteCloudflareDeploymentResources( - options.cloudflareClient ?? createCloudflareDeploymentClient(bindings), - { - hostname: createPlannedHost(deployment.mosooSubdomain, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN), - resourceName: deployment.mosooSubdomain, - }, - ); - - if (deleteFailures.length > 0) { - logCloudflareDeploymentResourceDeleteFailures( - "app-deployment.cloudflare_delete_failed", - deleteFailures, - ); - throw createApiError( - API_ERROR_CODE.appDeploymentCleanupFailed, - "Cloudflare deployment cleanup failed. Retry deletion.", - ); - } - - await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ - deletedAt: nowMs, - lastSuccessfulUrl: null, - updatedAt: nowMs, - }) - .where(eq(appDeploymentsTable.id, deployment.id)) - .run(); - - return { ok: true }; -} - -async function destroyActiveDeploymentRunSandboxes( - bindings: AppDeploymentDeleteBindings, - runIds: readonly AppDeploymentRunId[], -): Promise { - if (!hasRuntimeSubjectDestroyBinding(bindings)) { - return; - } - - await Promise.all( - runIds.map((runId) => - destroyAppDeploymentRunSandboxesBestEffort(bindings as ApiBindings, runId), - ), - ); -} - -function hasRuntimeSubjectDestroyBinding(bindings: AppDeploymentDeleteBindings): boolean { - return bindings.runtimeSubjectHandleFactory !== undefined || bindings.Sandbox !== undefined; -} - -async function readActiveDeployment( - database: D1Database, - appId: AppId, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(and(eq(appDeploymentsTable.appId, appId), isNull(appDeploymentsTable.deletedAt))) - .limit(1) - .get()) ?? null - ); -} - -async function readActiveDeploymentRunIds( - database: D1Database, - appId: AppId, -): Promise { - const rows = await getAppDatabase(database) - .select({ id: appDeploymentRunsTable.id }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .all(); - - return rows.map((row) => row.id); -} - -async function readDeploymentById( - database: D1Database, - deploymentId: AppDeploymentId, -): Promise { - const row = - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, deploymentId)) - .limit(1) - .get()) ?? null; - - if (row === null) { - throw new Error("App deployment row could not be loaded."); - } - - return row; -} - -async function readDeploymentsByIds( - database: D1Database, - deploymentIds: readonly AppDeploymentId[], -): Promise> { - const rows = await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(inArray(appDeploymentsTable.id, [...deploymentIds])) - .all(); - - return new Map(rows.map((row) => [row.id, row])); -} - -async function readLatestDeploymentRun( - database: D1Database, - appId: AppId, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.appId, appId)) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null - ); -} - -async function readActiveDeploymentRun( - database: D1Database, - appId: AppId, - options: { recoverMissingDispatch?: boolean } = {}, -): Promise | null> { - const run = - (await getAppDatabase(database) - .select({ - id: appDeploymentRunsTable.id, - status: appDeploymentRunsTable.status, - updatedAt: appDeploymentRunsTable.updatedAt, - }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .limit(1) - .get()) ?? null; - - if (run === null) { - return null; - } - - if (options.recoverMissingDispatch !== true) { - return run; - } - - const nowMs = currentTimestampMs(); - const dispatchCommand = - (await getAppDatabase(database) - .select({ - attemptCount: apiCommandsTable.attemptCount, - claimExpiresAt: apiCommandsTable.claimExpiresAt, - id: apiCommandsTable.id, - lastErrorCode: apiCommandsTable.lastErrorCode, - lastErrorMessage: apiCommandsTable.lastErrorMessage, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(run.id))) - .limit(1) - .get()) ?? null; - - const dispatchRetryExhausted = - dispatchCommand !== null && - (dispatchCommand.status === "queued" || dispatchCommand.status === "running") && - dispatchCommand.attemptCount >= APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS && - dispatchCommand.lastErrorCode !== null; - - if (dispatchRetryExhausted) { - const errorMessage = createAppDeploymentDispatchRetryExhaustedMessage({ - attemptCount: dispatchCommand.attemptCount, - lastErrorMessage: dispatchCommand.lastErrorMessage ?? dispatchCommand.lastErrorCode, - }); - - await markDeploymentRunFailed( - database, - run.id, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - new Error(errorMessage), - nowMs, - ); - await markDeploymentDispatchCommandFailed(database, dispatchCommand.id, { - errorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - errorMessage, - nowMs, - }); - - return null; - } - - if ( - dispatchCommand?.status === "queued" || - (dispatchCommand?.status === "running" && - dispatchCommand.claimExpiresAt !== null && - dispatchCommand.claimExpiresAt > nowMs) - ) { - return run; - } - - if (nowMs - run.updatedAt < API_COMMAND_LEASE_MS) { - return run; - } - - const staleDispatch = - dispatchCommand?.status === "running" && - dispatchCommand.claimExpiresAt !== null && - dispatchCommand.claimExpiresAt <= nowMs; - - await markDeploymentRunFailed( - database, - run.id, - staleDispatch ? "deployment_dispatch_expired" : "deployment_dispatch_missing", - new Error( - staleDispatch - ? "Deployment dispatch claim expired before completion." - : "Deployment dispatch command is missing.", - ), - nowMs, - ); - - return null; -} - -async function markDeploymentDispatchCommandFailed( - database: D1Database, - commandId: ApiCommandId, - input: { errorCode: string; errorMessage: string; nowMs: number }, -): Promise { - await getAppDatabase(database) - .update(apiCommandsTable) - .set({ - claimExpiresAt: null, - claimOwner: null, - completedAt: input.nowMs, - lastErrorCode: input.errorCode, - lastErrorMessage: input.errorMessage, - status: "failed", - updatedAt: input.nowMs, - }) - .where( - and( - eq(apiCommandsTable.id, commandId), - inArray(apiCommandsTable.status, ["queued", "running"]), - ), - ) - .run(); -} - -async function recoverStaleActiveDeploymentRun(database: D1Database, appId: AppId): Promise { - await readActiveDeploymentRun(database, appId, { recoverMissingDispatch: true }); -} - -async function markDeploymentRunFailed( - database: D1Database, - runId: AppDeploymentRunId, - errorCode: string, - error: unknown, - nowMs: number, -): Promise { - await getAppDatabase(database) - .update(appDeploymentRunsTable) - .set({ - errorCode, - errorMessage: error instanceof Error ? error.message : "Deployment queue failed.", - status: "failed", - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -function toAppDeployment( - row: AppDeploymentRow, - latestRun: AppDeploymentRunRow | null, - domain: string, -): AppDeployment { - return { - appId: row.appId, - createdAt: toIsoString(row.createdAt), - defaultBranch: row.defaultBranch, - id: row.id, - latestRun: latestRun === null ? null : toAppDeploymentRun(latestRun, row, domain), - liveUrl: row.lastSuccessfulUrl, - plannedUrl: createPlannedUrl(row.mosooSubdomain, domain), - repoName: row.repoName, - repoOwner: row.repoOwner, - repoUrl: row.repoUrl, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function toAppDeploymentRun( - row: AppDeploymentRunRow, - deployment: AppDeploymentRow, - domain: string, -): AppDeploymentRun { - return { - appId: row.appId, - createdAt: toIsoString(row.createdAt), - deploymentId: row.deploymentId, - errorCode: row.errorCode, - errorMessage: row.errorMessage, - id: row.id, - liveUrl: row.status === "success" && deployment.deletedAt === null ? row.url : null, - plannedUrl: createPlannedUrl(deployment.mosooSubdomain, domain), - sourceBranch: row.sourceBranch, - sourceCommitSha: row.sourceCommitSha, - status: row.status, - targetKind: row.targetKind, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function createMosooSubdomain(appId: AppId): string { - return `app-${appId.toLowerCase()}`; -} - -function createPlannedUrl(subdomain: string, domain: string): string { - return `https://${createPlannedHost(subdomain, domain)}`; -} - -function createPlannedHost(subdomain: string, domain: string): string { - return `${subdomain}.${domain}`; -} - -function normalizeConfigPath(value: string | null | undefined): ".mosoo.toml" | null { - if (value === null || value === undefined) { - return null; - } - - if (value !== ".mosoo.toml") { - throw validationError("configPath must be .mosoo.toml when provided."); - } - - return value; -} - -async function resolveGitHubRepository( - repoUrl: string, - fetcher: typeof fetch, -): Promise<{ - defaultBranch: string; - repoName: string; - repoOwner: string; - repoUrl: string; - sourceCommitSha: string; -}> { - const parsed = parseGitHubRepoUrl(repoUrl); - const repoJson = await fetchGitHubJson( - fetcher, - `https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, - "GitHub repository", - ); - - if (readBoolean(repoJson, "private", "GitHub repository")) { - throw validationError("GitHub repository must be public."); - } - - const defaultBranch = readNonEmptyString(repoJson, "default_branch", "GitHub repository"); - const repoOwner = readGitHubOwner(repoJson["owner"]) ?? parsed.owner; - const repoName = readOptionalString(repoJson, "name") ?? parsed.repo; - const cloneUrl = - readOptionalString(repoJson, "clone_url") ?? `https://github.com/${repoOwner}/${repoName}.git`; - const branchJson = await fetchGitHubJson( - fetcher, - `https://api.github.com/repos/${repoOwner}/${repoName}/branches/${encodeURIComponent(defaultBranch)}`, - "GitHub default branch", - ); - const commit = requireRecord(branchJson["commit"], "GitHub default branch commit"); - - return { - defaultBranch, - repoName, - repoOwner, - repoUrl: cloneUrl, - sourceCommitSha: readNonEmptyString(commit, "sha", "GitHub default branch commit"), - }; -} - -async function fetchGitHubJson( - fetcher: typeof fetch, - url: string, - label: string, -): Promise { - const response = await fetcher(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "mosoo-api", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - - if (response.status === 404) { - throw validationError(`${label} was not found.`); - } - - if (!response.ok) { - throw validationError(`${label} could not be checked.`); - } - - return requireRecord(await response.json(), label); -} - -function parseGitHubRepoUrl(repoUrl: string): { owner: string; repo: string } { - let url: URL; - - try { - url = new URL(repoUrl); - } catch { - throw validationError("repoUrl must be a GitHub HTTPS repository URL."); - } - - if (url.protocol !== "https:" || url.hostname !== "github.com") { - throw validationError("repoUrl must be a GitHub HTTPS repository URL."); - } - - const segments = url.pathname.split("/").filter((segment) => segment.length > 0); - - if (segments.length !== 2 || url.search !== "" || url.hash !== "") { - throw validationError("repoUrl must point to a GitHub repository root."); - } - - const owner = segments[0] ?? ""; - const repo = (segments[1] ?? "").replace(/\.git$/u, ""); - - if (!/^[A-Za-z0-9.-]+$/u.test(owner) || !/^[A-Za-z0-9._-]+$/u.test(repo)) { - throw validationError("repoUrl must point to a valid GitHub repository."); - } - - return { owner, repo }; -} - -function requireRecord(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw validationError(`${label} response is invalid.`); - } - - return value as JsonRecord; -} - -function readNonEmptyString(record: JsonRecord, field: string, label: string): string { - const value = record[field]; - - if (typeof value !== "string" || value.trim().length === 0) { - throw validationError(`${label} response is invalid.`); - } - - return value; -} - -function readOptionalString(record: JsonRecord, field: string): string | null { - const value = record[field]; - - return typeof value === "string" && value.length > 0 ? value : null; -} - -function readBoolean(record: JsonRecord, field: string, label: string): boolean { - const value = record[field]; - - if (typeof value !== "boolean") { - throw validationError(`${label} response is invalid.`); - } - - return value; -} - -function readGitHubOwner(value: unknown): string | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - - const login = (value as JsonRecord)["login"]; - - return typeof login === "string" && login.length > 0 ? login : null; -} diff --git a/apps/api/src/modules/apps/application/app-overview.service.ts b/apps/api/src/modules/apps/application/app-overview.service.ts deleted file mode 100644 index e6834253..00000000 --- a/apps/api/src/modules/apps/application/app-overview.service.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { - AppOverview, - AppOverviewAgent, - AppOverviewBoundAgent, - AppOverviewProviderCredential, - ControlPlaneOverview, -} from "@mosoo/contracts/app"; -import { appDeploymentRunsTable } from "@mosoo/db"; -import type { AppId } from "@mosoo/id"; -import { and, desc, eq, isNotNull } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { toIsoString } from "../../../time"; -import { listAppOwnerAgentRowsPage } from "../../agents/application/agent-repository"; -import { toAgentRuntimeModelProjection } from "../../agents/application/agent-runtime-model-identity"; -import type { AgentRow } from "../../agents/application/agent-types"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { resolveActiveOrganization } from "../../users/application/account-organization-context.service"; -import { parseCredentialModels } from "../../vendor-credentials/application/vendor-credential.mapper"; -import { - listAppVendorCredentialCountsByVendor, - listAppVendorCredentialRowsPage, -} from "../../vendor-credentials/application/vendor-credential.repository"; -import type { VendorCredentialRow } from "../../vendor-credentials/application/vendor-credential.types"; -import type { AppDeploymentAgentBinding } from "./app-deployment-detector"; -import type { AppDeploymentReadBindings } from "./app-deployment.service"; -import { readAppDeploymentForOwnedApp } from "./app-deployment.service"; -import { ensureAppOwnership, listOrganizationAppsPage, toAppSummary } from "./app.service"; -import { normalizeLimit } from "./normalize-limit"; - -const OVERVIEW_LIMITS = { defaultLimit: 50, maxLimit: 100 }; - -function toOverviewAgent(row: AgentRow): AppOverviewAgent { - const runtimeModel = toAgentRuntimeModelProjection(row); - - return { - appId: row.appId, - description: row.description, - id: row.id, - kind: row.kind, - model: runtimeModel.model, - name: row.name, - provider: runtimeModel.provider, - runtimeId: runtimeModel.runtimeId, - status: row.status, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function toOverviewProviderCredential(row: VendorCredentialRow): AppOverviewProviderCredential { - return { - appId: row.appId, - hasCustomApiBase: row.apiBase !== null, - id: row.id, - isDefault: row.isDefault, - modelCount: parseCredentialModels(row.modelsJson)?.length ?? 0, - name: row.name, - status: "configured", - vendorId: row.vendorId, - }; -} - -// The bound agents shown on the overview come from the deployed manifest's -// `.mosoo.toml [[agents]]` (persisted in the latest run's planJson). We surface -// only the env var NAME each agent injects, never the capability URL value. -async function readDeploymentAgentBindings( - bindings: AppDeploymentReadBindings, - deployment: AppOverview["deployment"], -): Promise { - const latestRunId = deployment?.latestRun?.id ?? null; - const deploymentId = deployment?.id ?? null; - - if (deploymentId === null) { - return []; - } - - const latestRunPlan = - latestRunId === null - ? null - : ((await getAppDatabase(bindings.DB) - .select({ planJson: appDeploymentRunsTable.planJson }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, latestRunId)) - .limit(1) - .get()) ?? null); - - let planJson = latestRunPlan?.planJson ?? null; - - if (planJson === null) { - const latestParsedPlan = - (await getAppDatabase(bindings.DB) - .select({ planJson: appDeploymentRunsTable.planJson }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.deploymentId, deploymentId), - isNotNull(appDeploymentRunsTable.planJson), - ), - ) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null; - - planJson = latestParsedPlan?.planJson ?? null; - } - - if (planJson === null) { - return []; - } - - try { - const plan = JSON.parse(planJson) as { agentBindings?: AppDeploymentAgentBinding[] }; - return Array.isArray(plan.agentBindings) ? plan.agentBindings : []; - } catch { - return []; - } -} - -function toBoundAgent( - binding: AppDeploymentAgentBinding, - agentsByName: Map, -): AppOverviewBoundAgent | null { - const agent = agentsByName.get(binding.name); - - if (agent === undefined) { - return null; - } - - return { - agentId: agent.id, - envVar: binding.env, - expose: binding.expose, - name: binding.name, - }; -} - -export async function getAppOverview( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - input: { - agentLimit?: number | null; - appId: AppId; - credentialLimit?: number | null; - }, -): Promise { - const agentLimit = normalizeLimit(input.agentLimit, "agentLimit", OVERVIEW_LIMITS); - const credentialLimit = normalizeLimit(input.credentialLimit, "credentialLimit", OVERVIEW_LIMITS); - const app = await ensureAppOwnership(bindings.DB, viewer.id, input.appId); - - const [agentRows, credentialRows, credentialCounts, deployment] = await Promise.all([ - listAppOwnerAgentRowsPage(bindings.DB, { - appId: input.appId, - limit: agentLimit + 1, - viewerId: viewer.id, - }), - listAppVendorCredentialRowsPage(bindings.DB, input.appId, credentialLimit + 1), - listAppVendorCredentialCountsByVendor(bindings.DB, input.appId), - readAppDeploymentForOwnedApp(bindings, input.appId), - ]); - - const agentsByName = new Map(agentRows.map((agent) => [agent.name, agent])); - const boundAgents = (await readDeploymentAgentBindings(bindings, deployment)) - .map((binding) => toBoundAgent(binding, agentsByName)) - .filter((agent): agent is AppOverviewBoundAgent => agent !== null); - - return { - agents: { - hasMore: agentRows.length > agentLimit, - items: agentRows.slice(0, agentLimit).map(toOverviewAgent), - limit: agentLimit, - }, - app: toAppSummary(app), - boundAgents, - deployment, - providerCredentials: { - byVendor: credentialCounts, - configuredCount: credentialCounts.reduce((sum, row) => sum + row.count, 0), - hasMore: credentialRows.length > credentialLimit, - items: credentialRows.slice(0, credentialLimit).map(toOverviewProviderCredential), - limit: credentialLimit, - }, - }; -} - -export async function getControlPlaneOverview( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - input: { - agentLimit?: number | null; - appLimit?: number | null; - credentialLimit?: number | null; - } = {}, -): Promise { - const appLimit = normalizeLimit(input.appLimit, "appLimit", OVERVIEW_LIMITS); - const activeOrganization = await resolveActiveOrganization(bindings.DB, viewer.id); - - if (activeOrganization === null) { - return { - activeOrganization, - apps: { - hasMore: false, - items: [], - limit: appLimit, - }, - }; - } - - const apps = await listOrganizationAppsPage(bindings.DB, viewer, { - limit: appLimit + 1, - organizationId: activeOrganization.id, - }); - - return { - activeOrganization, - apps: { - hasMore: apps.length > appLimit, - items: await Promise.all( - apps.slice(0, appLimit).map((app) => - getAppOverview(bindings, viewer, { - ...(input.agentLimit === undefined ? {} : { agentLimit: input.agentLimit }), - appId: app.id, - ...(input.credentialLimit === undefined - ? {} - : { credentialLimit: input.credentialLimit }), - }), - ), - ), - limit: appLimit, - }, - }; -} diff --git a/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts b/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts deleted file mode 100644 index 625394ee..00000000 --- a/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AppDeploymentRunStatus } from "@mosoo/contracts/app"; - -export const ACTIVE_APP_DEPLOYMENT_RUN_STATUSES = [ - "queued", - "preparing", - "building", - "submitting", - "submitted", - "activating", -] as const satisfies readonly AppDeploymentRunStatus[]; diff --git a/apps/api/src/modules/apps/graphql/app-graphql.ts b/apps/api/src/modules/apps/graphql/app-graphql.ts index 2631efc0..07395661 100644 --- a/apps/api/src/modules/apps/graphql/app-graphql.ts +++ b/apps/api/src/modules/apps/graphql/app-graphql.ts @@ -1,15 +1,7 @@ -import { parsePlatformId } from "@mosoo/id"; -import type { OrganizationId, AppId } from "@mosoo/id"; +import type { OrganizationId } from "@mosoo/id"; import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; import { appGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { - deleteAppDeployment, - deployApp, - getAppDeploymentStatus, - listAppDeploymentRuns, -} from "../application/app-deployment.service"; -import { getAppOverview, getControlPlaneOverview } from "../application/app-overview.service"; import { createApp } from "../application/app-provisioning.service"; import { listOrganizationApps, renameApp } from "../application/app.service"; @@ -17,76 +9,24 @@ interface OrganizationIdArgs { organizationId: OrganizationId; } -interface AppDeploymentRunListArgs { - appId: string; - limit?: number | null; -} - -interface AppOverviewArgs { - agentLimit?: number | null; - appId: string; - credentialLimit?: number | null; -} - -interface ControlPlaneOverviewArgs { - agentLimit?: number | null; - appLimit?: number | null; - credentialLimit?: number | null; -} - interface CreateAppArgs { - input: { - name: string; - organizationId: OrganizationId; - }; -} - -interface DeployAppArgs { - input: Parameters[2]; -} - -interface DeleteAppDeploymentArgs { - input: Parameters[2]; + input: Parameters[2]; } interface RenameAppArgs { input: Parameters[2]; } -function parseAppId(value: string): AppId { - return parsePlatformId(value, "App ID"); -} - export const appGraphQLModule = { ...appGraphQLSpec, authenticatedMutationResolvers: { createApp: async (_parent, args: CreateAppArgs, context) => createApp(context.bindings, context.viewer, args.input), - deleteAppDeployment: async (_parent, args: DeleteAppDeploymentArgs, context) => - deleteAppDeployment(context.bindings, context.viewer, args.input), - deployApp: async (_parent, args: DeployAppArgs, context) => - deployApp(context.bindings, context.viewer, args.input), renameApp: async (_parent, args: RenameAppArgs, context) => renameApp(context.bindings.DB, context.viewer, args.input), }, authenticatedQueryResolvers: { - appDeploymentRunList: async (_parent, args: AppDeploymentRunListArgs, context) => - listAppDeploymentRuns(context.bindings, context.viewer, parseAppId(args.appId), args.limit), - appDeploymentStatus: async (_parent, args: { appId: string }, context) => - getAppDeploymentStatus(context.bindings, context.viewer, parseAppId(args.appId)), appList: async (_parent, args: OrganizationIdArgs, context) => listOrganizationApps(context.bindings.DB, context.viewer, args.organizationId), - appOverview: async (_parent, args: AppOverviewArgs, context) => - getAppOverview(context.bindings, context.viewer, { - ...(args.agentLimit === undefined ? {} : { agentLimit: args.agentLimit }), - appId: parseAppId(args.appId), - ...(args.credentialLimit === undefined ? {} : { credentialLimit: args.credentialLimit }), - }), - controlPlaneOverview: async (_parent, args: ControlPlaneOverviewArgs, context) => - getControlPlaneOverview(context.bindings, context.viewer, { - ...(args.agentLimit === undefined ? {} : { agentLimit: args.agentLimit }), - ...(args.appLimit === undefined ? {} : { appLimit: args.appLimit }), - ...(args.credentialLimit === undefined ? {} : { credentialLimit: args.credentialLimit }), - }), }, } satisfies GraphQLModule; diff --git a/apps/api/src/modules/auth/application/public-api-caller.service.ts b/apps/api/src/modules/auth/application/public-api-caller.service.ts index 7f5916c0..1636e8e2 100644 --- a/apps/api/src/modules/auth/application/public-api-caller.service.ts +++ b/apps/api/src/modules/auth/application/public-api-caller.service.ts @@ -1,9 +1,8 @@ import { accountsTable } from "@mosoo/db"; -import type { AccountId, AppDeploymentId, PersonalAccessTokenId } from "@mosoo/id"; +import type { AccountId, PersonalAccessTokenId } from "@mosoo/id"; import { eq } from "drizzle-orm"; import { getAppDatabase } from "../../../platform/db/drizzle"; -import type { AppAgentCapabilityClaims } from "../../public-api/app-agent-capability"; import { authenticatePersonalAccessToken, isPersonalAccessTokenValue, @@ -13,7 +12,6 @@ import type { PersonalAccessTokenCaller } from "./personal-access-token.service" import type { AuthenticatedViewer } from "./viewer-auth.service"; type AccessTokenCredentialSubjectId = `human:${AccountId}`; -type DeploymentCapabilityCredentialSubjectId = `deployment:${AppDeploymentId}`; export interface AccessTokenPublicApiCaller { credentialSubjectId: AccessTokenCredentialSubjectId; @@ -23,31 +21,12 @@ export interface AccessTokenPublicApiCaller { viewer: AuthenticatedViewer; } -/** - * A deployed App calling through its injected bound Agent capability. The - * caller acts as the App owner (`viewer`) but only inside the App, Agent - * binding, and Deployment named by the verified claims; thread and file - * admission narrows every read and write to that scope. - */ -export interface DeploymentCapabilityPublicApiCaller { - capability: AppAgentCapabilityClaims; - credentialSubjectId: DeploymentCapabilityCredentialSubjectId; - kind: "deployment_capability"; - viewer: AuthenticatedViewer; -} - -export type PublicApiCaller = AccessTokenPublicApiCaller | DeploymentCapabilityPublicApiCaller; +export type PublicApiCaller = AccessTokenPublicApiCaller; function toAccessTokenCredentialSubjectId(accountId: AccountId): AccessTokenCredentialSubjectId { return `human:${accountId}`; } -export function toDeploymentCapabilityCredentialSubjectId( - deploymentId: AppDeploymentId, -): DeploymentCapabilityCredentialSubjectId { - return `deployment:${deploymentId}`; -} - function toAccessTokenCaller(caller: PersonalAccessTokenCaller): AccessTokenPublicApiCaller { return { credentialSubjectId: toAccessTokenCredentialSubjectId(caller.viewer.id), diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-create.service.ts b/apps/api/src/modules/channels/application/agent-channel-binding-create.service.ts deleted file mode 100644 index af4d782a..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-create.service.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { - normalizeDiscordCredentials, - serializeDiscordCredentials, -} from "../discord/discord-credentials"; -import { DiscordWebApiClient, DiscordWebApiError } from "../discord/discord-web-api"; -import type { LarkChannelCredentials } from "../lark/lark-credentials"; -import { normalizeLarkCredentials, serializeLarkCredentials } from "../lark/lark-credentials"; -import { LarkWebApiClient, LarkWebApiError } from "../lark/lark-web-api"; -import { normalizeSlackCredentials, serializeSlackCredentials } from "../slack/slack-credentials"; -import { SlackWebApiClient, SlackWebApiError } from "../slack/slack-web-api"; -import { - normalizeTelegramCredentials, - serializeTelegramCredentials, -} from "../telegram/telegram-credentials"; -import { TelegramWebApiClient, TelegramWebApiError } from "../telegram/telegram-web-api"; -import { - buildDiscordDisplayMetadata, - buildLarkDisplayMetadata, - buildSlackDisplayMetadata, - buildTelegramDisplayMetadata, - createProviderAgentChannelBinding, - createSlackAppAlreadyConnectedError, - ensureProviderBindingAvailable, -} from "./agent-channel-binding-records"; -import type { - AgentChannelBinding, - CreateDiscordAgentChannelBindingInput, - CreateLarkAgentChannelBindingInput, - CreateSlackAgentChannelBindingInput, - CreateTelegramAgentChannelBindingInput, -} from "./agent-channel-binding.types"; - -async function testSlackIdentity(botToken: string): ReturnType { - try { - return await new SlackWebApiClient(botToken).authTest(); - } catch (error) { - if (error instanceof SlackWebApiError && error.operation === "auth.test") { - throw validationError(error.message, "SLACK_AUTH_TEST_FAILED"); - } - - throw error; - } -} - -async function testLarkIdentity(input: LarkChannelCredentials): Promise<{ - appName: string | null; - botOpenId: string; -}> { - const client = new LarkWebApiClient(input); - - try { - return await client.getBotInfo(await client.getTenantAccessToken()); - } catch (error) { - if (error instanceof LarkWebApiError) { - throw validationError(error.message, "LARK_AUTH_TEST_FAILED"); - } - - if (error instanceof Error) { - logError("channel.lark.identity_check_failed", createErrorLogContext(error)); - throw validationError( - `Lark identity check failed: ${error.message}`, - "LARK_AUTH_TEST_FAILED", - ); - } - - throw validationError("Lark identity check failed.", "LARK_AUTH_TEST_FAILED"); - } -} - -async function testTelegramIdentity(botToken: string): ReturnType { - try { - return await new TelegramWebApiClient(botToken).getMe(); - } catch (error) { - if (error instanceof TelegramWebApiError && error.operation === "getMe") { - throw validationError(error.message, "TELEGRAM_AUTH_TEST_FAILED"); - } - - throw error; - } -} - -async function testDiscordIdentity( - botToken: string, -): ReturnType { - try { - const identity = await new DiscordWebApiClient(botToken).getCurrentBotUser(); - - if (!identity.bot) { - throw validationError( - "Discord credentials must belong to a bot user.", - "DISCORD_AUTH_TEST_NOT_BOT", - ); - } - - return identity; - } catch (error) { - if (error instanceof DiscordWebApiError && error.operation === "getCurrentBotUser") { - throw validationError(error.message, "DISCORD_AUTH_TEST_FAILED"); - } - - throw error; - } -} - -export async function createSlackAgentChannelBinding( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: CreateSlackAgentChannelBindingInput, -): Promise { - const access = await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: input.agentId, - appId: input.appId, - }); - - if (access.agent.status !== "published") { - throw validationError("Publish the Agent before connecting Slack.", "AGENT_NOT_PUBLISHED"); - } - - const credentials = normalizeSlackCredentials(input); - - await ensureProviderBindingAvailable(bindings.DB, { - agentId: input.agentId, - appId: input.appId, - provider: "slack", - }); - - const slackIdentity = await testSlackIdentity(credentials.botToken); - const externalBotId = slackIdentity.userId ?? slackIdentity.botId; - - if (!isTruthy(externalBotId)) { - throw validationError( - "Slack auth.test did not return a bot user id.", - "SLACK_AUTH_TEST_MISSING_BOT", - ); - } - - const externalTenantId = slackIdentity.teamId; - - if (!isTruthy(externalTenantId)) { - throw validationError( - "Slack auth.test did not return a team id.", - "SLACK_AUTH_TEST_MISSING_TEAM", - ); - } - - return createProviderAgentChannelBinding({ - access, - bindings, - credentialsJson: serializeSlackCredentials(credentials), - createAppBindingConflictError: createSlackAppAlreadyConnectedError, - displayMetadata: buildSlackDisplayMetadata({ - botHandle: slackIdentity.user, - workspaceName: slackIdentity.team, - }), - externalBotId, - externalTenantId, - provider: "slack", - viewer, - }); -} - -export async function createLarkAgentChannelBinding( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: CreateLarkAgentChannelBindingInput, -): Promise { - const access = await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: input.agentId, - appId: input.appId, - }); - - if (access.agent.status !== "published") { - throw validationError( - "Publish the Agent before connecting Lark / Feishu.", - "AGENT_NOT_PUBLISHED", - ); - } - - await ensureProviderBindingAvailable(bindings.DB, { - agentId: input.agentId, - appId: input.appId, - provider: "lark", - }); - - const credentials = normalizeLarkCredentials({ - appId: input.larkAppId, - appSecret: input.appSecret, - connectionMode: input.connectionMode, - domain: input.domain, - encryptKey: input.encryptKey, - verificationToken: input.verificationToken, - }); - const identity = await testLarkIdentity(credentials); - - return createProviderAgentChannelBinding({ - access, - bindings, - credentialsJson: serializeLarkCredentials(credentials), - displayMetadata: buildLarkDisplayMetadata({ - appName: identity.appName, - botOpenId: identity.botOpenId, - domain: credentials.domain, - }), - externalBotId: identity.botOpenId, - externalTenantId: `${credentials.domain}:${credentials.appId}`, - provider: "lark", - viewer, - }); -} - -export async function createTelegramAgentChannelBinding( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: CreateTelegramAgentChannelBindingInput, -): Promise { - const access = await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: input.agentId, - appId: input.appId, - }); - - if (access.agent.status !== "published") { - throw validationError("Publish the Agent before connecting Telegram.", "AGENT_NOT_PUBLISHED"); - } - - await ensureProviderBindingAvailable(bindings.DB, { - agentId: input.agentId, - appId: input.appId, - provider: "telegram", - }); - - const credentials = normalizeTelegramCredentials(input); - const identity = await testTelegramIdentity(credentials.botToken); - - return createProviderAgentChannelBinding({ - access, - bindings, - credentialsJson: serializeTelegramCredentials(credentials), - displayMetadata: buildTelegramDisplayMetadata({ - botFirstName: identity.firstName, - botUsername: identity.username, - }), - externalBotId: identity.id, - externalTenantId: identity.id, - provider: "telegram", - viewer, - }); -} - -export async function createDiscordAgentChannelBinding( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: CreateDiscordAgentChannelBindingInput, -): Promise { - const access = await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: input.agentId, - appId: input.appId, - }); - - if (access.agent.status !== "published") { - throw validationError("Publish the Agent before connecting Discord.", "AGENT_NOT_PUBLISHED"); - } - - await ensureProviderBindingAvailable(bindings.DB, { - agentId: input.agentId, - appId: input.appId, - provider: "discord", - }); - - const credentials = normalizeDiscordCredentials(input); - const identity = await testDiscordIdentity(credentials.botToken); - - return createProviderAgentChannelBinding({ - access, - bindings, - credentialsJson: serializeDiscordCredentials(credentials), - displayMetadata: buildDiscordDisplayMetadata({ - applicationId: credentials.applicationId, - botUsername: identity.username, - }), - externalBotId: identity.id, - externalTenantId: credentials.applicationId, - provider: "discord", - viewer, - }); -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-error.ts b/apps/api/src/modules/channels/application/agent-channel-binding-error.ts deleted file mode 100644 index 9a8ae96e..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-error.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { agentChannelBindingsTable } from "@mosoo/db"; -import { and, eq } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import type { RecordAgentChannelBindingErrorInput } from "./agent-channel-binding.types"; - -export async function recordAgentChannelBindingError( - database: D1Database, - input: RecordAgentChannelBindingErrorInput, -): Promise { - const row = await getAppDatabase(database) - .select({ - lastErrorCode: agentChannelBindingsTable.lastErrorCode, - status: agentChannelBindingsTable.status, - }) - .from(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.agentId, input.agentId), - eq(agentChannelBindingsTable.appId, input.appId), - ), - ) - .limit(1) - .get(); - - if (!row) { - return; - } - - if (row.status === "error" && row.lastErrorCode === input.errorCode) { - return; - } - - const timestampMs = currentTimestampMs(); - await getAppDatabase(database) - .update(agentChannelBindingsTable) - .set({ - lastErrorCode: input.errorCode, - status: "error", - updatedAt: timestampMs, - }) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.agentId, input.agentId), - eq(agentChannelBindingsTable.appId, input.appId), - ), - ) - .run(); -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-lark-registration.service.ts b/apps/api/src/modules/channels/application/agent-channel-binding-lark-registration.service.ts deleted file mode 100644 index c41cf755..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-lark-registration.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { AgentId } from "@mosoo/id"; -import type { AppId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { ApiError, validationError } from "../../../platform/errors"; -import type { ApiErrorCode } from "../../../platform/errors"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { pollLarkAppRegistration, startLarkAppRegistration } from "../lark/lark-app-registration"; -import { ensureProviderBindingAvailable } from "./agent-channel-binding-records"; -import type { - LarkAgentChannelRegistration, - PollLarkAgentChannelRegistrationInput, - StartLarkAgentChannelRegistrationInput, -} from "./agent-channel-binding.types"; - -function mapLarkRegistrationError(error: unknown, code: ApiErrorCode): Error { - if ( - error instanceof TypeError || - (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) - ) { - return new ApiError(502, code, "Lark / Feishu app registration request failed."); - } - - if (error instanceof Error) { - return new ApiError(502, code, error.message); - } - - return new ApiError(502, code, "Lark / Feishu app registration request failed."); -} - -async function ensureAgentCanRegisterLarkChannel( - database: D1Database, - viewer: AuthenticatedViewer, - input: { - agentId: AgentId; - appId: AppId; - }, -): Promise { - const viewerId = viewer.id; - const access = await ensureAppAgentOwner(database, viewerId, input); - - if (access.agent.status !== "published") { - throw validationError( - "Publish the Agent before connecting Lark / Feishu.", - "AGENT_NOT_PUBLISHED", - ); - } - - await ensureProviderBindingAvailable(database, { - agentId: input.agentId, - appId: input.appId, - provider: "lark", - }); -} - -export async function startLarkAgentChannelRegistration( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: StartLarkAgentChannelRegistrationInput, -): Promise { - await ensureAgentCanRegisterLarkChannel(bindings.DB, viewer, input); - - try { - const registration = await startLarkAppRegistration(input.domain); - - return { - appId: null, - appSecret: null, - deviceCode: registration.deviceCode, - domain: registration.domain, - expireIn: registration.expireIn, - interval: registration.interval, - lastErrorCode: null, - openId: null, - qrUrl: registration.qrUrl, - status: registration.status, - userCode: registration.userCode, - }; - } catch (error) { - throw mapLarkRegistrationError(error, "LARK_APP_REGISTRATION_START_FAILED"); - } -} - -export async function pollLarkAgentChannelRegistration( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: PollLarkAgentChannelRegistrationInput, -): Promise { - await ensureAgentCanRegisterLarkChannel(bindings.DB, viewer, input); - - try { - const registration = await pollLarkAppRegistration({ - deviceCode: input.deviceCode, - domain: input.domain, - }); - - return { - appId: registration.appId, - appSecret: registration.appSecret, - deviceCode: input.deviceCode.trim(), - domain: registration.domain, - expireIn: null, - interval: null, - lastErrorCode: registration.lastErrorCode, - openId: registration.openId, - qrUrl: null, - status: registration.status, - userCode: null, - }; - } catch (error) { - throw mapLarkRegistrationError(error, "LARK_APP_REGISTRATION_POLL_FAILED"); - } -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-maintenance.service.ts b/apps/api/src/modules/channels/application/agent-channel-binding-maintenance.service.ts deleted file mode 100644 index f2c31ba1..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-maintenance.service.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - agentChannelBindingsTable, - agentsTable, - vaultSecretsTable, - wechatChannelAccountsTable, -} from "@mosoo/db"; -import type { ChannelBindingId, PlatformId } from "@mosoo/id"; -import { and, asc, eq, isNull, like } from "drizzle-orm"; - -import { - createErrorLogContext, - logError, - logInfo, - logWarn, -} from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { notFoundError } from "../../../platform/errors"; -import { currentTimestampMs } from "../../../time"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { stopDiscordGatewayConnection } from "../discord/discord-gateway-connection-client"; -import { deleteWeChatChannelAccountRuntime } from "../wechat/wechat-runtime-store"; -import type { DeleteAgentChannelBindingInput } from "./agent-channel-binding.types"; -import { - CHANNEL_BINDING_CREDENTIAL_SECRET_KIND_PREFIX, - cleanupStoredAgentChannelBindingCredentialSecret, - parseAgentChannelBindingCredentialSecretKind, -} from "./channel-credential-secret-resolution"; -import { stopLarkLongConnection } from "./lark-long-connection-maintenance.service"; - -const CHANNEL_CREDENTIAL_ORPHAN_CLEANUP_BATCH_SIZE = 50; - -export interface ChannelCredentialOrphanCleanupResult { - deleted: number; - failed: number; - skipped: number; - total: number; -} - -async function stopDiscordGatewayConnectionBeforeDeletingBinding( - bindings: ApiBindings, - bindingId: ChannelBindingId, -): Promise { - try { - await stopDiscordGatewayConnection(bindings, { bindingId }); - } catch (error) { - logError("agent-channel-binding.discord_gateway_stop_before_delete_failed", { - ...createErrorLogContext(error), - bindingId, - provider: "discord", - }); - } -} - -async function stopLarkLongConnectionBeforeDeletingBinding( - bindings: ApiBindings, - bindingId: ChannelBindingId, -): Promise { - try { - await stopLarkLongConnection({ bindingId, bindings }); - } catch (error) { - logError("agent-channel-binding.lark_gateway_stop_before_delete_failed", { - ...createErrorLogContext(error), - bindingId, - provider: "lark", - }); - } -} - -async function markDiscordGatewayBindingDeleting( - database: D1Database, - bindingId: ChannelBindingId, -): Promise { - await getAppDatabase(database) - .update(agentChannelBindingsTable) - .set({ - lastErrorCode: "binding_deleting", - status: "error", - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(agentChannelBindingsTable.id, bindingId), - eq(agentChannelBindingsTable.provider, "discord"), - ), - ) - .run(); -} - -async function listOrphanChannelCredentialSecrets(input: { - bindings: ApiBindings; - limit: number; -}): Promise> { - return getAppDatabase(input.bindings.DB) - .select({ - id: vaultSecretsTable.id, - kind: vaultSecretsTable.kind, - }) - .from(vaultSecretsTable) - .leftJoin( - agentChannelBindingsTable, - eq(agentChannelBindingsTable.encryptedCredsSecretId, vaultSecretsTable.id), - ) - .leftJoin( - wechatChannelAccountsTable, - eq(wechatChannelAccountsTable.encryptedCredsSecretId, vaultSecretsTable.id), - ) - .where( - and( - like(vaultSecretsTable.kind, `${CHANNEL_BINDING_CREDENTIAL_SECRET_KIND_PREFIX}%`), - isNull(agentChannelBindingsTable.id), - isNull(wechatChannelAccountsTable.id), - ), - ) - .orderBy(asc(vaultSecretsTable.id)) - .limit(input.limit) - .all(); -} - -export async function cleanupOrphanChannelBindingCredentialSecrets( - bindings: ApiBindings, - _scheduledAt: Date, - options: { limit?: number } = {}, -): Promise { - const rows = await listOrphanChannelCredentialSecrets({ - bindings, - limit: options.limit ?? CHANNEL_CREDENTIAL_ORPHAN_CLEANUP_BATCH_SIZE, - }); - let deleted = 0; - let failed = 0; - let skipped = 0; - - for (const row of rows) { - const owner = parseAgentChannelBindingCredentialSecretKind(row.kind); - - if (owner === null) { - skipped += 1; - logWarn("agent-channel-binding.credential-secret-orphan-cleanup.skipped", { - kind: row.kind, - reason: "invalid_channel_credential_secret_kind", - secretId: row.id, - }); - continue; - } - - const cleanupSucceeded = await cleanupStoredAgentChannelBindingCredentialSecret({ - command: { - ...owner, - purpose: "channel_binding_orphan_maintenance", - secretId: row.id, - }, - database: bindings.DB, - }); - - if (cleanupSucceeded) { - deleted += 1; - } else { - failed += 1; - } - } - - if (rows.length > 0) { - logInfo("agent-channel-binding.credential-secret-orphan-cleanup.completed", { - deleted, - failed, - skipped, - total: rows.length, - }); - } - - return { - deleted, - failed, - skipped, - total: rows.length, - }; -} - -export async function deleteAgentChannelBinding( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: DeleteAgentChannelBindingInput, -): Promise { - const row = await getAppDatabase(bindings.DB) - .select({ - agentId: agentChannelBindingsTable.agentId, - agentAppId: agentsTable.appId, - encryptedCredsSecretId: agentChannelBindingsTable.encryptedCredsSecretId, - id: agentChannelBindingsTable.id, - appId: agentChannelBindingsTable.appId, - provider: agentChannelBindingsTable.provider, - }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.appId, input.appId), - ), - ) - .limit(1) - .get(); - - if (!row) { - throw notFoundError("Agent channel binding not found."); - } - - if (row.agentAppId !== row.appId) { - throw notFoundError("Agent channel binding not found."); - } - - await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: row.agentId, - appId: input.appId, - }); - - if (row.provider === "discord") { - await markDiscordGatewayBindingDeleting(bindings.DB, row.id); - await stopDiscordGatewayConnectionBeforeDeletingBinding(bindings, row.id); - } - - if (row.provider === "lark") { - await stopLarkLongConnectionBeforeDeletingBinding(bindings, row.id); - } - - if (row.provider === "wechat") { - await deleteWeChatChannelAccountRuntime(bindings, { accountId: row.id }); - } - - await getAppDatabase(bindings.DB) - .delete(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.appId, input.appId), - eq(agentChannelBindingsTable.provider, row.provider), - ), - ) - .run(); - await cleanupStoredAgentChannelBindingCredentialSecret({ - command: { - agentId: row.agentId, - provider: row.provider, - appId: row.appId, - purpose: "channel_binding_delete", - secretId: row.encryptedCredsSecretId, - }, - database: bindings.DB, - }); -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-records.ts b/apps/api/src/modules/channels/application/agent-channel-binding-records.ts deleted file mode 100644 index 5ffc2b7c..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-records.ts +++ /dev/null @@ -1,378 +0,0 @@ -import type { PrimitiveRecord } from "@mosoo/contracts"; -import { agentChannelBindingsTable, sessionsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider } from "@mosoo/db"; -import { createPlatformId, parsePlatformId } from "@mosoo/id"; -import type { AgentId, ChannelBindingId, PlatformId, AppId } from "@mosoo/id"; -import { and, asc, eq, inArray, sql } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { - errorMessageChainIncludes, - notFoundError, - validationError, -} from "../../../platform/errors"; -import { currentTimestampMs, toIsoString } from "../../../time"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import type { AgentChannelBinding } from "./agent-channel-binding.types"; -import { - cleanupStoredAgentChannelBindingCredentialSecret, - storeAgentChannelBindingCredentialSecret, -} from "./channel-credential-secret-resolution"; -import { parseChannelDisplayMetadata } from "./channel-display-metadata"; - -interface AgentChannelBindingActivity { - lastTriggeredAt: string | null; - sessionCount7d: number; -} - -type AppAgentOwnerAccess = Awaited>; - -const AGENT_CHANNEL_BINDING_PROVIDER_LABELS = { - discord: "Discord", - lark: "Lark / Feishu", - slack: "Slack", - telegram: "Telegram", - wechat: "WeChat", -} satisfies Record; - -function getAgentChannelBindingProviderLabel(provider: AgentChannelBindingProvider): string { - return AGENT_CHANNEL_BINDING_PROVIDER_LABELS[provider]; -} - -function createAgentChannelBindingExistsError(provider: AgentChannelBindingProvider): Error { - return validationError( - `${getAgentChannelBindingProviderLabel(provider)} is already connected to this Agent.`, - "AGENT_CHANNEL_BINDING_ALREADY_EXISTS", - ); -} - -export function createSlackAppAlreadyConnectedError(): Error { - return validationError("This Slack app is already connected to an Agent.", "SLACK_APP_BOUND"); -} - -function createChannelAppAlreadyConnectedError(): Error { - return validationError("This channel app is already connected to an Agent.", "CHANNEL_APP_BOUND"); -} - -async function cleanupStoredChannelBindingSecret(input: { - agentId: AgentId; - database: D1Database; - provider: AgentChannelBindingProvider; - appId: AppId; - secretId: PlatformId; -}): Promise { - await cleanupStoredAgentChannelBindingCredentialSecret({ - command: { - agentId: input.agentId, - provider: input.provider, - appId: input.appId, - purpose: "channel_binding_create_rollback", - secretId: input.secretId, - }, - database: input.database, - }); -} - -function errorIncludes(error: unknown, fragment: string): boolean { - return errorMessageChainIncludes(error, [fragment]); -} - -function isAgentProviderConflict(error: unknown): boolean { - return ( - errorIncludes(error, "agent_channel_binding_agent_provider_idx") || - (errorIncludes(error, "agent_channel_binding.agent_id") && - errorIncludes(error, "agent_channel_binding.provider") && - !errorIncludes(error, "agent_channel_binding.external_tenant_id")) - ); -} - -function isProviderAppBindingConflict(error: unknown): boolean { - return ( - errorIncludes(error, "agent_channel_binding_provider_tenant_bot_idx") || - (errorIncludes(error, "agent_channel_binding.provider") && - errorIncludes(error, "agent_channel_binding.external_tenant_id") && - errorIncludes(error, "agent_channel_binding.external_bot_id")) - ); -} - -export function buildSlackDisplayMetadata(input: { - botHandle: string | null; - workspaceName: string | null; -}): PrimitiveRecord { - return { - bot_handle: input.botHandle, - workspace_name: input.workspaceName, - }; -} - -export function buildLarkDisplayMetadata(input: { - appName: string | null; - botOpenId: string; - domain: string; -}): PrimitiveRecord { - return { - app_name: input.appName, - bot_open_id: input.botOpenId, - domain: input.domain, - }; -} - -export function buildTelegramDisplayMetadata(input: { - botFirstName: string | null; - botUsername: string | null; -}): PrimitiveRecord { - return { - bot_first_name: input.botFirstName, - bot_username: input.botUsername, - }; -} - -export function buildDiscordDisplayMetadata(input: { - applicationId: string; - botUsername: string | null; -}): PrimitiveRecord { - return { - application_id: input.applicationId, - bot_username: input.botUsername, - }; -} - -export async function ensureProviderBindingAvailable( - database: D1Database, - input: { - agentId: AgentId; - createAppBindingConflictError?: (() => Error) | undefined; - externalBotId?: string; - externalTenantId?: string; - appId: AppId; - provider: AgentChannelBindingProvider; - }, -): Promise { - const databaseClient = getAppDatabase(database); - const agentBinding = await databaseClient - .select({ id: agentChannelBindingsTable.id }) - .from(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.agentId, input.agentId), - eq(agentChannelBindingsTable.appId, input.appId), - eq(agentChannelBindingsTable.provider, input.provider), - ), - ) - .limit(1) - .get(); - - if (agentBinding) { - throw createAgentChannelBindingExistsError(input.provider); - } - - if (!input.externalBotId || !input.externalTenantId) { - return; - } - - const appBinding = await databaseClient - .select({ id: agentChannelBindingsTable.id }) - .from(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.provider, input.provider), - eq(agentChannelBindingsTable.externalTenantId, input.externalTenantId), - eq(agentChannelBindingsTable.externalBotId, input.externalBotId), - ), - ) - .limit(1) - .get(); - - if (appBinding) { - throw (input.createAppBindingConflictError ?? createChannelAppAlreadyConnectedError)(); - } -} - -function toAgentChannelBinding( - row: typeof agentChannelBindingsTable.$inferSelect, - activity: AgentChannelBindingActivity = { - lastTriggeredAt: null, - sessionCount7d: 0, - }, -): AgentChannelBinding { - return { - activityLastTriggeredAt: activity.lastTriggeredAt, - activitySessionCount7d: activity.sessionCount7d, - agentId: row.agentId, - createdAt: toIsoString(row.createdAt), - displayMetadata: parseChannelDisplayMetadata(row.displayMetadataJson), - externalBotId: row.externalBotId, - externalTenantId: row.externalTenantId, - id: row.id, - lastErrorCode: row.lastErrorCode, - appId: row.appId, - provider: row.provider, - status: row.status, - updatedAt: toIsoString(row.updatedAt), - }; -} - -async function loadAgentChannelBindingActivities( - database: D1Database, - input: { - agentId: AgentId; - bindingIds: readonly ChannelBindingId[]; - }, -): Promise> { - if (input.bindingIds.length === 0) { - return new Map(); - } - - const sevenDaysAgoMs = currentTimestampMs() - 7 * 24 * 60 * 60 * 1000; - const bindingIdExpression = sql`json_extract(${sessionsTable.metadataJson}, '$.triggered_by.binding_id')`; - const rows = await getAppDatabase(database) - .select({ - bindingId: bindingIdExpression, - lastCreatedAt: sql`max(${sessionsTable.createdAt})`, - sessionCount7d: sql< - number | string - >`sum(case when ${sessionsTable.createdAt} >= ${sevenDaysAgoMs} then 1 else 0 end)`, - }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.agentId, input.agentId), - eq(sessionsTable.type, "api_channel"), - inArray(bindingIdExpression, input.bindingIds), - ), - ) - .groupBy(bindingIdExpression) - .all(); - - const activityByBindingId = new Map(); - - for (const row of rows) { - activityByBindingId.set(row.bindingId, { - lastTriggeredAt: row.lastCreatedAt ? toIsoString(row.lastCreatedAt) : null, - sessionCount7d: Number(row.sessionCount7d), - }); - } - - return activityByBindingId; -} - -export async function listAgentChannelBindings( - database: D1Database, - viewer: AuthenticatedViewer, - input: { - agentId: AgentId; - appId: AppId; - }, -): Promise { - await ensureAppAgentOwner(database, viewer.id, input); - const rows = await getAppDatabase(database) - .select() - .from(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.agentId, input.agentId), - eq(agentChannelBindingsTable.appId, input.appId), - ), - ) - .orderBy(asc(agentChannelBindingsTable.id)) - .all(); - - const activityByBindingId = await loadAgentChannelBindingActivities(database, { - agentId: input.agentId, - bindingIds: rows.map((row) => row.id), - }); - - return rows.map((row) => toAgentChannelBinding(row, activityByBindingId.get(row.id))); -} - -export async function createProviderAgentChannelBinding(input: { - access: AppAgentOwnerAccess; - bindings: ApiBindings; - credentialsJson: string; - displayMetadata: PrimitiveRecord; - externalBotId: string; - externalTenantId: string; - createAppBindingConflictError?: (() => Error) | undefined; - provider: AgentChannelBindingProvider; - viewer: AuthenticatedViewer; -}): Promise { - const agentId = parsePlatformId(input.access.agent.id, "agent ID"); - const appId = parsePlatformId(input.access.agent.appId, "app ID"); - - await ensureProviderBindingAvailable(input.bindings.DB, { - agentId, - appId, - externalBotId: input.externalBotId, - externalTenantId: input.externalTenantId, - createAppBindingConflictError: input.createAppBindingConflictError, - provider: input.provider, - }); - - const id = createPlatformId(); - const timestampMs = currentTimestampMs(); - const encryptedCredsSecretId = await storeAgentChannelBindingCredentialSecret(input.bindings, { - agentId, - credentialsJson: input.credentialsJson, - provider: input.provider, - appId, - purpose: "channel_binding_create", - }); - - try { - await getAppDatabase(input.bindings.DB) - .insert(agentChannelBindingsTable) - .values({ - agentId, - createdAt: timestampMs, - displayMetadataJson: JSON.stringify(input.displayMetadata), - encryptedCredsSecretId, - externalBotId: input.externalBotId, - externalTenantId: input.externalTenantId, - id, - lastErrorCode: null, - provider: input.provider, - appId, - status: "active", - updatedAt: timestampMs, - }) - .run(); - } catch (error) { - await cleanupStoredChannelBindingSecret({ - agentId, - database: input.bindings.DB, - provider: input.provider, - appId, - secretId: encryptedCredsSecretId, - }); - if (isAgentProviderConflict(error)) { - throw createAgentChannelBindingExistsError(input.provider); - } - if (isProviderAppBindingConflict(error)) { - throw (input.createAppBindingConflictError ?? createChannelAppAlreadyConnectedError)(); - } - throw error; - } - - return readAgentChannelBindingById(input.bindings.DB, id); -} - -export async function readAgentChannelBindingById( - database: D1Database, - bindingId: ChannelBindingId, -): Promise { - const row = - (await getAppDatabase(database) - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .limit(1) - .get()) ?? null; - - if (!row) { - throw notFoundError("Agent channel binding could not be loaded."); - } - - return toAgentChannelBinding(row); -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding-wechat-pairing.service.ts b/apps/api/src/modules/channels/application/agent-channel-binding-wechat-pairing.service.ts deleted file mode 100644 index 90480615..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding-wechat-pairing.service.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { wechatChannelPairingsTable } from "@mosoo/db"; -import type { WeChatChannelPairingId } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, AppId } from "@mosoo/id"; -import { and, eq, gte, isNull } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { ApiError, validationError } from "../../../platform/errors"; -import type { ApiErrorCode } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; -import { currentTimestampMs } from "../../../time"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { - WeChatIlinkApiError, - WeChatIlinkClient, - WeChatIlinkHttpError, -} from "../wechat/wechat-ilink-client"; -import { applyWeChatQrStatusResponse } from "../wechat/wechat-runtime"; -import type { WeChatQrPairingSnapshot, WeChatQrPairingStatus } from "../wechat/wechat-runtime"; -import { persistConfirmedWeChatQrPairing } from "../wechat/wechat-runtime-store"; -import { - ensureProviderBindingAvailable, - readAgentChannelBindingById, -} from "./agent-channel-binding-records"; -import type { - PollWeChatAgentChannelPairingInput, - StartWeChatAgentChannelPairingInput, - WeChatAgentChannelPairing, -} from "./agent-channel-binding.types"; - -const WECHAT_QR_PAIRING_TTL_MS = 10 * 60 * 1000; - -async function hashWeChatQrToken(qrToken: string): Promise { - const encoded = new TextEncoder().encode(qrToken); - const digest = await crypto.subtle.digest("SHA-256", encoded); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -function requireWeChatQrToken(qrToken: string): string { - const trimmed = qrToken.trim(); - - if (!isTruthy(trimmed)) { - throw validationError("WeChat QR token is required.", "WECHAT_QR_TOKEN_REQUIRED"); - } - - return trimmed; -} - -function createWeChatPairingNotFoundError(): ApiError { - return validationError( - "WeChat QR pairing was not found or has expired.", - "WECHAT_QR_PAIRING_NOT_FOUND", - ); -} - -async function storeWeChatPendingPairing( - database: D1Database, - input: { - agentId: AgentId; - createdByAccountId: AccountId; - nowMs: number; - appId: AppId; - qrToken: string; - }, -): Promise { - await getAppDatabase(database) - .insert(wechatChannelPairingsTable) - .values({ - agentId: input.agentId, - consumedAt: null, - createdAt: input.nowMs, - createdByAccountId: input.createdByAccountId, - expiresAt: input.nowMs + WECHAT_QR_PAIRING_TTL_MS, - id: createPlatformId(), - appId: input.appId, - qrTokenHash: await hashWeChatQrToken(input.qrToken), - updatedAt: input.nowMs, - }) - .run(); -} - -async function ensureWeChatPendingPairing( - database: D1Database, - input: { - agentId: AgentId; - createdByAccountId: AccountId; - nowMs: number; - appId: AppId; - qrToken: string; - }, -): Promise { - const row = - (await getAppDatabase(database) - .select({ id: wechatChannelPairingsTable.id }) - .from(wechatChannelPairingsTable) - .where( - and( - eq(wechatChannelPairingsTable.agentId, input.agentId), - eq(wechatChannelPairingsTable.createdByAccountId, input.createdByAccountId), - eq(wechatChannelPairingsTable.appId, input.appId), - eq(wechatChannelPairingsTable.qrTokenHash, await hashWeChatQrToken(input.qrToken)), - isNull(wechatChannelPairingsTable.consumedAt), - gte(wechatChannelPairingsTable.expiresAt, input.nowMs), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - throw createWeChatPairingNotFoundError(); - } -} - -async function consumeWeChatPendingPairing( - database: D1Database, - input: { - agentId: AgentId; - createdByAccountId: AccountId; - nowMs: number; - appId: AppId; - qrToken: string; - }, -): Promise { - const result = await getAppDatabase(database) - .update(wechatChannelPairingsTable) - .set({ - consumedAt: input.nowMs, - updatedAt: input.nowMs, - }) - .where( - and( - eq(wechatChannelPairingsTable.agentId, input.agentId), - eq(wechatChannelPairingsTable.createdByAccountId, input.createdByAccountId), - eq(wechatChannelPairingsTable.appId, input.appId), - eq(wechatChannelPairingsTable.qrTokenHash, await hashWeChatQrToken(input.qrToken)), - isNull(wechatChannelPairingsTable.consumedAt), - gte(wechatChannelPairingsTable.expiresAt, input.nowMs), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - throw createWeChatPairingNotFoundError(); - } -} - -function createWeChatPairingSnapshot(input: { - qrCodeImageSrc: string | null; - qrToken: string | null; - status: WeChatQrPairingStatus; -}): WeChatQrPairingSnapshot { - return { - accountId: null, - baseUrl: null, - botToken: null, - expiresAtMs: null, - ilinkBotId: null, - ilinkUserId: null, - lastErrorCode: null, - qrCodeImageSrc: input.qrCodeImageSrc, - qrToken: input.qrToken, - status: input.status, - }; -} - -function toWeChatPairingPayload(input: { - binding?: WeChatAgentChannelPairing["binding"]; - snapshot: WeChatQrPairingSnapshot; -}): WeChatAgentChannelPairing { - return { - binding: input.binding ?? null, - lastErrorCode: input.snapshot.lastErrorCode, - qrCodeImageSrc: input.snapshot.qrCodeImageSrc, - qrToken: input.snapshot.qrToken, - status: input.snapshot.status, - }; -} - -function mapWeChatPairingError(error: unknown, code: ApiErrorCode): Error { - if (error instanceof WeChatIlinkApiError || error instanceof WeChatIlinkHttpError) { - return new ApiError(502, code, "WeChat iLink setup request failed."); - } - - if ( - error instanceof TypeError || - (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) - ) { - return new ApiError(502, code, "WeChat iLink setup request failed."); - } - - return error instanceof Error ? error : new Error("WeChat iLink setup request failed."); -} - -async function ensureAgentCanConnectWeChat( - database: D1Database, - viewer: AuthenticatedViewer, - input: { - agentId: AgentId; - appId: AppId; - }, -): Promise { - const viewerId = viewer.id; - const access = await ensureAppAgentOwner(database, viewerId, input); - - if (access.agent.status !== "published") { - throw validationError("Publish the Agent before connecting WeChat.", "AGENT_NOT_PUBLISHED"); - } - - await ensureProviderBindingAvailable(database, { - agentId: input.agentId, - appId: input.appId, - provider: "wechat", - }); -} - -function createWeChatIlinkClient(bindings: ApiBindings): WeChatIlinkClient { - return new WeChatIlinkClient( - bindings.WECHAT_ILINK_BASE_URL ? { baseUrl: bindings.WECHAT_ILINK_BASE_URL } : undefined, - ); -} - -export async function startWeChatAgentChannelPairing( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: StartWeChatAgentChannelPairingInput, -): Promise { - await ensureAgentCanConnectWeChat(bindings.DB, viewer, input); - const viewerId = viewer.id; - - let qr: Awaited>; - - try { - qr = await createWeChatIlinkClient(bindings).getBotQr(); - } catch (error) { - throw mapWeChatPairingError(error, "WECHAT_QR_START_FAILED"); - } - - const nowMs = currentTimestampMs(); - const qrToken = requireWeChatQrToken(qr.qrToken); - - await storeWeChatPendingPairing(bindings.DB, { - agentId: input.agentId, - createdByAccountId: viewerId, - nowMs, - appId: input.appId, - qrToken, - }); - - return toWeChatPairingPayload({ - snapshot: createWeChatPairingSnapshot({ - qrCodeImageSrc: qr.qrCodeImageContent, - qrToken, - status: "qr_pending", - }), - }); -} - -export async function pollWeChatAgentChannelPairing( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: PollWeChatAgentChannelPairingInput, -): Promise { - await ensureAgentCanConnectWeChat(bindings.DB, viewer, input); - const viewerId = viewer.id; - - const nowMs = currentTimestampMs(); - const qrToken = requireWeChatQrToken(input.qrToken); - - await ensureWeChatPendingPairing(bindings.DB, { - agentId: input.agentId, - createdByAccountId: viewerId, - nowMs, - appId: input.appId, - qrToken, - }); - - const current = createWeChatPairingSnapshot({ - qrCodeImageSrc: null, - qrToken, - status: "qr_pending", - }); - let snapshot: WeChatQrPairingSnapshot; - - try { - snapshot = applyWeChatQrStatusResponse( - current, - await createWeChatIlinkClient(bindings).getQrStatus({ qrToken }), - ); - } catch (error) { - throw mapWeChatPairingError(error, "WECHAT_QR_STATUS_FAILED"); - } - - if (snapshot.status !== "confirmed") { - if (snapshot.status === "expired" || snapshot.status === "failed") { - await consumeWeChatPendingPairing(bindings.DB, { - agentId: input.agentId, - createdByAccountId: viewerId, - nowMs, - appId: input.appId, - qrToken, - }); - } - - return toWeChatPairingPayload({ snapshot }); - } - - await consumeWeChatPendingPairing(bindings.DB, { - agentId: input.agentId, - createdByAccountId: viewerId, - nowMs, - appId: input.appId, - qrToken, - }); - - const account = await persistConfirmedWeChatQrPairing(bindings, viewer, { - agentId: input.agentId, - appId: input.appId, - snapshot, - }); - - return toWeChatPairingPayload({ - binding: await readAgentChannelBindingById(bindings.DB, account.id), - snapshot, - }); -} diff --git a/apps/api/src/modules/channels/application/agent-channel-binding.service.ts b/apps/api/src/modules/channels/application/agent-channel-binding.service.ts deleted file mode 100644 index d1b9218f..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - createDiscordAgentChannelBinding, - createLarkAgentChannelBinding, - createSlackAgentChannelBinding, - createTelegramAgentChannelBinding, -} from "./agent-channel-binding-create.service"; -export { deleteAgentChannelBinding } from "./agent-channel-binding-maintenance.service"; -export { - pollLarkAgentChannelRegistration, - startLarkAgentChannelRegistration, -} from "./agent-channel-binding-lark-registration.service"; -export { listAgentChannelBindings } from "./agent-channel-binding-records"; -export type { - AgentChannelBinding, - CreateDiscordAgentChannelBindingInput, - CreateLarkAgentChannelBindingInput, - CreateSlackAgentChannelBindingInput, - CreateTelegramAgentChannelBindingInput, - DeleteAgentChannelBindingInput, - LarkAgentChannelRegistration, - PollLarkAgentChannelRegistrationInput, - PollWeChatAgentChannelPairingInput, - StartLarkAgentChannelRegistrationInput, - StartWeChatAgentChannelPairingInput, - WeChatAgentChannelPairing, -} from "./agent-channel-binding.types"; -export { - pollWeChatAgentChannelPairing, - startWeChatAgentChannelPairing, -} from "./agent-channel-binding-wechat-pairing.service"; diff --git a/apps/api/src/modules/channels/application/agent-channel-binding.types.ts b/apps/api/src/modules/channels/application/agent-channel-binding.types.ts deleted file mode 100644 index d7570c30..00000000 --- a/apps/api/src/modules/channels/application/agent-channel-binding.types.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { PrimitiveRecord } from "@mosoo/contracts"; -import type { AgentChannelBindingProvider, AgentChannelBindingStatus } from "@mosoo/db"; -import type { AgentId, ChannelBindingId, AppId } from "@mosoo/id"; - -import type { LarkAppRegistrationStatus } from "../lark/lark-app-registration"; -import type { LarkConnectionMode } from "../lark/lark-credentials"; -import type { LarkDomain } from "../lark/lark-events"; -import type { WeChatQrPairingStatus } from "../wechat/wechat-runtime"; - -export interface CreateSlackAgentChannelBindingInput { - agentId: AgentId; - appLevelToken?: string | null; - botToken: string; - appId: AppId; - signingSecret: string; - threadRepliesRequireMention?: boolean | null; -} - -export interface CreateLarkAgentChannelBindingInput { - agentId: AgentId; - larkAppId: string; - appSecret: string; - connectionMode: LarkConnectionMode; - domain: LarkDomain; - encryptKey: string | null; - appId: AppId; - verificationToken: string | null; -} - -export interface StartLarkAgentChannelRegistrationInput { - agentId: AgentId; - domain: LarkDomain; - appId: AppId; -} - -export interface PollLarkAgentChannelRegistrationInput { - agentId: AgentId; - deviceCode: string; - domain: LarkDomain; - appId: AppId; -} - -export interface CreateTelegramAgentChannelBindingInput { - agentId: AgentId; - botToken: string; - appId: AppId; - webhookSecret: string; -} - -export interface CreateDiscordAgentChannelBindingInput { - agentId: AgentId; - applicationId: string; - botToken: string; - appId: AppId; - relaySecret: string; -} - -export interface StartWeChatAgentChannelPairingInput { - agentId: AgentId; - appId: AppId; -} - -export interface PollWeChatAgentChannelPairingInput { - agentId: AgentId; - appId: AppId; - qrToken: string; -} - -export interface DeleteAgentChannelBindingInput { - bindingId: ChannelBindingId; - appId: AppId; -} - -export interface RecordAgentChannelBindingErrorInput { - agentId: AgentId; - bindingId: ChannelBindingId; - errorCode: string; - appId: AppId; -} - -export interface AgentChannelBinding { - activityLastTriggeredAt: string | null; - activitySessionCount7d: number; - agentId: AgentId; - createdAt: string; - displayMetadata: PrimitiveRecord; - externalBotId: string; - externalTenantId: string; - id: ChannelBindingId; - lastErrorCode: string | null; - appId: AppId; - provider: AgentChannelBindingProvider; - status: AgentChannelBindingStatus; - updatedAt: string; -} - -export interface WeChatAgentChannelPairing { - binding: AgentChannelBinding | null; - lastErrorCode: string | null; - qrCodeImageSrc: string | null; - qrToken: string | null; - status: WeChatQrPairingStatus; -} - -export interface LarkAgentChannelRegistration { - appId: string | null; - appSecret: string | null; - deviceCode: string | null; - domain: LarkDomain; - expireIn: number | null; - interval: number | null; - lastErrorCode: string | null; - openId: string | null; - qrUrl: string | null; - status: LarkAppRegistrationStatus; - userCode: string | null; -} diff --git a/apps/api/src/modules/channels/application/channel-adapter-logger.ts b/apps/api/src/modules/channels/application/channel-adapter-logger.ts deleted file mode 100644 index 07a9f022..00000000 --- a/apps/api/src/modules/channels/application/channel-adapter-logger.ts +++ /dev/null @@ -1,31 +0,0 @@ -type ChannelAdapterLogContext = Record; - -function serializeError(error: unknown): ChannelAdapterLogContext { - if (error instanceof Error) { - return { - message: error.message, - name: error.name, - stack: error.stack, - }; - } - - return { - message: String(error), - }; -} - -export function logChannelAdapterError( - message: string, - error: unknown, - context: ChannelAdapterLogContext = {}, -): void { - globalThis.reportError( - new Error( - JSON.stringify({ - ...context, - error: serializeError(error), - message, - }), - ), - ); -} diff --git a/apps/api/src/modules/channels/application/channel-agent-reply.ts b/apps/api/src/modules/channels/application/channel-agent-reply.ts deleted file mode 100644 index b2633d03..00000000 --- a/apps/api/src/modules/channels/application/channel-agent-reply.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AgentId, SessionId, SessionRunId } from "@mosoo/id"; - -export const CHANNEL_AGENT_FAILURE_TEXT = "mosoo is having trouble responding. Try again later."; - -export function buildChannelSessionLink(input: { - agentId: AgentId; - sessionId: SessionId; - sessionLinkBaseUrl: string | null; -}): string { - const baseUrl = input.sessionLinkBaseUrl ?? ""; - const agentId = encodeURIComponent(input.agentId); - const sessionId = encodeURIComponent(input.sessionId); - - return `${baseUrl}/agent/${agentId}?tab=consume&sessionId=${sessionId}`; -} - -export function buildChannelWorkingText(input: { - linkLabel?: string; - sessionLink: string; -}): string { - const target = input.linkLabel ? `<${input.sessionLink}|${input.linkLabel}>` : input.sessionLink; - return `mosoo session created: ${target}. Agent is working...`; -} - -export type ChannelAgentReplyResult = - | { - status: "completed"; - text: string | null; - } - | { - status: "failed"; - text: string; - } - | { - status: "timeout"; - text: string | null; - }; - -export interface ChannelAgentReplyPollClient { - retrieveSessionReply( - sessionId: SessionId, - runId: SessionRunId, - ): Promise; -} diff --git a/apps/api/src/modules/channels/application/channel-binding-context.ts b/apps/api/src/modules/channels/application/channel-binding-context.ts deleted file mode 100644 index cad625ce..00000000 --- a/apps/api/src/modules/channels/application/channel-binding-context.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { accountsTable, agentChannelBindingsTable, agentsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, ChannelBindingId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { readAgentChannelBindingCredentialSecret } from "./channel-credential-secret-resolution"; -import { parseChannelDisplayMetadata } from "./channel-display-metadata"; -import type { AgentChannelBindingContext } from "./channel-session.types"; - -function toOwnerViewer(row: { - email: string; - emailVerified: boolean | number; - id: string; - imageUrl: string | null; - name: string; -}): AuthenticatedViewer { - return { - email: row.email, - emailVerified: row.emailVerified === true || row.emailVerified === 1, - id: parsePlatformId(row.id, "owner account ID"), - imageUrl: row.imageUrl, - name: row.name, - }; -} - -export async function resolveAgentChannelBindingContextById( - bindings: ApiBindings, - input: { - bindingId: ChannelBindingId; - provider: AgentChannelBindingProvider; - }, -): Promise { - const row = - (await getAppDatabase(bindings.DB) - .select({ - agentId: agentChannelBindingsTable.agentId, - agentAppId: agentsTable.appId, - agentStatus: agentsTable.status, - bindingId: agentChannelBindingsTable.id, - displayMetadataJson: agentChannelBindingsTable.displayMetadataJson, - encryptedCredsSecretId: agentChannelBindingsTable.encryptedCredsSecretId, - externalBotId: agentChannelBindingsTable.externalBotId, - externalTenantId: agentChannelBindingsTable.externalTenantId, - ownerEmail: accountsTable.email, - ownerEmailVerified: accountsTable.emailVerified, - ownerId: accountsTable.id, - ownerImageUrl: accountsTable.image, - ownerName: accountsTable.name, - appId: agentChannelBindingsTable.appId, - provider: agentChannelBindingsTable.provider, - }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .innerJoin(accountsTable, eq(accountsTable.id, agentsTable.ownerId)) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.provider, input.provider), - eq(agentChannelBindingsTable.status, "active"), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - return null; - } - - if (row.agentAppId !== row.appId) { - return null; - } - - return { - agentId: row.agentId, - agentStatus: row.agentStatus, - bindingId: row.bindingId, - credentialsJson: await readAgentChannelBindingCredentialSecret(bindings, { - bindingId: row.bindingId, - expectedOwner: { - agentId: row.agentId, - appId: row.appId, - }, - provider: row.provider, - purpose: "channel_context", - secretId: row.encryptedCredsSecretId, - }), - displayMetadata: parseChannelDisplayMetadata(row.displayMetadataJson), - externalBotId: row.externalBotId, - externalTenantId: row.externalTenantId, - owner: toOwnerViewer({ - email: row.ownerEmail, - emailVerified: row.ownerEmailVerified, - id: row.ownerId, - imageUrl: row.ownerImageUrl, - name: row.ownerName, - }), - appId: row.appId, - provider: row.provider, - }; -} diff --git a/apps/api/src/modules/channels/application/channel-connection-client.ts b/apps/api/src/modules/channels/application/channel-connection-client.ts deleted file mode 100644 index 7f4d76e5..00000000 --- a/apps/api/src/modules/channels/application/channel-connection-client.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import type { - ChannelConnection, - ChannelConnectionProvider, -} from "../../../adapters/durable-objects/channel-connection.do"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import type { DiscordGatewayStartResult } from "../discord/discord-gateway.do"; -import type { DiscordGatewayStopResult } from "../discord/discord-gateway.do"; -import type { LarkGatewayStartResult } from "../lark/lark-gateway.do"; -import type { LarkGatewayStopResult } from "../lark/lark-gateway.do"; - -interface ChannelConnectionKey { - readonly bindingId: ChannelBindingId; - readonly provider: ChannelConnectionProvider; -} - -function createChannelConnectionInstanceName(input: ChannelConnectionKey): string { - return `${input.provider}:${input.bindingId}`; -} - -function getChannelConnectionNamespace( - bindings: Pick, -): DurableObjectNamespace { - if (!bindings.ChannelConnection) { - throw new Error("ChannelConnection Durable Object binding is required."); - } - - return bindings.ChannelConnection; -} - -function getChannelConnectionStub( - bindings: Pick, - input: ChannelConnectionKey, -) { - const namespace = getChannelConnectionNamespace(bindings); - return namespace.get(namespace.idFromName(createChannelConnectionInstanceName(input))); -} - -export async function startChannelConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId; provider: "discord" }, -): Promise; -export async function startChannelConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId; provider: "lark" }, -): Promise; -export async function startChannelConnection( - bindings: Pick, - input: ChannelConnectionKey, -): Promise { - const stub = getChannelConnectionStub(bindings, input); - - switch (input.provider) { - case "discord": - return await stub.start("discord", input.bindingId); - case "lark": - return await stub.start("lark", input.bindingId); - } -} - -export async function stopChannelConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId; provider: "discord" }, -): Promise; -export async function stopChannelConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId; provider: "lark" }, -): Promise; -export async function stopChannelConnection( - bindings: Pick, - input: ChannelConnectionKey, -): Promise { - const stub = getChannelConnectionStub(bindings, input); - - switch (input.provider) { - case "discord": - return await stub.stop("discord", input.bindingId); - case "lark": - return await stub.stop("lark", input.bindingId); - } -} diff --git a/apps/api/src/modules/channels/application/channel-connection-health.ts b/apps/api/src/modules/channels/application/channel-connection-health.ts deleted file mode 100644 index 2f7432ce..00000000 --- a/apps/api/src/modules/channels/application/channel-connection-health.ts +++ /dev/null @@ -1,89 +0,0 @@ -export type ChannelConnectionOwnerStatus = - | "failed" - | "idle" - | "reconnecting" - | "relogin_required" - | "running" - | "stale" - | "starting" - | "stopped"; - -export interface ChannelConnectionKey { - accountId: string | null; - bindingId: ChannelBindingId; - provider: string; -} - -export interface ChannelConnectionOwnerSnapshot { - key: ChannelConnectionKey; - lastErrorCode: string | null; - lastHeartbeatAtMs: number | null; - lastInboundAtMs: number | null; - lastPollAtMs: number | null; - leaseExpiresAtMs: number | null; - leaseOwnerId: string | null; - status: ChannelConnectionOwnerStatus; - statusChangedAtMs: number; -} - -export interface ChannelConnectionHealthSummary { - reason: string | null; - stale: boolean; - status: ChannelConnectionOwnerStatus; -} - -function isRuntimeTransitionStatus(status: ChannelConnectionOwnerStatus): boolean { - return status === "starting" || status === "reconnecting"; -} - -export function summarizeChannelConnectionOwnerHealth( - snapshot: ChannelConnectionOwnerSnapshot, - input: { - nowMs: number; - staleAfterMs: number; - }, -): ChannelConnectionHealthSummary { - if (snapshot.leaseExpiresAtMs !== null && snapshot.leaseExpiresAtMs <= input.nowMs) { - return { - reason: "lease_expired", - stale: true, - status: "stale", - }; - } - - if ( - isRuntimeTransitionStatus(snapshot.status) && - input.nowMs - snapshot.statusChangedAtMs > input.staleAfterMs - ) { - return { - reason: snapshot.lastErrorCode ?? `${snapshot.status}_stale`, - stale: true, - status: "stale", - }; - } - - if (snapshot.status !== "running") { - return { - reason: snapshot.lastErrorCode, - stale: false, - status: snapshot.status, - }; - } - - const heartbeatReferenceMs = snapshot.lastHeartbeatAtMs ?? snapshot.statusChangedAtMs; - - if (input.nowMs - heartbeatReferenceMs > input.staleAfterMs) { - return { - reason: "heartbeat_stale", - stale: true, - status: "stale", - }; - } - - return { - reason: null, - stale: false, - status: "running", - }; -} -import type { ChannelBindingId } from "@mosoo/id"; diff --git a/apps/api/src/modules/channels/application/channel-connection-state.service.ts b/apps/api/src/modules/channels/application/channel-connection-state.service.ts deleted file mode 100644 index 2893bf47..00000000 --- a/apps/api/src/modules/channels/application/channel-connection-state.service.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { channelConnectionStatesTable } from "@mosoo/db"; -import type { - AgentChannelBindingProvider, - ChannelConnectionStateId, - ChannelConnectionStateRow, - ChannelConnectionStateStatus, -} from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { ChannelBindingId } from "@mosoo/id"; -import { and, eq, gt, isNull, lte, or } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import type { - ChannelConnectionKey, - ChannelConnectionOwnerSnapshot, -} from "./channel-connection-health"; - -export interface ChannelConnectionStatePayload { - readonly lastErrorCode?: string | null; - readonly lastHeartbeatAtMs?: number | null; - readonly lastInboundAtMs?: number | null; - readonly lastPollAtMs?: number | null; - readonly runtimeStateJson?: string; - readonly status: ChannelConnectionStateStatus; - readonly statusChangedAtMs: number; -} - -export interface ChannelConnectionOwnerStateRecord { - readonly runtimeStateJson: string; - readonly snapshot: ChannelConnectionOwnerSnapshot; -} - -function toRuntimeAccountId(accountId: string | null): string { - return accountId ?? ""; -} - -function fromRuntimeAccountId(runtimeAccountId: string): string | null { - return runtimeAccountId.length > 0 ? runtimeAccountId : null; -} - -function toRuntimeKey(row: ChannelConnectionStateRow): ChannelConnectionKey { - return { - accountId: fromRuntimeAccountId(row.runtimeAccountId), - bindingId: row.bindingId, - provider: row.provider, - }; -} - -function toRuntimeSnapshot(row: ChannelConnectionStateRow): ChannelConnectionOwnerSnapshot { - return { - key: toRuntimeKey(row), - lastErrorCode: row.lastErrorCode, - lastHeartbeatAtMs: row.lastHeartbeatAt, - lastInboundAtMs: row.lastInboundAt, - lastPollAtMs: row.lastPollAt, - leaseExpiresAtMs: row.leaseExpiresAt, - leaseOwnerId: row.leaseOwnerId, - status: row.status, - statusChangedAtMs: row.statusChangedAt, - }; -} - -function toStateFields(state: ChannelConnectionStatePayload) { - return { - lastErrorCode: state.lastErrorCode ?? null, - lastHeartbeatAt: state.lastHeartbeatAtMs ?? null, - lastInboundAt: state.lastInboundAtMs ?? null, - lastPollAt: state.lastPollAtMs ?? null, - runtimeStateJson: state.runtimeStateJson ?? "{}", - status: state.status, - statusChangedAt: state.statusChangedAtMs, - }; -} - -async function readRuntimeStateRow( - database: D1Database, - input: { - accountId: string | null; - bindingId: ChannelBindingId; - provider: AgentChannelBindingProvider; - }, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(channelConnectionStatesTable) - .where( - and( - eq(channelConnectionStatesTable.provider, input.provider), - eq(channelConnectionStatesTable.bindingId, input.bindingId), - eq(channelConnectionStatesTable.runtimeAccountId, toRuntimeAccountId(input.accountId)), - ), - ) - .limit(1) - .get()) ?? null - ); -} - -async function ensureRuntimeStateRow( - database: D1Database, - input: { - accountId: string | null; - bindingId: ChannelBindingId; - nowMs: number; - provider: AgentChannelBindingProvider; - }, -): Promise { - await getAppDatabase(database) - .insert(channelConnectionStatesTable) - .values({ - bindingId: input.bindingId, - createdAt: input.nowMs, - id: createPlatformId(), - lastErrorCode: null, - lastHeartbeatAt: null, - lastInboundAt: null, - lastPollAt: null, - leaseExpiresAt: null, - leaseOwnerId: null, - provider: input.provider, - runtimeAccountId: toRuntimeAccountId(input.accountId), - runtimeStateJson: "{}", - status: "idle", - statusChangedAt: input.nowMs, - updatedAt: input.nowMs, - }) - .onConflictDoNothing({ - target: [ - channelConnectionStatesTable.provider, - channelConnectionStatesTable.bindingId, - channelConnectionStatesTable.runtimeAccountId, - ], - }) - .run(); -} - -export async function claimChannelConnectionOwner(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - leaseDurationMs: number; - nowMs: number; - ownerId: string; - provider: AgentChannelBindingProvider; - state?: ChannelConnectionStatePayload; -}): Promise { - const accountId = input.accountId ?? null; - const state: ChannelConnectionStatePayload = input.state ?? { - status: "starting", - statusChangedAtMs: input.nowMs, - }; - - await ensureRuntimeStateRow(input.bindings.DB, { - accountId, - bindingId: input.bindingId, - nowMs: input.nowMs, - provider: input.provider, - }); - - const result = await getAppDatabase(input.bindings.DB) - .update(channelConnectionStatesTable) - .set({ - leaseExpiresAt: input.nowMs + input.leaseDurationMs, - leaseOwnerId: input.ownerId, - ...toStateFields(state), - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelConnectionStatesTable.provider, input.provider), - eq(channelConnectionStatesTable.bindingId, input.bindingId), - eq(channelConnectionStatesTable.runtimeAccountId, toRuntimeAccountId(accountId)), - or( - isNull(channelConnectionStatesTable.leaseExpiresAt), - lte(channelConnectionStatesTable.leaseExpiresAt, input.nowMs), - eq(channelConnectionStatesTable.leaseOwnerId, input.ownerId), - ), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - return null; - } - - return readChannelConnectionOwnerSnapshot({ - accountId, - bindingId: input.bindingId, - bindings: input.bindings, - provider: input.provider, - }); -} - -export async function renewChannelConnectionOwnerLease(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - leaseDurationMs: number; - nowMs: number; - ownerId: string; - provider: AgentChannelBindingProvider; - state: ChannelConnectionStatePayload; -}): Promise { - const accountId = input.accountId ?? null; - const result = await getAppDatabase(input.bindings.DB) - .update(channelConnectionStatesTable) - .set({ - leaseExpiresAt: input.nowMs + input.leaseDurationMs, - ...toStateFields(input.state), - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelConnectionStatesTable.provider, input.provider), - eq(channelConnectionStatesTable.bindingId, input.bindingId), - eq(channelConnectionStatesTable.runtimeAccountId, toRuntimeAccountId(accountId)), - eq(channelConnectionStatesTable.leaseOwnerId, input.ownerId), - gt(channelConnectionStatesTable.leaseExpiresAt, input.nowMs), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - return null; - } - - return readChannelConnectionOwnerSnapshot({ - accountId, - bindingId: input.bindingId, - bindings: input.bindings, - provider: input.provider, - }); -} - -export async function releaseChannelConnectionOwner(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - nowMs: number; - ownerId: string; - provider: AgentChannelBindingProvider; - status?: Extract; -}): Promise { - const accountId = input.accountId ?? null; - const result = await getAppDatabase(input.bindings.DB) - .update(channelConnectionStatesTable) - .set({ - leaseExpiresAt: null, - leaseOwnerId: null, - status: input.status ?? "stopped", - statusChangedAt: input.nowMs, - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelConnectionStatesTable.provider, input.provider), - eq(channelConnectionStatesTable.bindingId, input.bindingId), - eq(channelConnectionStatesTable.runtimeAccountId, toRuntimeAccountId(accountId)), - eq(channelConnectionStatesTable.leaseOwnerId, input.ownerId), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - return null; - } - - return readChannelConnectionOwnerSnapshot({ - accountId, - bindingId: input.bindingId, - bindings: input.bindings, - provider: input.provider, - }); -} - -export async function completeChannelConnectionOwner(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - nowMs: number; - ownerId: string; - provider: AgentChannelBindingProvider; - state: ChannelConnectionStatePayload; -}): Promise { - const accountId = input.accountId ?? null; - const result = await getAppDatabase(input.bindings.DB) - .update(channelConnectionStatesTable) - .set({ - leaseExpiresAt: null, - leaseOwnerId: null, - ...toStateFields(input.state), - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelConnectionStatesTable.provider, input.provider), - eq(channelConnectionStatesTable.bindingId, input.bindingId), - eq(channelConnectionStatesTable.runtimeAccountId, toRuntimeAccountId(accountId)), - eq(channelConnectionStatesTable.leaseOwnerId, input.ownerId), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - return null; - } - - return readChannelConnectionOwnerSnapshot({ - accountId, - bindingId: input.bindingId, - bindings: input.bindings, - provider: input.provider, - }); -} - -export async function readChannelConnectionOwnerSnapshot(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - provider: AgentChannelBindingProvider; -}): Promise { - const row = await readRuntimeStateRow(input.bindings.DB, { - accountId: input.accountId ?? null, - bindingId: input.bindingId, - provider: input.provider, - }); - - return row ? toRuntimeSnapshot(row) : null; -} - -export async function readChannelConnectionOwnerState(input: { - accountId?: string | null; - bindingId: ChannelBindingId; - bindings: Pick; - provider: AgentChannelBindingProvider; -}): Promise { - const row = await readRuntimeStateRow(input.bindings.DB, { - accountId: input.accountId ?? null, - bindingId: input.bindingId, - provider: input.provider, - }); - - if (!row) { - return null; - } - - return { - runtimeStateJson: row.runtimeStateJson, - snapshot: toRuntimeSnapshot(row), - }; -} diff --git a/apps/api/src/modules/channels/application/channel-credential-secret-resolution.ts b/apps/api/src/modules/channels/application/channel-credential-secret-resolution.ts deleted file mode 100644 index 3d849e56..00000000 --- a/apps/api/src/modules/channels/application/channel-credential-secret-resolution.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { agentChannelBindingsTable, agentsTable, vaultSecretsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider } from "@mosoo/db"; -import type { AgentId, ChannelBindingId, PlatformId, AppId } from "@mosoo/id"; -import { parsePlatformId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; -import { - deleteSecret, - readSecretOutcome, - storeSecret, -} from "../../vault/application/vault-secret-store"; - -export type ChannelCredentialSecretReadPurpose = - | "channel_callback" - | "channel_context" - | "channel_final_delivery"; - -export type ChannelCredentialSecretWritePurpose = - | "channel_binding_create" - | "channel_binding_update"; - -export type ChannelCredentialSecretDeletePurpose = - | "channel_binding_create_rollback" - | "channel_binding_delete" - | "channel_binding_orphan_maintenance" - | "channel_binding_replace_cleanup" - | "channel_binding_write_rollback"; - -export type ChannelCredentialSecretDeleteOutcome = - | { - status: "deleted" | "skipped"; - } - | { - agentId: AgentId; - provider: AgentChannelBindingProvider; - appId: AppId; - purpose: ChannelCredentialSecretDeletePurpose; - reason: "secret_kind_mismatch" | "secret_not_found"; - status: "denied"; - }; - -const AGENT_CHANNEL_BINDING_PROVIDERS = [ - "discord", - "lark", - "slack", - "telegram", - "wechat", -] as const satisfies readonly AgentChannelBindingProvider[]; - -export const CHANNEL_BINDING_CREDENTIAL_SECRET_KIND_PREFIX = "channel_binding:"; - -function assertChannelCredentialReadPurpose(purpose: ChannelCredentialSecretReadPurpose): void { - switch (purpose) { - case "channel_callback": - case "channel_context": - case "channel_final_delivery": { - return; - } - } -} - -function assertChannelCredentialWritePurpose(purpose: ChannelCredentialSecretWritePurpose): void { - switch (purpose) { - case "channel_binding_create": - case "channel_binding_update": { - return; - } - } -} - -interface AgentChannelBindingCredentialSecretOwner { - readonly agentId: AgentId; - readonly provider: AgentChannelBindingProvider; - readonly appId: AppId; -} - -function toAgentChannelBindingCredentialSecretKind( - owner: AgentChannelBindingCredentialSecretOwner, -): string { - return ["channel_binding", owner.appId, owner.agentId, owner.provider].join(":"); -} - -function isAgentChannelBindingProvider(value: string): value is AgentChannelBindingProvider { - return AGENT_CHANNEL_BINDING_PROVIDERS.some((provider) => provider === value); -} - -export function parseAgentChannelBindingCredentialSecretKind( - kind: string, -): AgentChannelBindingCredentialSecretOwner | null { - const [prefix, appId, agentId, provider, extra] = kind.split(":"); - - if ( - prefix !== "channel_binding" || - appId === undefined || - agentId === undefined || - provider === undefined || - extra !== undefined || - !isAgentChannelBindingProvider(provider) - ) { - return null; - } - - try { - return { - agentId: parsePlatformId(agentId, "agent ID"), - provider, - appId: parsePlatformId(appId, "app ID"), - }; - } catch { - return null; - } -} - -async function readVaultSecretKind( - database: D1Database, - secretId: PlatformId | string, -): Promise { - const row = - (await getAppDatabase(database) - .select({ kind: vaultSecretsTable.kind }) - .from(vaultSecretsTable) - .where(eq(vaultSecretsTable.id, parsePlatformId(secretId, "secretId"))) - .limit(1) - .get()) ?? null; - - return row?.kind ?? null; -} - -export async function storeAgentChannelBindingCredentialSecret( - bindings: ApiBindings, - input: AgentChannelBindingCredentialSecretOwner & { - readonly credentialsJson: string; - readonly purpose: ChannelCredentialSecretWritePurpose; - }, -): Promise { - assertChannelCredentialWritePurpose(input.purpose); - - return storeSecret(bindings.DB, bindings, { - kind: toAgentChannelBindingCredentialSecretKind(input), - value: input.credentialsJson, - }); -} - -export async function readAgentChannelBindingCredentialSecret( - bindings: ApiBindings, - input: { - bindingId: ChannelBindingId; - expectedOwner: { - readonly agentId: AgentId; - readonly appId: AppId; - }; - provider: AgentChannelBindingProvider; - purpose: ChannelCredentialSecretReadPurpose; - secretId: PlatformId; - }, -): Promise { - assertChannelCredentialReadPurpose(input.purpose); - - const row = - (await getAppDatabase(bindings.DB) - .select({ - agentId: agentChannelBindingsTable.agentId, - agentAppId: agentsTable.appId, - appId: agentChannelBindingsTable.appId, - secretKind: vaultSecretsTable.kind, - }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .innerJoin( - vaultSecretsTable, - eq(vaultSecretsTable.id, agentChannelBindingsTable.encryptedCredsSecretId), - ) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.provider, input.provider), - eq(agentChannelBindingsTable.status, "active"), - eq(agentChannelBindingsTable.encryptedCredsSecretId, input.secretId), - ), - ) - .limit(1) - .get()) ?? null; - - if ( - !row || - row.agentId !== input.expectedOwner.agentId || - row.appId !== input.expectedOwner.appId || - row.agentAppId !== row.appId || - row.secretKind !== - toAgentChannelBindingCredentialSecretKind({ - agentId: row.agentId, - provider: input.provider, - appId: row.appId, - }) - ) { - throw validationError("Channel binding credential is unavailable."); - } - - const secret = await readSecretOutcome(bindings.DB, bindings, input.secretId); - - if (secret.status === "missing") { - throw validationError("Channel binding credential is unavailable."); - } - - return secret.value; -} - -function denyAgentChannelBindingCredentialSecretDelete( - command: AgentChannelBindingCredentialSecretOwner & { - purpose: ChannelCredentialSecretDeletePurpose; - }, - reason: "secret_kind_mismatch" | "secret_not_found", -): ChannelCredentialSecretDeleteOutcome { - return { - agentId: command.agentId, - provider: command.provider, - appId: command.appId, - purpose: command.purpose, - reason, - status: "denied", - }; -} - -export async function deleteAgentChannelBindingCredentialSecret( - database: D1Database, - command: AgentChannelBindingCredentialSecretOwner & { - purpose: ChannelCredentialSecretDeletePurpose; - secretId: PlatformId | string | null | undefined; - }, -): Promise { - if (!isTruthy(command.secretId)) { - return { status: "skipped" }; - } - - const expectedKind = toAgentChannelBindingCredentialSecretKind(command); - const actualKind = await readVaultSecretKind(database, command.secretId); - - if (actualKind === null) { - return denyAgentChannelBindingCredentialSecretDelete(command, "secret_not_found"); - } - - if (actualKind !== expectedKind) { - return denyAgentChannelBindingCredentialSecretDelete(command, "secret_kind_mismatch"); - } - - await deleteSecret(database, command.secretId); - return { status: "deleted" }; -} - -export async function cleanupStoredAgentChannelBindingCredentialSecret(input: { - command: AgentChannelBindingCredentialSecretOwner & { - purpose: ChannelCredentialSecretDeletePurpose; - secretId: PlatformId | string | null | undefined; - }; - database: D1Database; -}): Promise { - try { - const outcome = await deleteAgentChannelBindingCredentialSecret(input.database, input.command); - - if (outcome.status !== "denied") { - return true; - } - - logError("agent-channel-binding.credential-secret-cleanup.denied", { - agentId: outcome.agentId, - provider: outcome.provider, - appId: outcome.appId, - purpose: outcome.purpose, - reason: outcome.reason, - secretId: input.command.secretId, - }); - } catch (error) { - logError("agent-channel-binding.credential-secret-cleanup.failed", { - ...createErrorLogContext(error), - agentId: input.command.agentId, - provider: input.command.provider, - appId: input.command.appId, - purpose: input.command.purpose, - secretId: input.command.secretId, - }); - } - - return false; -} diff --git a/apps/api/src/modules/channels/application/channel-display-metadata.ts b/apps/api/src/modules/channels/application/channel-display-metadata.ts deleted file mode 100644 index 39950fdd..00000000 --- a/apps/api/src/modules/channels/application/channel-display-metadata.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { PrimitiveRecord } from "@mosoo/contracts"; - -export function parseChannelDisplayMetadata(value: string): PrimitiveRecord { - const parsed: unknown = JSON.parse(value); - - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Channel binding display metadata must be an object."); - } - - return Object.fromEntries( - Object.entries(parsed).filter((entry): entry is [string, PrimitiveRecord[string]] => { - const metadataValue = entry[1]; - return ( - typeof metadataValue === "string" || - typeof metadataValue === "number" || - typeof metadataValue === "boolean" || - metadataValue === null - ); - }), - ); -} diff --git a/apps/api/src/modules/channels/application/channel-event-receipt-store.ts b/apps/api/src/modules/channels/application/channel-event-receipt-store.ts deleted file mode 100644 index 11e7a234..00000000 --- a/apps/api/src/modules/channels/application/channel-event-receipt-store.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { channelEventReceiptsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider, ChannelEventReceiptId } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { ChannelBindingId, SessionId } from "@mosoo/id"; -import { and, eq, isNull, lt } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import { getSessionRuntimeEventSourceReceipts } from "../../sessions/application/session-runtime-event-receipts.service"; - -export interface ChannelEventReceiptReservation { - duplicate: boolean; - receiptId: ChannelEventReceiptId | null; - sessionId: SessionId | null; -} - -const CHANNEL_EVENT_RECEIPT_STALE_MS = 5 * 60 * 1000; -const CHANNEL_EVENT_RECEIPT_TTL_MS = 7 * 24 * 60 * 60 * 1000; - -export async function hasProcessedChannelEvent(input: { - clientRequestId: string; - database: D1Database; - sessionId: SessionId; -}): Promise { - const receipts = await getSessionRuntimeEventSourceReceipts(input.database, { - sessionId: input.sessionId, - sourceEventIds: [input.clientRequestId], - }); - - return receipts.has(input.clientRequestId); -} - -async function deleteExpiredChannelEventReceipts(input: { - database: D1Database; - timestampMs: number; -}): Promise { - await getAppDatabase(input.database) - .delete(channelEventReceiptsTable) - .where(lt(channelEventReceiptsTable.expiresAt, input.timestampMs)) - .run(); -} - -export async function beginChannelEventReceipt(input: { - bindingId: ChannelBindingId; - database: D1Database; - externalEventId: string; - externalTenantId: string; - provider: AgentChannelBindingProvider; - retryStale?: boolean; -}): Promise { - const externalEventId = input.externalEventId.trim(); - - if (externalEventId.length === 0) { - throw new Error("Channel event receipt external event id is required."); - } - - const reservationId = createPlatformId(); - const timestampMs = currentTimestampMs(); - - await deleteExpiredChannelEventReceipts({ - database: input.database, - timestampMs, - }); - - await getAppDatabase(input.database) - .insert(channelEventReceiptsTable) - .values({ - bindingId: input.bindingId, - createdAt: timestampMs, - expiresAt: timestampMs + CHANNEL_EVENT_RECEIPT_TTL_MS, - externalEventId, - externalTenantId: input.externalTenantId, - id: reservationId, - provider: input.provider, - sessionId: null, - updatedAt: timestampMs, - }) - .onConflictDoNothing({ - target: [ - channelEventReceiptsTable.provider, - channelEventReceiptsTable.externalTenantId, - channelEventReceiptsTable.externalEventId, - ], - }) - .run(); - - const current = - (await getAppDatabase(input.database) - .select({ - id: channelEventReceiptsTable.id, - sessionId: channelEventReceiptsTable.sessionId, - updatedAt: channelEventReceiptsTable.updatedAt, - }) - .from(channelEventReceiptsTable) - .where( - and( - eq(channelEventReceiptsTable.provider, input.provider), - eq(channelEventReceiptsTable.externalTenantId, input.externalTenantId), - eq(channelEventReceiptsTable.externalEventId, externalEventId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!current) { - throw new Error("Channel event receipt reservation could not be confirmed."); - } - - if ( - current.id !== reservationId && - current.sessionId === null && - input.retryStale !== false && - current.updatedAt < timestampMs - CHANNEL_EVENT_RECEIPT_STALE_MS - ) { - await getAppDatabase(input.database) - .delete(channelEventReceiptsTable) - .where( - and( - eq(channelEventReceiptsTable.id, current.id), - isNull(channelEventReceiptsTable.sessionId), - lt(channelEventReceiptsTable.updatedAt, timestampMs - CHANNEL_EVENT_RECEIPT_STALE_MS), - ), - ) - .run(); - - return beginChannelEventReceipt({ - ...input, - retryStale: false, - }); - } - - return { - duplicate: current.id !== reservationId, - receiptId: current.id === reservationId ? reservationId : null, - sessionId: current.sessionId, - }; -} - -export async function completeChannelEventReceipt(input: { - database: D1Database; - receiptId: ChannelEventReceiptId | null; - sessionId: SessionId; -}): Promise { - if (!input.receiptId) { - return; - } - - const timestampMs = currentTimestampMs(); - - await getAppDatabase(input.database) - .update(channelEventReceiptsTable) - .set({ - expiresAt: timestampMs + CHANNEL_EVENT_RECEIPT_TTL_MS, - sessionId: input.sessionId, - updatedAt: timestampMs, - }) - .where(eq(channelEventReceiptsTable.id, input.receiptId)) - .run(); -} - -export async function clearChannelEventReceipt(input: { - database: D1Database; - receiptId: ChannelEventReceiptId | null; -}): Promise { - if (!input.receiptId) { - return; - } - - await getAppDatabase(input.database) - .delete(channelEventReceiptsTable) - .where(eq(channelEventReceiptsTable.id, input.receiptId)) - .run(); -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery-errors.ts b/apps/api/src/modules/channels/application/channel-final-delivery-errors.ts deleted file mode 100644 index 90911bd1..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery-errors.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { DiscordWebApiError } from "../discord/discord-web-api"; -import { isLarkCredentialScopedError, LarkWebApiError } from "../lark/lark-web-api"; -import { SlackWebApiError } from "../slack/slack-web-api"; -import { isTelegramCredentialScopedError, TelegramWebApiError } from "../telegram/telegram-web-api"; -import { WeChatIlinkApiError, WeChatIlinkHttpError } from "../wechat/wechat-ilink-client"; -import { WeChatReplyError } from "../wechat/wechat-reply.service"; -import type { ChannelSessionCommandClient } from "./channel-session.types"; - -function shouldMarkSlackBindingError(error: SlackWebApiError): boolean { - return ( - error.code === "account_inactive" || - error.code === "invalid_auth" || - error.code === "missing_scope" || - error.code === "not_authed" || - error.code === "token_revoked" - ); -} - -function shouldMarkDiscordBindingError(error: DiscordWebApiError): boolean { - const code = error.code.toLowerCase(); - - return ( - code.includes("401") || - code.includes("invalid token") || - code.includes("unauthorized") || - code.includes("token") - ); -} - -export function isCredentialScopedDeliveryError(error: unknown): boolean { - if (error instanceof DiscordWebApiError) { - return shouldMarkDiscordBindingError(error); - } - - if (error instanceof LarkWebApiError) { - return isLarkCredentialScopedError(error); - } - - if (error instanceof SlackWebApiError) { - return shouldMarkSlackBindingError(error); - } - - if (error instanceof TelegramWebApiError) { - return isTelegramCredentialScopedError(error); - } - - if (error instanceof WeChatIlinkApiError) { - return error.code === "missing_bot_token" || error.code === "ilink_-14"; - } - - if (error instanceof WeChatIlinkHttpError) { - return error.status === 401 || error.status === 403; - } - - return false; -} - -export async function markBindingErrorIfCredentialScoped(input: { - error: unknown; - sessionClient: ChannelSessionCommandClient; -}): Promise { - if (input.error instanceof DiscordWebApiError && shouldMarkDiscordBindingError(input.error)) { - await input.sessionClient.markBindingError(input.error.code); - return; - } - - if (input.error instanceof LarkWebApiError && isLarkCredentialScopedError(input.error)) { - await input.sessionClient.markBindingError(input.error.code); - return; - } - - if (input.error instanceof SlackWebApiError && shouldMarkSlackBindingError(input.error)) { - await input.sessionClient.markBindingError(input.error.code); - return; - } - - if (input.error instanceof TelegramWebApiError && isTelegramCredentialScopedError(input.error)) { - await input.sessionClient.markBindingError(input.error.code); - return; - } - - if ( - input.error instanceof WeChatIlinkApiError && - (input.error.code === "missing_bot_token" || input.error.code === "ilink_-14") - ) { - await input.sessionClient.markBindingError(input.error.code); - return; - } - - if ( - input.error instanceof WeChatIlinkHttpError && - (input.error.status === 401 || input.error.status === 403) - ) { - await input.sessionClient.markBindingError(`http_${input.error.status}`); - } -} - -export function getDeliveryErrorCode(error: unknown): string { - if (error instanceof DiscordWebApiError) { - return error.code; - } - - if (error instanceof LarkWebApiError) { - return error.code; - } - - if (error instanceof SlackWebApiError) { - return error.code; - } - - if (error instanceof TelegramWebApiError) { - return error.code; - } - - if (error instanceof WeChatIlinkApiError) { - return error.code; - } - - if (error instanceof WeChatIlinkHttpError) { - return `http_${error.status}`; - } - - if (error instanceof WeChatReplyError) { - return error.code; - } - - if (error instanceof Error) { - return error.name; - } - - return "unknown_error"; -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery-jobs.ts b/apps/api/src/modules/channels/application/channel-final-delivery-jobs.ts deleted file mode 100644 index 97869d8e..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery-jobs.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { channelFinalDeliveryJobsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider, ChannelFinalDeliveryJobId } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { ChannelBindingId, SessionId, SessionRunId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import type { - ChannelFinalDeliveryMessage, - ChannelFinalDeliveryPayload, -} from "./channel-final-delivery-message"; -import { parseChannelFinalDeliveryPayload } from "./channel-final-delivery-payload"; - -type ChannelFinalDeliveryProvider = ChannelFinalDeliveryPayload["provider"]; -type ChannelFinalDeliveryPayloadFor = Extract< - ChannelFinalDeliveryPayload, - { provider: Provider } ->; - -interface EnqueueChannelFinalDeliveryJobInputBase { - bindingId: ChannelBindingId; - externalEventId: string; - runId: SessionRunId; - sessionId: SessionId; -} - -type EnqueueChannelFinalDeliveryJobInputFor = - EnqueueChannelFinalDeliveryJobInputBase & { - payload: ChannelFinalDeliveryPayloadFor; - provider: Provider; - }; - -export type EnqueueChannelFinalDeliveryJobInput = - | EnqueueChannelFinalDeliveryJobInputFor<"discord"> - | EnqueueChannelFinalDeliveryJobInputFor<"lark"> - | EnqueueChannelFinalDeliveryJobInputFor<"slack"> - | EnqueueChannelFinalDeliveryJobInputFor<"telegram"> - | EnqueueChannelFinalDeliveryJobInputFor<"wechat">; - -export interface ChannelFinalDeliveryScheduler { - enqueue(input: EnqueueChannelFinalDeliveryJobInput): Promise; -} - -export interface JobLedgerRow { - attemptCount: number; - bindingId: ChannelBindingId; - externalEventId: string; - lastErrorCode: string | null; - payloadJson: string; - provider: AgentChannelBindingProvider; - runId: SessionRunId; - sessionId: SessionId; - status: "delivered" | "dispatched" | "failed"; -} - -const DELIVERY_CLAIM_PREFIX = "delivery_claim:"; - -export interface ChannelFinalDeliveryClaim { - attemptCount: number; - claimCode: string; - leaseExpiresAtMs: number; - ownerId: string; -} - -export function parseActiveChannelFinalDeliveryClaim( - lastErrorCode: string | null, - nowMs: number, -): Omit | null { - if (!lastErrorCode?.startsWith(DELIVERY_CLAIM_PREFIX)) { - return null; - } - - const [ownerId, expiresAtRaw] = lastErrorCode.slice(DELIVERY_CLAIM_PREFIX.length).split(":"); - const leaseExpiresAtMs = Number(expiresAtRaw); - - if (!ownerId || !Number.isSafeInteger(leaseExpiresAtMs) || leaseExpiresAtMs <= nowMs) { - return null; - } - - return { leaseExpiresAtMs, ownerId }; -} - -function createDeliveryClaimCode(input: { leaseExpiresAtMs: number; ownerId: string }): string { - return `${DELIVERY_CLAIM_PREFIX}${input.ownerId}:${input.leaseExpiresAtMs}`; -} - -export async function claimJobForDelivery(input: { - database: D1Database; - expectedAttemptCount: number; - jobId: ChannelFinalDeliveryJobId; - leaseDurationMs: number; - nowMs: number; - ownerId: string; -}): Promise { - const leaseExpiresAtMs = input.nowMs + input.leaseDurationMs; - const attemptCount = input.expectedAttemptCount + 1; - const claimCode = createDeliveryClaimCode({ - leaseExpiresAtMs, - ownerId: input.ownerId, - }); - const result = await getAppDatabase(input.database) - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount, - lastErrorCode: claimCode, - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelFinalDeliveryJobsTable.id, input.jobId), - eq(channelFinalDeliveryJobsTable.status, "dispatched"), - eq(channelFinalDeliveryJobsTable.attemptCount, input.expectedAttemptCount), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - return null; - } - - return { - attemptCount, - claimCode, - leaseExpiresAtMs, - ownerId: input.ownerId, - }; -} - -export async function markJobDelivered(input: { - attemptCount: number; - claimCode: string; - database: D1Database; - jobId: ChannelFinalDeliveryJobId; - nowMs: number; -}): Promise { - const result = await getAppDatabase(input.database) - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount: input.attemptCount, - lastErrorCode: null, - status: "delivered", - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelFinalDeliveryJobsTable.id, input.jobId), - eq(channelFinalDeliveryJobsTable.status, "dispatched"), - eq(channelFinalDeliveryJobsTable.attemptCount, input.attemptCount), - eq(channelFinalDeliveryJobsTable.lastErrorCode, input.claimCode), - ), - ) - .run(); - - return getD1ChangeCount(result) > 0; -} - -export async function markJobFailed(input: { - attemptCount: number; - database: D1Database; - errorCode: string; - jobId: ChannelFinalDeliveryJobId; - nowMs: number; -}): Promise { - await getAppDatabase(input.database) - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount: input.attemptCount, - lastErrorCode: input.errorCode, - status: "failed", - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelFinalDeliveryJobsTable.id, input.jobId), - eq(channelFinalDeliveryJobsTable.status, "dispatched"), - ), - ) - .run(); -} - -export async function recordJobAttempt(input: { - attemptCount: number; - database: D1Database; - errorCode: string | null; - jobId: ChannelFinalDeliveryJobId; - nowMs: number; -}): Promise { - await getAppDatabase(input.database) - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount: input.attemptCount, - lastErrorCode: input.errorCode, - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelFinalDeliveryJobsTable.id, input.jobId), - eq(channelFinalDeliveryJobsTable.status, "dispatched"), - ), - ) - .run(); -} - -export async function recordJobWait(input: { - database: D1Database; - jobId: ChannelFinalDeliveryJobId; - nowMs: number; -}): Promise { - await getAppDatabase(input.database) - .update(channelFinalDeliveryJobsTable) - .set({ - updatedAt: input.nowMs, - }) - .where( - and( - eq(channelFinalDeliveryJobsTable.id, input.jobId), - eq(channelFinalDeliveryJobsTable.status, "dispatched"), - ), - ) - .run(); -} - -async function deleteJobDedupeRow(input: { - database: D1Database; - jobId: ChannelFinalDeliveryJobId; -}): Promise { - await getAppDatabase(input.database) - .delete(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, input.jobId)) - .run(); -} - -export async function enqueueChannelFinalDeliveryJob( - bindings: ApiBindings, - input: EnqueueChannelFinalDeliveryJobInput, - nowMs = currentTimestampMs(), -): Promise { - const payload = parseChannelFinalDeliveryPayload(input.provider, input.payload); - const jobId = createPlatformId(); - const insertResult = await getAppDatabase(bindings.DB) - .insert(channelFinalDeliveryJobsTable) - .values({ - attemptCount: 0, - bindingId: input.bindingId, - createdAt: nowMs, - externalEventId: input.externalEventId, - id: jobId, - lastErrorCode: null, - payloadJson: JSON.stringify(payload), - provider: input.provider, - runId: input.runId, - sessionId: input.sessionId, - status: "dispatched", - updatedAt: nowMs, - }) - .onConflictDoNothing({ - target: [ - channelFinalDeliveryJobsTable.provider, - channelFinalDeliveryJobsTable.bindingId, - channelFinalDeliveryJobsTable.externalEventId, - ], - }) - .run(); - - if (getD1ChangeCount(insertResult) === 0) { - return null; - } - - try { - const message: ChannelFinalDeliveryMessage = { jobId }; - await bindings.CHANNEL_FINAL_DELIVERY_QUEUE.send(message); - } catch (error) { - await deleteJobDedupeRow({ database: bindings.DB, jobId }); - throw error; - } - - return jobId; -} - -export function createChannelFinalDeliveryScheduler( - bindings: ApiBindings, -): ChannelFinalDeliveryScheduler { - return { - async enqueue(input) { - await enqueueChannelFinalDeliveryJob(bindings, input); - }, - }; -} - -export async function readJobLedger(input: { - database: D1Database; - jobId: ChannelFinalDeliveryJobId; -}): Promise { - const row = await getAppDatabase(input.database) - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - bindingId: channelFinalDeliveryJobsTable.bindingId, - externalEventId: channelFinalDeliveryJobsTable.externalEventId, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - payloadJson: channelFinalDeliveryJobsTable.payloadJson, - provider: channelFinalDeliveryJobsTable.provider, - runId: channelFinalDeliveryJobsTable.runId, - sessionId: channelFinalDeliveryJobsTable.sessionId, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, input.jobId)) - .limit(1) - .get(); - - return row ?? null; -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery-message.ts b/apps/api/src/modules/channels/application/channel-final-delivery-message.ts deleted file mode 100644 index 1d4c5b45..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery-message.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ChannelFinalDeliveryJobId } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; - -import type { SlackMessageReference } from "../slack/slack-web-api"; - -export interface SlackFinalDeliveryPayload { - channelId: string; - provider: "slack"; - threadTs: string; - workingMessage: SlackMessageReference | null; -} - -export interface LarkFinalDeliveryPayload { - messageId: string; - provider: "lark"; -} - -export interface TelegramFinalDeliveryPayload { - chatId: string; - messageThreadId: number | null; - provider: "telegram"; -} - -export interface DiscordFinalDeliveryPayload { - channelId: string; - provider: "discord"; - workingMessage: { - channelId: string; - messageId: string; - }; -} - -export interface WeChatFinalDeliveryPayload { - peerId: string; - provider: "wechat"; -} - -export type ChannelFinalDeliveryPayload = - | DiscordFinalDeliveryPayload - | LarkFinalDeliveryPayload - | SlackFinalDeliveryPayload - | TelegramFinalDeliveryPayload - | WeChatFinalDeliveryPayload; - -export interface ChannelFinalDeliveryMessage { - jobId: ChannelFinalDeliveryJobId; -} - -export function parseChannelFinalDeliveryMessage(value: unknown): ChannelFinalDeliveryMessage { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error("Channel final delivery queue message must be an object."); - } - - const jobId = (value as Record)["jobId"]; - - return { - jobId: parsePlatformId( - jobId, - "Channel final delivery queue message jobId", - ), - }; -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery-payload.ts b/apps/api/src/modules/channels/application/channel-final-delivery-payload.ts deleted file mode 100644 index c7b8fbd0..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery-payload.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { AgentChannelBindingProvider } from "@mosoo/db"; - -import type { SlackMessageReference } from "../slack/slack-web-api"; -import type { - ChannelFinalDeliveryPayload, - DiscordFinalDeliveryPayload, -} from "./channel-final-delivery-message"; - -export class ChannelFinalDeliveryPayloadError extends Error { - override name = "ChannelFinalDeliveryPayloadError"; -} - -function payloadError(message: string): ChannelFinalDeliveryPayloadError { - return new ChannelFinalDeliveryPayloadError(message); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - throw payloadError(`Channel final delivery payload ${field} is required.`); -} - -function readNullableNumber(value: Record, field: string): number | null { - const candidate = value[field]; - - if (candidate === null || candidate === undefined) { - return null; - } - - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return candidate; - } - - throw payloadError(`Channel final delivery payload ${field} must be a number or null.`); -} - -function parseSlackWorkingMessage(value: unknown): SlackMessageReference | null { - if (value === null) { - return null; - } - - if (!isRecord(value)) { - throw payloadError("Slack final delivery working message must be an object or null."); - } - - return { - channelId: readString(value, "channelId"), - ts: readString(value, "ts"), - }; -} - -function parseDiscordWorkingMessage(value: unknown): DiscordFinalDeliveryPayload["workingMessage"] { - if (!isRecord(value)) { - throw payloadError("Discord final delivery working message must be an object."); - } - - return { - channelId: readString(value, "channelId"), - messageId: readString(value, "messageId"), - }; -} - -export function parseChannelFinalDeliveryPayload( - provider: AgentChannelBindingProvider, - value: unknown, -): ChannelFinalDeliveryPayload { - if (!isRecord(value) || value["provider"] !== provider) { - throw payloadError("Channel final delivery payload provider does not match the job."); - } - - switch (provider) { - case "discord": { - return { - channelId: readString(value, "channelId"), - provider, - workingMessage: parseDiscordWorkingMessage(value["workingMessage"]), - }; - } - case "lark": { - return { - messageId: readString(value, "messageId"), - provider, - }; - } - case "slack": { - return { - channelId: readString(value, "channelId"), - provider, - threadTs: readString(value, "threadTs"), - workingMessage: parseSlackWorkingMessage(value["workingMessage"]), - }; - } - case "telegram": { - return { - chatId: readString(value, "chatId"), - messageThreadId: readNullableNumber(value, "messageThreadId"), - provider, - }; - } - case "wechat": { - return { - peerId: readString(value, "peerId"), - provider, - }; - } - default: { - throw payloadError("Unsupported channel final delivery provider."); - } - } -} - -export function parseChannelFinalDeliveryPayloadJson( - provider: AgentChannelBindingProvider, - payloadJson: string, -): ChannelFinalDeliveryPayload { - try { - const parsed: unknown = JSON.parse(payloadJson); - return parseChannelFinalDeliveryPayload(provider, parsed); - } catch (error) { - if (error instanceof ChannelFinalDeliveryPayloadError) { - throw error; - } - - throw payloadError("Channel final delivery payload JSON is invalid."); - } -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery-reply.ts b/apps/api/src/modules/channels/application/channel-final-delivery-reply.ts deleted file mode 100644 index b932b3bd..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery-reply.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { SessionId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { parseDiscordCredentials } from "../discord/discord-credentials"; -import { DiscordWebApiClient } from "../discord/discord-web-api"; -import { parseLarkCredentials } from "../lark/lark-credentials"; -import { LarkWebApiClient } from "../lark/lark-web-api"; -import { parseSlackCredentials } from "../slack/slack-credentials"; -import { SlackWebApiClient } from "../slack/slack-web-api"; -import { parseTelegramCredentials } from "../telegram/telegram-credentials"; -import { TelegramWebApiClient } from "../telegram/telegram-web-api"; -import { sendWeChatStoredContextReply } from "../wechat/wechat-reply.service"; -import { buildChannelSessionLink } from "./channel-agent-reply"; -import type { ChannelAgentReplyResult } from "./channel-agent-reply"; -import type { - ChannelFinalDeliveryPayload, - DiscordFinalDeliveryPayload, - LarkFinalDeliveryPayload, - SlackFinalDeliveryPayload, - TelegramFinalDeliveryPayload, - WeChatFinalDeliveryPayload, -} from "./channel-final-delivery-message"; -import type { AgentChannelBindingContext as ResolvedAgentChannelBindingContext } from "./channel-session.types"; - -type AgentChannelBindingContext = ResolvedAgentChannelBindingContext | null; - -function truncateReplyText(input: { maxLength: number; text: string }): string { - if (input.text.length <= input.maxLength) { - return input.text; - } - - return `${input.text.slice(0, input.maxLength - 70)}\n\n[mosoo reply truncated. Open the session for the full output.]`; -} - -function buildAgentReplyText(input: { - maxLength: number | null; - result: ChannelAgentReplyResult; - sessionId: SessionId; - sessionLabel: string; -}): string { - const text = input.result.text; - const replyText = - text && input.maxLength - ? truncateReplyText({ maxLength: input.maxLength, text }) - : (text ?? null); - - switch (input.result.status) { - case "completed": { - return replyText - ? `${input.sessionLabel}\n\n${replyText}` - : `${input.sessionLabel}\n\nAgent completed. Open mosoo for the full output.`; - } - case "failed": { - return `${input.sessionLabel}\n\nAgent run failed: ${replyText ?? "Open mosoo for details."}`; - } - case "timeout": { - return replyText - ? [ - input.sessionLabel, - "", - replyText, - "", - "Agent is still running. Open mosoo for the latest output.", - ].join("\n") - : `${input.sessionLabel}\n\nAgent is still running. Open mosoo for the latest output.`; - } - default: { - throw new Error("Unsupported channel final delivery result status."); - } - } -} - -async function sendDiscordFinalReply(input: { - binding: AgentChannelBindingContext; - payload: DiscordFinalDeliveryPayload; - providerRequestTimeoutMs: number; - result: ChannelAgentReplyResult; - sessionId: SessionId; -}): Promise { - const binding = input.binding; - - if (!binding) { - throw new Error("Discord final delivery binding is missing."); - } - - const credentials = parseDiscordCredentials(binding.credentialsJson); - const discord = new DiscordWebApiClient(credentials.botToken, { - timeoutMs: input.providerRequestTimeoutMs, - }); - - await discord.editMessage({ - channelId: input.payload.workingMessage.channelId, - messageId: input.payload.workingMessage.messageId, - text: buildAgentReplyText({ - maxLength: 1900, - result: input.result, - sessionId: input.sessionId, - sessionLabel: `mosoo session ${input.sessionId}`, - }), - }); -} - -async function sendSlackFinalReply(input: { - binding: AgentChannelBindingContext; - payload: SlackFinalDeliveryPayload; - providerRequestTimeoutMs: number; - result: ChannelAgentReplyResult; - sessionId: SessionId; - sessionLinkBaseUrl: string | null; -}): Promise { - const binding = input.binding; - - if (!binding) { - throw new Error("Slack final delivery binding is missing."); - } - - const credentials = parseSlackCredentials(binding.credentialsJson); - const slack = new SlackWebApiClient(credentials.botToken, { - timeoutMs: input.providerRequestTimeoutMs, - }); - const sessionLink = buildChannelSessionLink({ - agentId: binding.agentId, - sessionId: input.sessionId, - sessionLinkBaseUrl: input.sessionLinkBaseUrl, - }); - const text = buildAgentReplyText({ - maxLength: 3500, - result: input.result, - sessionId: input.sessionId, - sessionLabel: `mosoo session <${sessionLink}|${input.sessionId}>`, - }); - - if (input.payload.workingMessage) { - await slack.updateMessage({ - channelId: input.payload.workingMessage.channelId, - text, - ts: input.payload.workingMessage.ts, - }); - return; - } - - await slack.postChatMessage({ - channelId: input.payload.channelId, - text, - threadTs: input.payload.threadTs, - }); -} - -async function sendLarkFinalReply(input: { - binding: AgentChannelBindingContext; - payload: LarkFinalDeliveryPayload; - providerRequestTimeoutMs: number; - result: ChannelAgentReplyResult; - sessionId: SessionId; -}): Promise { - const binding = input.binding; - - if (!binding) { - throw new Error("Lark final delivery binding is missing."); - } - - const credentials = parseLarkCredentials(binding.credentialsJson); - const lark = new LarkWebApiClient({ - ...credentials, - timeoutMs: input.providerRequestTimeoutMs, - }); - const tenantAccessToken = await lark.getTenantAccessToken(); - - await lark.replyMessage({ - messageId: input.payload.messageId, - tenantAccessToken, - text: buildAgentReplyText({ - maxLength: null, - result: input.result, - sessionId: input.sessionId, - sessionLabel: `mosoo session ${input.sessionId}`, - }), - }); -} - -async function sendTelegramFinalReply(input: { - binding: AgentChannelBindingContext; - payload: TelegramFinalDeliveryPayload; - providerRequestTimeoutMs: number; - result: ChannelAgentReplyResult; - sessionId: SessionId; -}): Promise { - const binding = input.binding; - - if (!binding) { - throw new Error("Telegram final delivery binding is missing."); - } - - const credentials = parseTelegramCredentials(binding.credentialsJson); - const telegram = new TelegramWebApiClient(credentials.botToken, { - timeoutMs: input.providerRequestTimeoutMs, - }); - - await telegram.sendMessage({ - chatId: input.payload.chatId, - messageThreadId: input.payload.messageThreadId, - text: buildAgentReplyText({ - maxLength: 3900, - result: input.result, - sessionId: input.sessionId, - sessionLabel: `mosoo session ${input.sessionId}`, - }), - }); -} - -async function sendWeChatFinalReply(input: { - binding: AgentChannelBindingContext; - bindings: ApiBindings; - payload: WeChatFinalDeliveryPayload; - result: ChannelAgentReplyResult; - sessionId: SessionId; -}): Promise { - const binding = input.binding; - - if (!binding) { - throw new Error("WeChat final delivery binding is missing."); - } - - await sendWeChatStoredContextReply(input.bindings, { - accountId: binding.bindingId, - peerId: input.payload.peerId, - text: buildAgentReplyText({ - maxLength: 3000, - result: input.result, - sessionId: input.sessionId, - sessionLabel: `mosoo session ${input.sessionId}`, - }), - }); -} - -export async function sendProviderFinalReply(input: { - binding: AgentChannelBindingContext; - bindings: ApiBindings; - payload: ChannelFinalDeliveryPayload; - providerRequestTimeoutMs: number; - result: ChannelAgentReplyResult; - sessionId: SessionId; - sessionLinkBaseUrl: string | null; -}): Promise { - switch (input.payload.provider) { - case "discord": { - await sendDiscordFinalReply({ - binding: input.binding, - payload: input.payload, - providerRequestTimeoutMs: input.providerRequestTimeoutMs, - result: input.result, - sessionId: input.sessionId, - }); - return; - } - case "lark": { - await sendLarkFinalReply({ - binding: input.binding, - payload: input.payload, - providerRequestTimeoutMs: input.providerRequestTimeoutMs, - result: input.result, - sessionId: input.sessionId, - }); - return; - } - case "slack": { - await sendSlackFinalReply({ - binding: input.binding, - payload: input.payload, - providerRequestTimeoutMs: input.providerRequestTimeoutMs, - result: input.result, - sessionId: input.sessionId, - sessionLinkBaseUrl: input.sessionLinkBaseUrl, - }); - return; - } - case "telegram": { - await sendTelegramFinalReply({ - binding: input.binding, - payload: input.payload, - providerRequestTimeoutMs: input.providerRequestTimeoutMs, - result: input.result, - sessionId: input.sessionId, - }); - return; - } - case "wechat": { - await sendWeChatFinalReply({ - binding: input.binding, - bindings: input.bindings, - payload: input.payload, - result: input.result, - sessionId: input.sessionId, - }); - return; - } - default: { - throw new Error("Unsupported channel final delivery provider."); - } - } -} diff --git a/apps/api/src/modules/channels/application/channel-final-delivery.service.ts b/apps/api/src/modules/channels/application/channel-final-delivery.service.ts deleted file mode 100644 index a2d51ff2..00000000 --- a/apps/api/src/modules/channels/application/channel-final-delivery.service.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { sessionsTable } from "@mosoo/db"; -import type { SessionId } from "@mosoo/id"; -import { eq } from "drizzle-orm"; - -import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import { resolveAgentChannelBindingContextById } from "./channel-binding-context"; -import { - getDeliveryErrorCode, - isCredentialScopedDeliveryError, - markBindingErrorIfCredentialScoped, -} from "./channel-final-delivery-errors"; -import { - claimJobForDelivery, - markJobDelivered, - markJobFailed, - parseActiveChannelFinalDeliveryClaim, - readJobLedger, - recordJobAttempt, - recordJobWait, -} from "./channel-final-delivery-jobs"; -import { parseChannelFinalDeliveryMessage } from "./channel-final-delivery-message"; -import type { ChannelFinalDeliveryMessage } from "./channel-final-delivery-message"; -import { - ChannelFinalDeliveryPayloadError, - parseChannelFinalDeliveryPayloadJson, -} from "./channel-final-delivery-payload"; -import { sendProviderFinalReply } from "./channel-final-delivery-reply"; -import { createChannelSessionClient } from "./channel-session-command-client"; - -const CHANNEL_FINAL_DELIVERY_RETRY_DELAY_SECONDS = 60; -const CHANNEL_FINAL_DELIVERY_WAIT_DELAY_SECONDS = 30; -const CHANNEL_FINAL_DELIVERY_MAX_DELIVERY_ATTEMPTS = 8; -const CHANNEL_FINAL_DELIVERY_PROVIDER_REQUEST_TIMEOUT_MS = 30 * 1000; - -export type { ChannelFinalDeliveryMessage } from "./channel-final-delivery-message"; -export { - createChannelFinalDeliveryScheduler, - enqueueChannelFinalDeliveryJob, -} from "./channel-final-delivery-jobs"; -export type { - ChannelFinalDeliveryScheduler, - EnqueueChannelFinalDeliveryJobInput, -} from "./channel-final-delivery-jobs"; - -export interface ProcessChannelFinalDeliveryMessageOptions { - providerRequestTimeoutMs?: number; -} - -function createClaimOwnerId(message: Message): string { - const normalized = message.id.replaceAll(":", "_").trim(); - return normalized || "channel-final-delivery-worker"; -} - -async function ensureSessionCanReceiveFinalDelivery(input: { - attemptCount: number; - bindings: ApiBindings; - jobId: ChannelFinalDeliveryMessage["jobId"]; - nowMs: number; - sessionId: SessionId; -}): Promise { - const session = - (await getAppDatabase(input.bindings.DB) - .select({ status: sessionsTable.status }) - .from(sessionsTable) - .where(eq(sessionsTable.id, input.sessionId)) - .limit(1) - .get()) ?? null; - - if (session !== null && session.status !== "TERMINATED") { - return true; - } - - await markJobFailed({ - attemptCount: input.attemptCount, - database: input.bindings.DB, - errorCode: "session_not_deliverable", - jobId: input.jobId, - nowMs: input.nowMs, - }); - return false; -} - -async function requeueDelayedWakeup(input: { - bindings: ApiBindings; - delaySeconds: number; - jobId: ChannelFinalDeliveryMessage["jobId"]; - message: Message; -}): Promise { - try { - await input.bindings.CHANNEL_FINAL_DELIVERY_QUEUE.send( - { jobId: input.jobId }, - { delaySeconds: input.delaySeconds }, - ); - input.message.ack(); - } catch (error) { - logError("channel-final-delivery.requeue_failed", { - ...createErrorLogContext(error), - delaySeconds: input.delaySeconds, - jobId: input.jobId, - }); - input.message.retry({ delaySeconds: input.delaySeconds }); - } -} - -export async function processChannelFinalDeliveryMessage( - bindings: ApiBindings, - message: Message, - options: ProcessChannelFinalDeliveryMessageOptions = {}, - nowMs: () => number = currentTimestampMs, -): Promise { - let body: ChannelFinalDeliveryMessage; - - try { - body = parseChannelFinalDeliveryMessage(message.body); - } catch (error) { - logError("channel-final-delivery.message_invalid", { - ...createErrorLogContext(error), - errorCode: getDeliveryErrorCode(error), - }); - message.ack(); - return; - } - - const providerRequestTimeoutMs = - options.providerRequestTimeoutMs ?? CHANNEL_FINAL_DELIVERY_PROVIDER_REQUEST_TIMEOUT_MS; - const startMs = nowMs(); - const ledger = await readJobLedger({ database: bindings.DB, jobId: body.jobId }); - let claim: Awaited> = null; - - if (!ledger) { - logInfo("channel-final-delivery.ledger_missing", { - jobId: body.jobId, - }); - message.ack(); - return; - } - - if (ledger.status !== "dispatched") { - message.ack(); - return; - } - - const activeClaim = parseActiveChannelFinalDeliveryClaim(ledger.lastErrorCode, startMs); - - if (activeClaim) { - await requeueDelayedWakeup({ - bindings, - delaySeconds: Math.max(1, Math.ceil((activeClaim.leaseExpiresAtMs - startMs) / 1000)), - jobId: body.jobId, - message, - }); - return; - } - - const attemptCount = ledger.attemptCount + 1; - let binding: Awaited> = null; - - try { - if ( - !(await ensureSessionCanReceiveFinalDelivery({ - attemptCount, - bindings, - jobId: body.jobId, - nowMs: startMs, - sessionId: ledger.sessionId, - })) - ) { - message.ack(); - return; - } - - const payload = parseChannelFinalDeliveryPayloadJson(ledger.provider, ledger.payloadJson); - binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: ledger.bindingId, - provider: ledger.provider, - }); - - if (!binding) { - await markJobFailed({ - attemptCount, - database: bindings.DB, - errorCode: "binding_not_found", - jobId: body.jobId, - nowMs: startMs, - }); - message.ack(); - return; - } - - const sessionClient = createChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: "queue://channel-final-delivery", - }); - const result = await sessionClient.retrieveSessionReply(ledger.sessionId, ledger.runId); - - if (!result) { - await recordJobWait({ - database: bindings.DB, - jobId: body.jobId, - nowMs: startMs, - }); - await requeueDelayedWakeup({ - bindings, - delaySeconds: CHANNEL_FINAL_DELIVERY_WAIT_DELAY_SECONDS, - jobId: body.jobId, - message, - }); - return; - } - - claim = await claimJobForDelivery({ - database: bindings.DB, - expectedAttemptCount: ledger.attemptCount, - jobId: body.jobId, - leaseDurationMs: providerRequestTimeoutMs * 2, - nowMs: startMs, - ownerId: createClaimOwnerId(message), - }); - - if (!claim) { - message.ack(); - return; - } - - await sendProviderFinalReply({ - binding, - bindings, - payload, - providerRequestTimeoutMs, - result, - sessionId: ledger.sessionId, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }); - - await markJobDelivered({ - attemptCount: claim.attemptCount, - claimCode: claim.claimCode, - database: bindings.DB, - jobId: body.jobId, - nowMs: startMs, - }); - - logInfo("channel-final-delivery.delivered", { - attemptCount, - bindingId: ledger.bindingId, - durationMs: nowMs() - startMs, - jobId: body.jobId, - provider: ledger.provider, - runId: ledger.runId, - sessionId: ledger.sessionId, - }); - message.ack(); - } catch (error) { - const errorCode = getDeliveryErrorCode(error); - const credentialScoped = isCredentialScopedDeliveryError(error); - - if (binding && credentialScoped) { - try { - await markBindingErrorIfCredentialScoped({ - error, - sessionClient: createChannelSessionClient({ - binding, - bindings, - executionContext: null, - requestUrl: "queue://channel-final-delivery", - }), - }); - } catch (markError) { - logError("channel-final-delivery.binding_error_mark_failed", { - ...createErrorLogContext(markError), - bindingId: ledger.bindingId, - errorCode: getDeliveryErrorCode(markError), - originalErrorCode: errorCode, - provider: ledger.provider, - }); - } - } - - logError("channel-final-delivery.failed", { - ...createErrorLogContext(error), - attemptCount, - bindingId: ledger.bindingId, - errorCode, - jobId: body.jobId, - provider: ledger.provider, - runId: ledger.runId, - sessionId: ledger.sessionId, - }); - - if (credentialScoped || error instanceof ChannelFinalDeliveryPayloadError) { - await markJobFailed({ - attemptCount, - database: bindings.DB, - errorCode, - jobId: body.jobId, - nowMs: startMs, - }); - message.ack(); - return; - } - - if (attemptCount >= CHANNEL_FINAL_DELIVERY_MAX_DELIVERY_ATTEMPTS) { - await markJobFailed({ - attemptCount, - database: bindings.DB, - errorCode, - jobId: body.jobId, - nowMs: startMs, - }); - message.ack(); - return; - } - - await recordJobAttempt({ - attemptCount, - database: bindings.DB, - errorCode, - jobId: body.jobId, - nowMs: startMs, - }); - await requeueDelayedWakeup({ - bindings, - delaySeconds: CHANNEL_FINAL_DELIVERY_RETRY_DELAY_SECONDS, - jobId: body.jobId, - message, - }); - } -} diff --git a/apps/api/src/modules/channels/application/channel-session-command-client.ts b/apps/api/src/modules/channels/application/channel-session-command-client.ts deleted file mode 100644 index 7808b6df..00000000 --- a/apps/api/src/modules/channels/application/channel-session-command-client.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { ChannelThreadSessionId } from "@mosoo/db"; - -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { - createAgentSession, - sendAgentSessionEvents, -} from "../../runtime/application/session-run.service"; -import type { ChannelSessionTriggeredByMetadata } from "../../runtime/application/session-runs/create-agent-session.service"; -import { recordAgentChannelBindingError } from "./agent-channel-binding-error"; -import { - beginChannelEventReceipt, - clearChannelEventReceipt, - completeChannelEventReceipt, - hasProcessedChannelEvent, -} from "./channel-event-receipt-store"; -import { getSessionRunReply } from "./channel-session-reply"; -import type { - AgentChannelBindingContext, - ChannelSessionCommandClient, - ChannelWorkTrigger, -} from "./channel-session.types"; -import { - claimChannelThreadSession, - clearChannelThreadSessionReservation, - completeChannelThreadSessionReservation, - findExistingChannelSession, -} from "./channel-thread-session-store"; - -interface ChannelTriggeredByMetadata extends ChannelSessionTriggeredByMetadata { - binding_id: string; - event_id: string; - external_actor_id: string; - external_message_id: string; - external_thread_id: string; - external_workspace_id: string; - provider: AgentChannelBindingContext["provider"]; - provider_metadata: AgentChannelBindingContext["displayMetadata"]; -} - -function toTriggeredByMetadata( - binding: AgentChannelBindingContext, - trigger: ChannelWorkTrigger, -): ChannelTriggeredByMetadata { - const externalWorkspaceId = trigger.externalWorkspaceId?.trim() || binding.externalTenantId; - - return { - binding_id: binding.bindingId, - event_id: trigger.eventId, - external_actor_id: trigger.externalActorId, - external_message_id: trigger.externalMessageId, - external_thread_id: trigger.externalThreadId, - external_workspace_id: externalWorkspaceId, - provider: binding.provider, - provider_metadata: { - ...binding.displayMetadata, - ...trigger.providerMetadata, - }, - }; -} - -export function createChannelSessionClient(input: { - binding: AgentChannelBindingContext; - bindings: ApiBindings; - executionContext: Pick | null; - requestUrl: string; -}): ChannelSessionCommandClient { - return { - async createOrContinueSession(command) { - const clientRequestId = command.clientRequestId.trim(); - const receipt = await beginChannelEventReceipt({ - bindingId: input.binding.bindingId, - database: input.bindings.DB, - externalEventId: clientRequestId, - externalTenantId: input.binding.externalTenantId, - provider: input.binding.provider, - }); - - if (receipt.duplicate) { - return { duplicate: true, runId: null, sessionId: receipt.sessionId }; - } - - let threadReservationId: ChannelThreadSessionId | null = null; - - try { - const threadSession = command.trigger.requiresExistingSession - ? { - reservationId: null, - sessionId: await findExistingChannelSession({ - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - database: input.bindings.DB, - externalThreadId: command.trigger.externalThreadId, - provider: input.binding.provider, - }), - } - : await claimChannelThreadSession({ - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - database: input.bindings.DB, - externalThreadId: command.trigger.externalThreadId, - provider: input.binding.provider, - }); - - threadReservationId = threadSession.reservationId; - - if (command.trigger.requiresExistingSession && !threadSession.sessionId) { - logInfo("channel-events.thread_reply_ignored", { - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - eventId: command.trigger.eventId, - provider: input.binding.provider, - reason: "orphan_thread", - threadId: command.trigger.externalThreadId, - }); - await clearChannelEventReceipt({ - database: input.bindings.DB, - receiptId: receipt.receiptId, - }); - return { duplicate: false, ignored: true, runId: null, sessionId: null }; - } - - const sessionId = - threadSession.sessionId ?? - ( - await createAgentSession({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - agentId: input.binding.agentId, - appId: input.binding.appId, - type: "api_channel", - }, - options: { - accessViewer: input.binding.owner, - metadata: { - triggered_by: toTriggeredByMetadata(input.binding, command.trigger), - }, - }, - viewer: input.binding.owner, - }) - ).id; - - await completeChannelThreadSessionReservation({ - database: input.bindings.DB, - reservationId: threadReservationId, - sessionId, - }); - threadReservationId = null; - - if ( - clientRequestId.length > 0 && - (await hasProcessedChannelEvent({ - clientRequestId, - database: input.bindings.DB, - sessionId, - })) - ) { - await completeChannelEventReceipt({ - database: input.bindings.DB, - receiptId: receipt.receiptId, - sessionId, - }); - return { duplicate: true, runId: null, sessionId }; - } - - const eventBatch = await sendAgentSessionEvents({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - events: [ - { - attachmentIds: [], - clientRequestId, - text: command.text, - type: "user_message", - }, - ], - appId: input.binding.appId, - sessionId, - }, - options: { - accessViewer: input.binding.owner, - }, - requestUrl: input.requestUrl, - viewer: input.binding.owner, - }); - const runId = - eventBatch.events.find((event) => event.type === "user_message")?.run?.id ?? null; - - if (!runId) { - throw new Error("Channel session command did not start a run."); - } - - await completeChannelEventReceipt({ - database: input.bindings.DB, - receiptId: receipt.receiptId, - sessionId, - }); - - return { duplicate: false, ignored: false, runId, sessionId }; - } catch (error) { - await clearChannelEventReceipt({ - database: input.bindings.DB, - receiptId: receipt.receiptId, - }); - await clearChannelThreadSessionReservation({ - database: input.bindings.DB, - reservationId: threadReservationId, - }); - throw error; - } - }, - async markBindingError(errorCode) { - await recordAgentChannelBindingError(input.bindings.DB, { - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - errorCode, - appId: input.binding.appId, - }); - }, - retrieveSessionReply(sessionId, runId) { - return getSessionRunReply(input.bindings.DB, { runId, sessionId }); - }, - }; -} diff --git a/apps/api/src/modules/channels/application/channel-session-reply.ts b/apps/api/src/modules/channels/application/channel-session-reply.ts deleted file mode 100644 index f4804e71..00000000 --- a/apps/api/src/modules/channels/application/channel-session-reply.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { sessionMessagesTable, sessionRunsTable } from "@mosoo/db"; -import type { SessionId, SessionRunId } from "@mosoo/id"; -import { and, desc, eq } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import type { ChannelAgentReplyResult } from "./channel-agent-reply"; - -export async function getSessionRunReply( - database: D1Database, - input: { runId: SessionRunId; sessionId: SessionId }, -): Promise { - const run = - (await getAppDatabase(database) - .select({ - errorMessage: sessionRunsTable.errorMessage, - status: sessionRunsTable.status, - }) - .from(sessionRunsTable) - .where( - and(eq(sessionRunsTable.id, input.runId), eq(sessionRunsTable.sessionId, input.sessionId)), - ) - .limit(1) - .get()) ?? null; - - if (!run) { - return null; - } - - if (run.status === "failed" || run.status === "cancelled" || run.status === "expired") { - return { - status: "failed", - text: run.errorMessage ?? `Run ended with status ${run.status}.`, - }; - } - - if (run.status !== "completed") { - return null; - } - - const message = - (await getAppDatabase(database) - .select({ content: sessionMessagesTable.contentText }) - .from(sessionMessagesTable) - .where( - and( - eq(sessionMessagesTable.sessionId, input.sessionId), - eq(sessionMessagesTable.sessionRunId, input.runId), - eq(sessionMessagesTable.role, "assistant"), - ), - ) - .orderBy(desc(sessionMessagesTable.seq)) - .limit(1) - .get()) ?? null; - - return { - status: "completed", - text: message?.content ?? null, - }; -} diff --git a/apps/api/src/modules/channels/application/channel-session.service.ts b/apps/api/src/modules/channels/application/channel-session.service.ts deleted file mode 100644 index 010bd04d..00000000 --- a/apps/api/src/modules/channels/application/channel-session.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { resolveAgentChannelBindingContextById } from "./channel-binding-context"; -export { createChannelSessionClient } from "./channel-session-command-client"; -export type { - AgentChannelBindingContext, - ChannelSessionCommandClient, - ChannelWorkTrigger, -} from "./channel-session.types"; diff --git a/apps/api/src/modules/channels/application/channel-session.types.ts b/apps/api/src/modules/channels/application/channel-session.types.ts deleted file mode 100644 index e4781e16..00000000 --- a/apps/api/src/modules/channels/application/channel-session.types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { PrimitiveRecord } from "@mosoo/contracts"; -import type { AgentChannelBindingProvider } from "@mosoo/db"; -import type { AgentId, ChannelBindingId, AppId, SessionId, SessionRunId } from "@mosoo/id"; - -import type { AgentRow } from "../../agents/application/agent-types"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import type { ChannelAgentReplyResult } from "./channel-agent-reply"; - -export interface AgentChannelBindingContext { - agentId: AgentId; - agentStatus: AgentRow["status"]; - bindingId: ChannelBindingId; - credentialsJson: string; - displayMetadata: PrimitiveRecord; - externalBotId: string; - externalTenantId: string; - owner: AuthenticatedViewer; - appId: AppId; - provider: AgentChannelBindingProvider; -} - -export interface ChannelWorkTrigger { - eventId: string; - externalActorId: string; - externalMessageId: string; - externalThreadId: string; - externalWorkspaceId?: string | null; - providerMetadata: PrimitiveRecord; - requiresExistingSession: boolean; -} - -export interface ChannelSessionCommandClient { - createOrContinueSession(input: { - clientRequestId: string; - text: string; - trigger: ChannelWorkTrigger; - }): Promise<{ - duplicate: boolean; - ignored?: boolean; - runId: SessionRunId | null; - sessionId: SessionId | null; - }>; - markBindingError(errorCode: string): Promise; - retrieveSessionReply( - sessionId: SessionId, - runId: SessionRunId, - ): Promise; -} diff --git a/apps/api/src/modules/channels/application/channel-thread-session-store.ts b/apps/api/src/modules/channels/application/channel-thread-session-store.ts deleted file mode 100644 index b7c3515f..00000000 --- a/apps/api/src/modules/channels/application/channel-thread-session-store.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { channelThreadSessionsTable, sessionsTable } from "@mosoo/db"; -import type { AgentChannelBindingProvider, ChannelThreadSessionId } from "@mosoo/db"; -import { sleepPromise } from "@mosoo/effects"; -import { createPlatformId } from "@mosoo/id"; -import type { AgentId, ChannelBindingId, SessionId } from "@mosoo/id"; -import { and, desc, eq, isNull, lt, ne, sql } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; - -export interface ChannelThreadSessionReservation { - reservationId: ChannelThreadSessionId | null; - sessionId: SessionId | null; -} - -const CHANNEL_THREAD_SESSION_STALE_MS = 5 * 60 * 1000; -const CHANNEL_THREAD_SESSION_WAIT_ATTEMPTS = 50; -const CHANNEL_THREAD_SESSION_WAIT_INTERVAL_MS = 100; - -export async function completeChannelThreadSessionReservation(input: { - database: D1Database; - reservationId: ChannelThreadSessionId | null; - sessionId: SessionId; -}): Promise { - if (!input.reservationId) { - return; - } - - await getAppDatabase(input.database) - .update(channelThreadSessionsTable) - .set({ - sessionId: input.sessionId, - updatedAt: currentTimestampMs(), - }) - .where(eq(channelThreadSessionsTable.id, input.reservationId)) - .run(); -} - -export async function clearChannelThreadSessionReservation(input: { - database: D1Database; - reservationId: ChannelThreadSessionId | null; -}): Promise { - if (!input.reservationId) { - return; - } - - await getAppDatabase(input.database) - .delete(channelThreadSessionsTable) - .where( - and( - eq(channelThreadSessionsTable.id, input.reservationId), - isNull(channelThreadSessionsTable.sessionId), - ), - ) - .run(); -} - -async function findExistingChannelSessionFromMetadata(input: { - agentId: AgentId; - bindingId: ChannelBindingId; - database: D1Database; - externalThreadId: string; -}): Promise { - const row = - (await getAppDatabase(input.database) - .select({ id: sessionsTable.id }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.agentId, input.agentId), - eq(sessionsTable.type, "api_channel"), - isNull(sessionsTable.archivedAt), - ne(sessionsTable.status, "TERMINATED"), - sql`json_extract(${sessionsTable.metadataJson}, '$.triggered_by.binding_id') = ${input.bindingId}`, - sql`json_extract(${sessionsTable.metadataJson}, '$.triggered_by.external_thread_id') = ${input.externalThreadId}`, - ), - ) - .orderBy(desc(sessionsTable.updatedAt)) - .limit(1) - .get()) ?? null; - - return row?.id ?? null; -} - -async function upsertChannelThreadSession(input: { - bindingId: ChannelBindingId; - database: D1Database; - externalThreadId: string; - provider: AgentChannelBindingProvider; - sessionId: SessionId; -}): Promise { - const timestampMs = currentTimestampMs(); - - await getAppDatabase(input.database) - .insert(channelThreadSessionsTable) - .values({ - bindingId: input.bindingId, - createdAt: timestampMs, - externalThreadId: input.externalThreadId, - id: createPlatformId(), - provider: input.provider, - sessionId: input.sessionId, - updatedAt: timestampMs, - }) - .onConflictDoUpdate({ - set: { - sessionId: input.sessionId, - updatedAt: timestampMs, - }, - target: [ - channelThreadSessionsTable.provider, - channelThreadSessionsTable.bindingId, - channelThreadSessionsTable.externalThreadId, - ], - }) - .run(); -} - -async function waitForReservedChannelThreadSession(input: { - bindingId: ChannelBindingId; - database: D1Database; - externalThreadId: string; - provider: AgentChannelBindingProvider; -}): Promise { - for (let attempt = 0; attempt < CHANNEL_THREAD_SESSION_WAIT_ATTEMPTS; attempt += 1) { - const row = - (await getAppDatabase(input.database) - .select({ sessionId: channelThreadSessionsTable.sessionId }) - .from(channelThreadSessionsTable) - .where( - and( - eq(channelThreadSessionsTable.provider, input.provider), - eq(channelThreadSessionsTable.bindingId, input.bindingId), - eq(channelThreadSessionsTable.externalThreadId, input.externalThreadId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row || row.sessionId) { - return row?.sessionId ?? null; - } - - await sleepPromise(CHANNEL_THREAD_SESSION_WAIT_INTERVAL_MS); - } - - return null; -} - -export async function findExistingChannelSession(input: { - agentId: AgentId; - bindingId: ChannelBindingId; - database: D1Database; - externalThreadId: string; - provider: AgentChannelBindingProvider; -}): Promise { - const row = - (await getAppDatabase(input.database) - .select({ - sessionId: channelThreadSessionsTable.sessionId, - }) - .from(channelThreadSessionsTable) - .where( - and( - eq(channelThreadSessionsTable.provider, input.provider), - eq(channelThreadSessionsTable.bindingId, input.bindingId), - eq(channelThreadSessionsTable.externalThreadId, input.externalThreadId), - ), - ) - .limit(1) - .get()) ?? null; - - if (row?.sessionId) { - return row.sessionId; - } - - if (row) { - const reservedSessionId = await waitForReservedChannelThreadSession(input); - - if (reservedSessionId) { - return reservedSessionId; - } - } - - const legacySessionId = await findExistingChannelSessionFromMetadata(input); - - if (legacySessionId) { - await upsertChannelThreadSession({ - bindingId: input.bindingId, - database: input.database, - externalThreadId: input.externalThreadId, - provider: input.provider, - sessionId: legacySessionId, - }); - } - - return legacySessionId; -} - -export async function claimChannelThreadSession(input: { - agentId: AgentId; - bindingId: ChannelBindingId; - database: D1Database; - externalThreadId: string; - provider: AgentChannelBindingProvider; - retryStale?: boolean; -}): Promise { - const existingSessionId = await findExistingChannelSession(input); - - if (existingSessionId) { - return { - reservationId: null, - sessionId: existingSessionId, - }; - } - - const reservationId = createPlatformId(); - const timestampMs = currentTimestampMs(); - - await getAppDatabase(input.database) - .insert(channelThreadSessionsTable) - .values({ - bindingId: input.bindingId, - createdAt: timestampMs, - externalThreadId: input.externalThreadId, - id: reservationId, - provider: input.provider, - sessionId: null, - updatedAt: timestampMs, - }) - .onConflictDoNothing({ - target: [ - channelThreadSessionsTable.provider, - channelThreadSessionsTable.bindingId, - channelThreadSessionsTable.externalThreadId, - ], - }) - .run(); - - const current = - (await getAppDatabase(input.database) - .select({ - id: channelThreadSessionsTable.id, - sessionId: channelThreadSessionsTable.sessionId, - updatedAt: channelThreadSessionsTable.updatedAt, - }) - .from(channelThreadSessionsTable) - .where( - and( - eq(channelThreadSessionsTable.provider, input.provider), - eq(channelThreadSessionsTable.bindingId, input.bindingId), - eq(channelThreadSessionsTable.externalThreadId, input.externalThreadId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!current) { - throw new Error("Channel thread session reservation could not be confirmed."); - } - - if (current.id === reservationId) { - return { - reservationId, - sessionId: null, - }; - } - - if (current.sessionId) { - return { - reservationId: null, - sessionId: current.sessionId, - }; - } - - if ( - input.retryStale !== false && - current.updatedAt < timestampMs - CHANNEL_THREAD_SESSION_STALE_MS - ) { - await getAppDatabase(input.database) - .delete(channelThreadSessionsTable) - .where( - and( - eq(channelThreadSessionsTable.id, current.id), - isNull(channelThreadSessionsTable.sessionId), - lt(channelThreadSessionsTable.updatedAt, timestampMs - CHANNEL_THREAD_SESSION_STALE_MS), - ), - ) - .run(); - - return claimChannelThreadSession({ - ...input, - retryStale: false, - }); - } - - const sessionId = await waitForReservedChannelThreadSession(input); - - if (sessionId) { - return { - reservationId: null, - sessionId, - }; - } - - throw new Error("Channel thread session reservation is still pending."); -} diff --git a/apps/api/src/modules/channels/application/discord-gateway-connection-maintenance.service.ts b/apps/api/src/modules/channels/application/discord-gateway-connection-maintenance.service.ts deleted file mode 100644 index f8d5ef36..00000000 --- a/apps/api/src/modules/channels/application/discord-gateway-connection-maintenance.service.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { agentChannelBindingsTable, agentsTable } from "@mosoo/db"; -import type { ChannelBindingId } from "@mosoo/id"; -import { and, asc, eq, gt } from "drizzle-orm"; - -import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { startDiscordGatewayConnection } from "../discord/discord-gateway-connection-client"; -import type { DiscordGatewayStartResult } from "../discord/discord-gateway.do"; - -const DISCORD_GATEWAY_CONNECTION_BATCH_SIZE = 50; - -export interface DiscordGatewayConnectionMaintenanceResult { - failed: number; - started: number; - total: number; -} - -export type DiscordGatewayConnectionStarter = ( - bindings: ApiBindings, - input: { bindingId: ChannelBindingId }, -) => Promise; - -async function listPublishedDiscordBindingIdPage( - bindings: ApiBindings, - input: { afterBindingId: ChannelBindingId | null }, -): Promise { - const predicates = [ - eq(agentChannelBindingsTable.provider, "discord"), - eq(agentChannelBindingsTable.appId, agentsTable.appId), - eq(agentChannelBindingsTable.status, "active"), - eq(agentsTable.status, "published"), - ]; - - if (input.afterBindingId) { - predicates.push(gt(agentChannelBindingsTable.id, input.afterBindingId)); - } - - const rows = await getAppDatabase(bindings.DB) - .select({ bindingId: agentChannelBindingsTable.id }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .where(and(...predicates)) - .orderBy(asc(agentChannelBindingsTable.id)) - .limit(DISCORD_GATEWAY_CONNECTION_BATCH_SIZE) - .all(); - - return rows.map((row) => row.bindingId); -} - -async function listPublishedDiscordBindingIds(bindings: ApiBindings): Promise { - const bindingIds: ChannelBindingId[] = []; - let afterBindingId: ChannelBindingId | null = null; - - for (;;) { - const page = await listPublishedDiscordBindingIdPage(bindings, { afterBindingId }); - - if (page.length === 0) { - return bindingIds; - } - - bindingIds.push(...page); - afterBindingId = page[page.length - 1] ?? null; - - if (page.length < DISCORD_GATEWAY_CONNECTION_BATCH_SIZE) { - return bindingIds; - } - } -} - -export async function runDiscordGatewayConnectionMaintenance( - bindings: ApiBindings, - _scheduledAt: Date, - options: { startConnection?: DiscordGatewayConnectionStarter } = {}, -): Promise { - const bindingIds = await listPublishedDiscordBindingIds(bindings); - const startConnection = options.startConnection ?? startDiscordGatewayConnection; - let failed = 0; - let started = 0; - - for (const bindingId of bindingIds) { - try { - const result = await startConnection(bindings, { bindingId }); - - if (result.status === "started" || result.status === "already_started") { - started += 1; - } - } catch (error) { - failed += 1; - logError("discord-gateway-connection-maintenance.start_failed", { - ...createErrorLogContext(error), - bindingId, - }); - } - } - - if (bindingIds.length > 0) { - logInfo("discord-gateway-connection-maintenance.completed", { - failed, - started, - total: bindingIds.length, - }); - } - - return { - failed, - started, - total: bindingIds.length, - }; -} diff --git a/apps/api/src/modules/channels/application/lark-long-connection-maintenance.service.ts b/apps/api/src/modules/channels/application/lark-long-connection-maintenance.service.ts deleted file mode 100644 index bef22ccc..00000000 --- a/apps/api/src/modules/channels/application/lark-long-connection-maintenance.service.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Lark long-connection maintenance service. -// -// Scope: typed ChannelConnection calls for Lark start, stop, and snapshot, -// plus a scheduled reconciler that ensures every -// `connectionMode="websocket"` Lark binding has a running connection. - -import { agentChannelBindingsTable, agentsTable } from "@mosoo/db"; -import type { ChannelBindingId } from "@mosoo/id"; -import { and, asc, eq, gt } from "drizzle-orm"; - -import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { parseLarkCredentials } from "../lark/lark-credentials"; -import type { LarkChannelCredentials } from "../lark/lark-credentials"; -import type { LarkGatewayStopResult } from "../lark/lark-gateway.do"; -import { resolveAgentChannelBindingContextById } from "./channel-binding-context"; -import { stopChannelConnection } from "./channel-connection-client"; - -export interface LarkGatewayBindingRecord { - readonly bindingId: ChannelBindingId; - readonly credentials: Pick; -} - -export interface ReconcileLarkLongConnectionsResult { - readonly skippedDisabledWebsocketBindings: readonly ChannelBindingId[]; - readonly skippedWebhookBindings: readonly ChannelBindingId[]; - readonly startedBindings: readonly ChannelBindingId[]; - readonly errors: readonly { bindingId: ChannelBindingId; reason: string }[]; -} - -export async function stopLarkLongConnection(input: { - bindingId: ChannelBindingId; - bindings: ApiBindings; -}): Promise { - return await stopChannelConnection(input.bindings, { - bindingId: input.bindingId, - provider: "lark", - }); -} - -/** - * Walk the supplied list of Lark bindings and ensure every one whose - * credentials are in `websocket` mode has its connection started. Webhook-mode - * bindings are explicitly skipped — they live on the HTTP route, not the - * DO. Errors per-binding are accumulated and returned; one bad binding - * does not abort the whole sweep. - * - * The DB list resolver is injected so the scheduled entry can wire it - * after L-003 makes `connectionMode` part of the canonical creds schema. - */ -async function reconcileLarkLongConnections(input: { - bindings: ApiBindings; - records: readonly LarkGatewayBindingRecord[]; -}): Promise { - const skippedWebhookBindings: ChannelBindingId[] = []; - const skippedDisabledWebsocketBindings: ChannelBindingId[] = []; - const startedBindings: ChannelBindingId[] = []; - const errors: { bindingId: ChannelBindingId; reason: string }[] = []; - - for (const record of input.records) { - if (record.credentials.connectionMode !== "websocket") { - skippedWebhookBindings.push(record.bindingId); - continue; - } - - skippedDisabledWebsocketBindings.push(record.bindingId); - } - - logInfo("lark.gateway.maintenance.completed", { - errorCount: errors.length, - skippedDisabledWebsocketCount: skippedDisabledWebsocketBindings.length, - skippedCount: skippedWebhookBindings.length, - startedCount: startedBindings.length, - }); - - return { errors, skippedDisabledWebsocketBindings, skippedWebhookBindings, startedBindings }; -} - -const LARK_LONG_CONNECTION_BATCH_SIZE = 50; - -async function listPublishedLarkBindingIdPage( - bindings: ApiBindings, - input: { afterBindingId: ChannelBindingId | null }, -): Promise { - const predicates = [ - eq(agentChannelBindingsTable.provider, "lark"), - eq(agentChannelBindingsTable.appId, agentsTable.appId), - eq(agentChannelBindingsTable.status, "active"), - eq(agentsTable.status, "published"), - ]; - - if (input.afterBindingId) { - predicates.push(gt(agentChannelBindingsTable.id, input.afterBindingId)); - } - - const rows = await getAppDatabase(bindings.DB) - .select({ bindingId: agentChannelBindingsTable.id }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .where(and(...predicates)) - .orderBy(asc(agentChannelBindingsTable.id)) - .limit(LARK_LONG_CONNECTION_BATCH_SIZE) - .all(); - return rows.map((row) => row.bindingId); -} - -async function listPublishedLarkBindingRecords( - bindings: ApiBindings, -): Promise { - const records: LarkGatewayBindingRecord[] = []; - let afterBindingId: ChannelBindingId | null = null; - - for (;;) { - const page = await listPublishedLarkBindingIdPage(bindings, { afterBindingId }); - if (page.length === 0) { - return records; - } - for (const bindingId of page) { - try { - const context = await resolveAgentChannelBindingContextById(bindings, { - bindingId, - provider: "lark", - }); - if (!context) { - continue; - } - const credentials = parseLarkCredentials(context.credentialsJson); - records.push({ bindingId, credentials }); - } catch (error) { - logError("lark.gateway.maintenance.parse_failed", { - ...createErrorLogContext(error), - bindingId, - }); - } - } - afterBindingId = page[page.length - 1] ?? null; - if (page.length < LARK_LONG_CONNECTION_BATCH_SIZE) { - return records; - } - } -} - -/** - * Scheduled-tick entry. Pulls every active+published Lark binding, - * parses its credentials, filters to `connectionMode="websocket"`, and - * tells ChannelConnection to start its connection. Webhook-mode bindings are - * explicitly skipped (they live on the HTTP route, not the DO). - * - * Mirrors the Discord/WeChat maintenance shape; only one signature - * difference: this returns the dual-mode reconcile result (skipped vs - * started) rather than the Discord {started, failed, total} struct so - * the call site can log the webhook-skip count for observability. - */ -export async function runLarkLongConnectionMaintenance( - bindings: ApiBindings, - _scheduledAt: Date, -): Promise { - // When a Lark sidecar process is configured (dev-local injects - // MOSOO_LARK_SIDECAR_SECRET when spawning bin/lark-ws-sidecar.ts), - // the sidecar owns the long-connection lifecycle via the official - // @larksuiteoapi/node-sdk WSClient. Don't also spin up the in-worker - // Lark long-connection runtime — its protocol implementation is incomplete - // (placeholder URL + JSON-assumed frames where Feishu sends protobuf) - // and would just spam connect_failed every minute. - if ((bindings.MOSOO_LARK_SIDECAR_SECRET ?? "").trim().length > 0) { - logInfo("lark.gateway.maintenance.skipped_sidecar_active", {}); - return { - errors: [], - skippedDisabledWebsocketBindings: [], - skippedWebhookBindings: [], - startedBindings: [], - }; - } - - const records = await listPublishedLarkBindingRecords(bindings); - return await reconcileLarkLongConnections({ bindings, records }); -} diff --git a/apps/api/src/modules/channels/application/slack-channel-session.service.ts b/apps/api/src/modules/channels/application/slack-channel-session.service.ts deleted file mode 100644 index c439cf37..00000000 --- a/apps/api/src/modules/channels/application/slack-channel-session.service.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { accountsTable, agentChannelBindingsTable, agentsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, ChannelBindingId, AppId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import { logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import type { AgentRow } from "../../agents/application/agent-types"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import type { SlackChannelCredentials } from "../slack/slack-credentials"; -import { parseSlackCredentials } from "../slack/slack-credentials"; -import type { SlackWorkTrigger } from "../slack/slack-events"; -import type { - SlackAdapterConfig, - SlackSessionCommandClient, -} from "../slack/slack-first-party-adapter"; -import { readAgentChannelBindingCredentialSecret } from "./channel-credential-secret-resolution"; -import { parseChannelDisplayMetadata } from "./channel-display-metadata"; -import { createChannelSessionClient } from "./channel-session-command-client"; -import type { AgentChannelBindingContext, ChannelWorkTrigger } from "./channel-session.types"; - -export interface SlackChannelBindingContext { - agentId: AgentId; - agentStatus: AgentRow["status"]; - bindingId: ChannelBindingId; - botHandle: string | null; - credentials: SlackChannelCredentials; - externalBotId: string; - externalTenantId: string; - owner: AuthenticatedViewer; - appId: AppId; - threadRepliesRequireMention: boolean; - workspaceName: string | null; -} - -interface SlackDisplayMetadata { - bot_handle: string | null; - workspace_name: string | null; -} - -function readOptionalMetadataString( - value: ReturnType, - field: string, -): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : null; -} - -function parseSlackDisplayMetadata(value: string): SlackDisplayMetadata { - const metadata = parseChannelDisplayMetadata(value); - - return { - bot_handle: readOptionalMetadataString(metadata, "bot_handle"), - workspace_name: readOptionalMetadataString(metadata, "workspace_name"), - }; -} - -function toOwnerViewer(row: { - email: string; - emailVerified: boolean | number; - id: AccountId; - imageUrl: string | null; - name: string; -}): AuthenticatedViewer { - return { - email: row.email, - emailVerified: row.emailVerified === true || row.emailVerified === 1, - id: row.id, - imageUrl: row.imageUrl, - name: row.name, - }; -} - -function toExternalThreadId(trigger: SlackWorkTrigger): string { - return `${trigger.channelId}:${trigger.threadTs}`; -} - -function toAgentChannelBindingContext( - binding: SlackChannelBindingContext, -): AgentChannelBindingContext { - return { - agentId: binding.agentId, - agentStatus: binding.agentStatus, - bindingId: binding.bindingId, - credentialsJson: JSON.stringify(binding.credentials), - displayMetadata: { - bot_handle: binding.botHandle, - workspace_name: binding.workspaceName, - }, - externalBotId: binding.externalBotId, - externalTenantId: binding.externalTenantId, - owner: binding.owner, - appId: binding.appId, - provider: "slack", - }; -} - -function toChannelWorkTrigger(trigger: SlackWorkTrigger): ChannelWorkTrigger { - return { - eventId: trigger.eventId, - externalActorId: `slack:${trigger.userId}`, - externalMessageId: trigger.messageTs, - externalThreadId: toExternalThreadId(trigger), - providerMetadata: { - channel_id: trigger.channelId, - channel_name: null, - enterprise_id: trigger.enterpriseId, - is_enterprise_install: trigger.isEnterpriseInstall, - team_id: trigger.teamId, - }, - requiresExistingSession: trigger.requiresExistingSession, - }; -} - -async function toSlackChannelBindingContext( - bindings: ApiBindings, - row: { - agentId: AgentId; - agentStatus: AgentRow["status"]; - bindingId: ChannelBindingId; - displayMetadataJson: string; - encryptedCredsSecretId: string; - externalBotId: string; - externalTenantId: string; - ownerEmail: string; - ownerEmailVerified: boolean | number; - ownerId: AccountId; - ownerImageUrl: string | null; - ownerName: string; - appId: AppId; - }, -): Promise { - const metadata = parseSlackDisplayMetadata(row.displayMetadataJson); - const credentials = parseSlackCredentials( - await readAgentChannelBindingCredentialSecret(bindings, { - bindingId: row.bindingId, - expectedOwner: { - agentId: row.agentId, - appId: row.appId, - }, - provider: "slack", - purpose: "channel_callback", - secretId: parsePlatformId(row.encryptedCredsSecretId, "Slack binding credential secret ID"), - }), - ); - - return { - agentId: row.agentId, - agentStatus: row.agentStatus, - bindingId: row.bindingId, - botHandle: metadata.bot_handle, - credentials, - externalBotId: row.externalBotId, - externalTenantId: row.externalTenantId, - owner: toOwnerViewer({ - email: row.ownerEmail, - emailVerified: row.ownerEmailVerified, - id: row.ownerId, - imageUrl: row.ownerImageUrl, - name: row.ownerName, - }), - appId: row.appId, - threadRepliesRequireMention: credentials.threadRepliesRequireMention, - workspaceName: metadata.workspace_name, - }; -} - -export async function resolveSlackChannelBindingContext( - bindings: ApiBindings, - input: { - externalBotId: string; - externalTenantId: string; - }, -): Promise { - const botId = input.externalBotId.trim(); - const tenantId = input.externalTenantId.trim(); - - if (!botId || !tenantId) { - return null; - } - - const row = - (await getAppDatabase(bindings.DB) - .select({ - agentId: agentChannelBindingsTable.agentId, - agentStatus: agentsTable.status, - bindingId: agentChannelBindingsTable.id, - displayMetadataJson: agentChannelBindingsTable.displayMetadataJson, - encryptedCredsSecretId: agentChannelBindingsTable.encryptedCredsSecretId, - externalBotId: agentChannelBindingsTable.externalBotId, - externalTenantId: agentChannelBindingsTable.externalTenantId, - agentAppId: agentsTable.appId, - ownerEmail: accountsTable.email, - ownerEmailVerified: accountsTable.emailVerified, - ownerId: accountsTable.id, - ownerImageUrl: accountsTable.image, - ownerName: accountsTable.name, - appId: agentChannelBindingsTable.appId, - }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .innerJoin(accountsTable, eq(accountsTable.id, agentsTable.ownerId)) - .where( - and( - eq(agentChannelBindingsTable.provider, "slack"), - eq(agentChannelBindingsTable.externalTenantId, tenantId), - eq(agentChannelBindingsTable.externalBotId, botId), - eq(agentChannelBindingsTable.status, "active"), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - return null; - } - - if (row.agentAppId !== row.appId) { - return null; - } - - return toSlackChannelBindingContext(bindings, row); -} - -export async function resolveSlackChannelBindingContextById( - bindings: ApiBindings, - input: { - bindingId: ChannelBindingId; - }, -): Promise { - const row = - (await getAppDatabase(bindings.DB) - .select({ - agentId: agentChannelBindingsTable.agentId, - agentStatus: agentsTable.status, - bindingId: agentChannelBindingsTable.id, - displayMetadataJson: agentChannelBindingsTable.displayMetadataJson, - encryptedCredsSecretId: agentChannelBindingsTable.encryptedCredsSecretId, - externalBotId: agentChannelBindingsTable.externalBotId, - externalTenantId: agentChannelBindingsTable.externalTenantId, - agentAppId: agentsTable.appId, - ownerEmail: accountsTable.email, - ownerEmailVerified: accountsTable.emailVerified, - ownerId: accountsTable.id, - ownerImageUrl: accountsTable.image, - ownerName: accountsTable.name, - appId: agentChannelBindingsTable.appId, - }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .innerJoin(accountsTable, eq(accountsTable.id, agentsTable.ownerId)) - .where( - and( - eq(agentChannelBindingsTable.id, input.bindingId), - eq(agentChannelBindingsTable.provider, "slack"), - eq(agentChannelBindingsTable.status, "active"), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - return null; - } - - if (row.agentAppId !== row.appId) { - return null; - } - - return toSlackChannelBindingContext(bindings, row); -} - -export function createSlackAdapterConfig(input: { - binding: SlackChannelBindingContext; - sessionLinkBaseUrl: string | null; -}): SlackAdapterConfig { - return { - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - sessionLinkBaseUrl: input.sessionLinkBaseUrl, - slackBotToken: input.binding.credentials.botToken, - }; -} - -export function createSlackChannelSessionClient(input: { - binding: SlackChannelBindingContext; - bindings: ApiBindings; - executionContext: Pick | null; - requestUrl: string; -}): SlackSessionCommandClient { - const channelClient = createChannelSessionClient({ - binding: toAgentChannelBindingContext(input.binding), - bindings: input.bindings, - executionContext: input.executionContext, - requestUrl: input.requestUrl, - }); - - return { - async createOrContinueSession(command) { - if (command.trigger.requiresExistingSession && input.binding.threadRepliesRequireMention) { - logInfo("slack-channel-events.thread_reply_ignored", { - agentId: input.binding.agentId, - bindingId: input.binding.bindingId, - channelId: command.trigger.channelId, - eventId: command.trigger.eventId, - reason: "mention_required", - teamId: command.trigger.teamId, - threadTs: command.trigger.threadTs, - }); - return { duplicate: false, ignored: true, runId: null, sessionId: null }; - } - - return channelClient.createOrContinueSession({ - clientRequestId: command.clientRequestId, - text: command.text, - trigger: toChannelWorkTrigger(command.trigger), - }); - }, - markBindingError: channelClient.markBindingError, - retrieveSessionReply: channelClient.retrieveSessionReply, - }; -} diff --git a/apps/api/src/modules/channels/application/wechat-polling-owner-maintenance.service.ts b/apps/api/src/modules/channels/application/wechat-polling-owner-maintenance.service.ts deleted file mode 100644 index 91d76be8..00000000 --- a/apps/api/src/modules/channels/application/wechat-polling-owner-maintenance.service.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { agentChannelBindingsTable, agentsTable, wechatChannelAccountsTable } from "@mosoo/db"; -import type { WeChatChannelAccountStatus } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { ChannelBindingId } from "@mosoo/id"; -import { and, asc, eq, gt, or } from "drizzle-orm"; - -import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import { processWeChatWorkTrigger } from "../wechat/wechat-first-party-adapter"; -import { WeChatIlinkClient } from "../wechat/wechat-ilink-client"; -import { WeChatPollingRuntimeOwner } from "../wechat/wechat-polling-owner"; -import type { WeChatPollingOwnerPollResult } from "../wechat/wechat-polling-owner"; -import { - createWeChatPollingOwnerDatabaseStore, - readWeChatChannelAccountWithCredentials, -} from "../wechat/wechat-runtime-store"; -import type { WeChatChannelAccountWithCredentials } from "../wechat/wechat-runtime-store"; -import { resolveAgentChannelBindingContextById } from "./channel-binding-context"; -import { - claimChannelConnectionOwner, - completeChannelConnectionOwner, - releaseChannelConnectionOwner, -} from "./channel-connection-state.service"; -import { createChannelFinalDeliveryScheduler } from "./channel-final-delivery.service"; -import { createChannelSessionClient } from "./channel-session-command-client"; - -const WECHAT_POLLING_OWNER_BATCH_SIZE = 20; -const WECHAT_POLLING_OWNER_LEASE_MS = 60 * 1000; -// Failed and starting rows are retried so provider errors or mid-claim crashes do not orphan polling. -const WECHAT_POLLING_OWNER_RETRY_STATUSES = [ - "failed", - "idle", - "reconnecting", - "running", - "stale", - "starting", -] as const satisfies readonly WeChatChannelAccountStatus[]; - -export interface WeChatPollingOwnerMaintenanceResult { - failed: number; - polled: number; - skipped: number; - total: number; -} - -export interface WeChatPollingOwnerPollAccountResult { - code: - | "account_not_found" - | "binding_not_found" - | "lease_lost" - | "lease_unavailable" - | "polled" - | "status_not_pollable"; - pollResult?: WeChatPollingOwnerPollResult; -} - -type WeChatPollingOwnerClient = Pick; - -export type WeChatPollingOwnerClientFactory = ( - account: WeChatChannelAccountWithCredentials, -) => WeChatPollingOwnerClient; - -interface WeChatPollingOwnerOptions { - clientFactory?: WeChatPollingOwnerClientFactory; - executionContext?: Pick | null; - nowMs?: () => number; -} - -interface WeChatPollingAccountPageRow { - accountId: ChannelBindingId; -} - -function createOwnerAttemptId(accountId: ChannelBindingId): string { - return `wechat-polling-owner:${accountId}:${createPlatformId()}`; -} - -function isPollableWeChatAccountStatus(status: WeChatChannelAccountStatus): boolean { - return WECHAT_POLLING_OWNER_RETRY_STATUSES.some((candidate) => candidate === status); -} - -function toRuntimeStateJson(owner: WeChatPollingRuntimeOwner): string { - return JSON.stringify(owner.getRuntimeState()); -} - -function logWeChatPollingOwnerLeaseLost(input: { - accountId: string; - bindingId: ChannelBindingId; - ownerId: string; - stage: "complete_error" | "complete_success"; -}): void { - logInfo("wechat-polling-owner-maintenance.lease_lost", { - accountId: input.accountId, - bindingId: input.bindingId, - ownerId: input.ownerId, - provider: "wechat", - stage: input.stage, - }); -} - -async function listPublishedWeChatPollingAccountPage( - bindings: ApiBindings, - input: { afterAccountId: ChannelBindingId | null }, -): Promise { - const predicates = [ - eq(agentChannelBindingsTable.provider, "wechat"), - eq(agentChannelBindingsTable.appId, agentsTable.appId), - eq(agentChannelBindingsTable.status, "active"), - eq(agentsTable.status, "published"), - eq(wechatChannelAccountsTable.appId, agentChannelBindingsTable.appId), - or( - ...WECHAT_POLLING_OWNER_RETRY_STATUSES.map((status) => - eq(wechatChannelAccountsTable.status, status), - ), - ), - ]; - - if (input.afterAccountId) { - predicates.push(gt(wechatChannelAccountsTable.id, input.afterAccountId)); - } - - return getAppDatabase(bindings.DB) - .select({ accountId: wechatChannelAccountsTable.id }) - .from(wechatChannelAccountsTable) - .innerJoin( - agentChannelBindingsTable, - eq(agentChannelBindingsTable.id, wechatChannelAccountsTable.id), - ) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .where(and(...predicates)) - .orderBy(asc(wechatChannelAccountsTable.id)) - .limit(WECHAT_POLLING_OWNER_BATCH_SIZE) - .all(); -} - -async function listPublishedWeChatPollingAccountIds( - bindings: ApiBindings, -): Promise { - const accountIds: ChannelBindingId[] = []; - let afterAccountId: ChannelBindingId | null = null; - - for (;;) { - const page = await listPublishedWeChatPollingAccountPage(bindings, { afterAccountId }); - - if (page.length === 0) { - return accountIds; - } - - accountIds.push(...page.map((row) => row.accountId)); - afterAccountId = page[page.length - 1]?.accountId ?? null; - - if (page.length < WECHAT_POLLING_OWNER_BATCH_SIZE) { - return accountIds; - } - } -} - -export async function pollWeChatChannelAccountOnce( - bindings: ApiBindings, - input: { - accountId: ChannelBindingId; - clientFactory?: WeChatPollingOwnerClientFactory; - executionContext?: Pick | null; - nowMs?: () => number; - }, -): Promise { - const nowMs = input.nowMs ?? currentTimestampMs; - const account = await readWeChatChannelAccountWithCredentials(bindings, { - accountId: input.accountId, - }); - - if (!account) { - return { code: "account_not_found" }; - } - - if (!isPollableWeChatAccountStatus(account.account.status)) { - return { code: "status_not_pollable" }; - } - - const ownerId = createOwnerAttemptId(account.account.id); - const claimNowMs = nowMs(); - const claimed = await claimChannelConnectionOwner({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - bindings, - leaseDurationMs: WECHAT_POLLING_OWNER_LEASE_MS, - nowMs: claimNowMs, - ownerId, - provider: "wechat", - state: { - status: "starting", - statusChangedAtMs: claimNowMs, - }, - }); - - if (!claimed) { - return { code: "lease_unavailable" }; - } - - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: account.account.id, - provider: "wechat", - }); - - if (!binding) { - await releaseChannelConnectionOwner({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - bindings, - nowMs: nowMs(), - ownerId, - provider: "wechat", - status: "failed", - }); - return { code: "binding_not_found" }; - } - - const owner = new WeChatPollingRuntimeOwner({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - botId: account.account.externalBotId, - client: - input.clientFactory?.(account) ?? - new WeChatIlinkClient({ - baseUrl: account.credentials.baseUrl, - botToken: account.credentials.botToken, - }), - nowMs, - onTrigger: (trigger) => - processWeChatWorkTrigger({ - bindings, - config: { - agentId: binding.agentId, - bindingId: binding.bindingId, - sessionLinkBaseUrl: bindings.WEB_ORIGIN, - }, - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(bindings), - sessionClient: createChannelSessionClient({ - binding, - bindings, - executionContext: input.executionContext ?? null, - requestUrl: "scheduled://wechat-polling-owner", - }), - trigger, - }), - store: createWeChatPollingOwnerDatabaseStore(bindings), - }); - - try { - const pollResult = await owner.pollOnce(); - const snapshot = owner.getSnapshot(); - - const completed = await completeChannelConnectionOwner({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - bindings, - nowMs: nowMs(), - ownerId, - provider: "wechat", - state: { - lastErrorCode: snapshot.lastErrorCode, - lastHeartbeatAtMs: snapshot.lastHeartbeatAtMs, - lastInboundAtMs: snapshot.lastInboundAtMs, - lastPollAtMs: snapshot.lastPollAtMs, - runtimeStateJson: toRuntimeStateJson(owner), - status: snapshot.status, - statusChangedAtMs: snapshot.statusChangedAtMs, - }, - }); - - if (!completed) { - logWeChatPollingOwnerLeaseLost({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - ownerId, - stage: "complete_success", - }); - return { code: "lease_lost", pollResult }; - } - - return { code: "polled", pollResult }; - } catch (error) { - const snapshot = owner.getSnapshot(); - - const completed = await completeChannelConnectionOwner({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - bindings, - nowMs: nowMs(), - ownerId, - provider: "wechat", - state: { - lastErrorCode: snapshot.lastErrorCode ?? "poll_failed", - lastHeartbeatAtMs: snapshot.lastHeartbeatAtMs, - lastInboundAtMs: snapshot.lastInboundAtMs, - lastPollAtMs: snapshot.lastPollAtMs, - runtimeStateJson: toRuntimeStateJson(owner), - status: "failed", - statusChangedAtMs: snapshot.statusChangedAtMs, - }, - }); - - if (!completed) { - logWeChatPollingOwnerLeaseLost({ - accountId: account.account.externalAccountId, - bindingId: account.account.id, - ownerId, - stage: "complete_error", - }); - } - throw error; - } -} - -export async function runWeChatPollingOwnerMaintenance( - bindings: ApiBindings, - _scheduledAt: Date, - options: WeChatPollingOwnerOptions = {}, -): Promise { - const accountIds = await listPublishedWeChatPollingAccountIds(bindings); - let failed = 0; - let polled = 0; - let skipped = 0; - - for (const accountId of accountIds) { - try { - const result = await pollWeChatChannelAccountOnce(bindings, { - accountId, - ...(options.clientFactory ? { clientFactory: options.clientFactory } : {}), - ...(options.executionContext !== undefined - ? { executionContext: options.executionContext } - : {}), - ...(options.nowMs ? { nowMs: options.nowMs } : {}), - }); - - if (result.code === "polled") { - polled += 1; - } else { - skipped += 1; - } - } catch (error) { - failed += 1; - logError("wechat-polling-owner-maintenance.poll_failed", { - ...createErrorLogContext(error), - accountId, - }); - } - } - - // Zero-account ticks prove the scheduled cron fired. - logInfo("wechat-polling-owner-maintenance.completed", { - failed, - polled, - skipped, - total: accountIds.length, - }); - - return { - failed, - polled, - skipped, - total: accountIds.length, - }; -} diff --git a/apps/api/src/modules/channels/channel-fetch.ts b/apps/api/src/modules/channels/channel-fetch.ts deleted file mode 100644 index 2afee21d..00000000 --- a/apps/api/src/modules/channels/channel-fetch.ts +++ /dev/null @@ -1,58 +0,0 @@ -const DEFAULT_CHANNEL_WEB_API_TIMEOUT_MS = 30_000; - -class ChannelWebApiTimeoutError extends Error { - readonly label: string; - readonly timeoutMs: number; - - constructor(input: { label: string; timeoutMs: number }) { - super(`${input.label} timed out after ${input.timeoutMs}ms.`); - this.label = input.label; - this.name = "ChannelWebApiTimeoutError"; - this.timeoutMs = input.timeoutMs; - } -} - -export interface ChannelWebApiFetchInput { - init?: RequestInit; - label: string; - timeoutMs?: number | undefined; - url: RequestInfo | URL; -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; -} - -export async function fetchChannelWebApi(input: ChannelWebApiFetchInput): Promise { - const timeoutMs = input.timeoutMs ?? DEFAULT_CHANNEL_WEB_API_TIMEOUT_MS; - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, timeoutMs); - - try { - return await fetch(input.url, { - ...input.init, - signal: controller.signal, - }); - } catch (error) { - if (isAbortError(error)) { - throw new ChannelWebApiTimeoutError({ - label: input.label, - timeoutMs, - }); - } - - throw error; - } finally { - clearTimeout(timeout); - } -} - -export async function readChannelWebApiJson(response: Response): Promise { - try { - return await response.json(); - } catch { - return null; - } -} diff --git a/apps/api/src/modules/channels/discord/discord-credentials.ts b/apps/api/src/modules/channels/discord/discord-credentials.ts deleted file mode 100644 index 5a71d1ec..00000000 --- a/apps/api/src/modules/channels/discord/discord-credentials.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; - -export interface DiscordChannelCredentials { - applicationId: string; - botToken: string; - relaySecret: string; -} - -export interface NormalizeDiscordCredentialsInput { - applicationId: string; - botToken: string; - relaySecret: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRequiredString(value: string, label: string): string { - const normalized = value.trim(); - - if (!isTruthy(normalized)) { - throw validationError(`${label} is required.`); - } - - return normalized; -} - -function readRequiredString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - throw new Error(`Discord channel credential ${field} is required.`); -} - -export function normalizeDiscordCredentials( - input: NormalizeDiscordCredentialsInput, -): DiscordChannelCredentials { - return { - applicationId: normalizeRequiredString(input.applicationId, "Discord application ID"), - botToken: normalizeRequiredString(input.botToken, "Discord bot token"), - relaySecret: normalizeRequiredString(input.relaySecret, "Discord relay secret"), - }; -} - -export function serializeDiscordCredentials(input: DiscordChannelCredentials): string { - return JSON.stringify(input); -} - -export function parseDiscordCredentials(value: string): DiscordChannelCredentials { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("Discord channel credentials must be a JSON object."); - } - - return { - applicationId: readRequiredString(parsed, "applicationId"), - botToken: readRequiredString(parsed, "botToken"), - relaySecret: readRequiredString(parsed, "relaySecret"), - }; -} diff --git a/apps/api/src/modules/channels/discord/discord-events.ts b/apps/api/src/modules/channels/discord/discord-events.ts deleted file mode 100644 index 181bb1fe..00000000 --- a/apps/api/src/modules/channels/discord/discord-events.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -interface DiscordGatewayAuthor { - bot: boolean; - id: string; - username: string | null; -} - -interface DiscordGatewayMessage { - author: DiscordGatewayAuthor; - channelId: string; - channelType: number | null; - content: string; - guildId: string | null; - id: string; -} - -export interface DiscordGatewayDispatchEnvelope { - message: DiscordGatewayMessage; - sequence: number; - type: "MESSAGE_CREATE"; -} - -export interface DiscordWorkTrigger { - authorDisplayName: string | null; - authorId: string; - channelId: string; - channelType: number | null; - eventId: string; - externalActorId: string; - externalMessageId: string; - externalThreadId: string; - guildId: string | null; - messageId: string; - text: string; -} - -export interface DiscordGatewayDispatchParseFailure { - code: "invalid_json" | "missing_message" | "missing_sequence" | "unsupported_dispatch"; - message: string; - ok: false; -} - -export interface DiscordGatewayDispatchParseSuccess { - envelope: DiscordGatewayDispatchEnvelope; - ok: true; -} - -export type DiscordGatewayDispatchParseResult = - | DiscordGatewayDispatchParseFailure - | DiscordGatewayDispatchParseSuccess; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readMessageContent(value: Record): string | null { - const candidate = value["content"]; - return typeof candidate === "string" ? candidate : null; -} - -function readBoolean(value: Record, field: string): boolean { - return value[field] === true; -} - -function readNumber(value: Record, field: string): number | null { - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readRelayChannelType(value: Record): number | null { - return readNumber(value, "relay_channel_type"); -} - -function readAuthor(value: unknown): DiscordGatewayAuthor | null { - if (!isRecord(value)) { - return null; - } - - const id = readString(value, "id"); - - if (!id) { - return null; - } - - return { - bot: readBoolean(value, "bot"), - id, - username: readString(value, "username"), - }; -} - -function readGatewayMessage(value: unknown): DiscordGatewayMessage | null { - if (!isRecord(value)) { - return null; - } - - const author = readAuthor(value["author"]); - const channelId = readString(value, "channel_id"); - const content = readMessageContent(value); - const id = readString(value, "id"); - - if (!author || !channelId || content === null || !id) { - return null; - } - - return { - author, - channelId, - channelType: readRelayChannelType(value), - content, - guildId: readString(value, "guild_id"), - id, - }; -} - -export function parseDiscordGatewayDispatchEnvelope( - body: string, -): DiscordGatewayDispatchParseResult { - let parsed: unknown; - - try { - parsed = JSON.parse(body); - } catch { - return { - code: "invalid_json", - message: "Discord gateway dispatch body must be valid JSON.", - ok: false, - }; - } - - if (!isRecord(parsed) || parsed["op"] !== 0 || parsed["t"] !== "MESSAGE_CREATE") { - return { - code: "unsupported_dispatch", - message: "Discord gateway dispatch type is not supported.", - ok: false, - }; - } - - const message = readGatewayMessage(parsed["d"]); - const sequence = readNumber(parsed, "s"); - - if (sequence === null) { - return { - code: "missing_sequence", - message: "Discord MESSAGE_CREATE dispatch is missing a sequence.", - ok: false, - }; - } - - if (!message) { - return { - code: "missing_message", - message: "Discord MESSAGE_CREATE dispatch is missing required fields.", - ok: false, - }; - } - - return { - envelope: { - message, - sequence, - type: "MESSAGE_CREATE", - }, - ok: true, - }; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function mentionPattern(botUserId: string): RegExp { - return new RegExp(`^<@!?${escapeRegExp(botUserId)}>\\s*`, "u"); -} - -export function normalizeDiscordGatewayWorkTrigger( - envelope: DiscordGatewayDispatchEnvelope, - input: { botUserId: string }, -): DiscordWorkTrigger | null { - const { message } = envelope; - - if (message.channelType === null) { - return null; - } - - if (message.author.bot || message.channelType === 3) { - return null; - } - - if (message.guildId === null && message.channelType !== 1) { - return null; - } - - let text = message.content.trim(); - - if (message.guildId) { - const pattern = mentionPattern(input.botUserId); - - if (!pattern.test(text)) { - return null; - } - - text = text.replace(pattern, "").trim(); - } - - if (!isTruthy(text)) { - return null; - } - - const externalThreadId = message.guildId - ? `guild:${message.guildId}:channel:${message.channelId}:message:${message.id}` - : `dm:${message.channelId}:message:${message.id}`; - - return { - authorDisplayName: message.author.username, - authorId: message.author.id, - channelId: message.channelId, - channelType: message.channelType, - eventId: `discord:message:${message.id}`, - externalActorId: `discord:user:${message.author.id}`, - externalMessageId: `${message.channelId}:${message.id}`, - externalThreadId, - guildId: message.guildId, - messageId: message.id, - text, - }; -} diff --git a/apps/api/src/modules/channels/discord/discord-first-party-adapter.ts b/apps/api/src/modules/channels/discord/discord-first-party-adapter.ts deleted file mode 100644 index de889c04..00000000 --- a/apps/api/src/modules/channels/discord/discord-first-party-adapter.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { AgentId, ChannelBindingId } from "@mosoo/id"; - -import { logChannelAdapterError } from "../application/channel-adapter-logger"; -import { - CHANNEL_AGENT_FAILURE_TEXT, - buildChannelSessionLink, - buildChannelWorkingText, -} from "../application/channel-agent-reply"; -import type { ChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import type { ChannelSessionCommandClient } from "../application/channel-session.types"; -import type { DiscordWorkTrigger } from "./discord-events"; -import { DiscordWebApiClient, DiscordWebApiError } from "./discord-web-api"; - -export const DISCORD_FIRST_PARTY_ADAPTER_MANIFEST = { - displayName: "Discord", - id: "discord", - requires: { - auth: ["relay_signature"], - credentials: ["application_id", "bot_token", "relay_secret"], - }, - surfaceType: "im", - triggers: ["MESSAGE_CREATE"], -} as const; - -export interface DiscordAdapterConfig { - agentId: AgentId; - bindingId: ChannelBindingId; - botToken: string; - sessionLinkBaseUrl: string | null; -} - -export type DiscordWorkTriggerProcessResult = - | { - ok: true; - } - | { - code: "discord_work_trigger_failed"; - ok: false; - }; - -function shouldMarkBindingError(error: DiscordWebApiError): boolean { - const code = error.code.toLowerCase(); - - return ( - code.includes("401") || - code.includes("invalid token") || - code.includes("unauthorized") || - code.includes("token") - ); -} - -async function markBindingErrorIfCredentialScoped(input: { - error: DiscordWebApiError; - sessionClient: ChannelSessionCommandClient; -}): Promise { - if (!shouldMarkBindingError(input.error)) { - return; - } - - await input.sessionClient.markBindingError(input.error.code); -} - -function toMosooMessage(trigger: DiscordWorkTrigger): string { - return [ - trigger.text, - "", - "---", - "Source: Discord message", - `Discord channel: ${trigger.channelId}`, - `Discord guild: ${trigger.guildId ?? "dm"}`, - `Discord thread: ${trigger.externalThreadId}`, - `Discord user: ${trigger.authorId}`, - ].join("\n"); -} - -export async function processDiscordWorkTrigger(input: { - config: DiscordAdapterConfig; - finalDeliveryScheduler: ChannelFinalDeliveryScheduler; - sessionClient: ChannelSessionCommandClient; - trigger: DiscordWorkTrigger; -}): Promise { - const discord = new DiscordWebApiClient(input.config.botToken); - - try { - const sessionCommand = await input.sessionClient.createOrContinueSession({ - clientRequestId: input.trigger.eventId, - text: toMosooMessage(input.trigger), - trigger: { - eventId: input.trigger.eventId, - externalActorId: input.trigger.externalActorId, - externalMessageId: input.trigger.externalMessageId, - externalThreadId: input.trigger.externalThreadId, - externalWorkspaceId: input.trigger.guildId ?? input.trigger.channelId, - providerMetadata: { - author_display_name: input.trigger.authorDisplayName, - channel_id: input.trigger.channelId, - channel_type: input.trigger.channelType, - guild_id: input.trigger.guildId, - message_id: input.trigger.messageId, - }, - requiresExistingSession: false, - }, - }); - - if (sessionCommand.duplicate || sessionCommand.ignored) { - return { ok: true }; - } - - const sessionId = sessionCommand.sessionId; - - if (!sessionId) { - throw new Error("Discord channel session command did not return a session id."); - } - - const runId = sessionCommand.runId; - - if (!runId) { - throw new Error("Discord channel session command did not return a run id."); - } - - const sessionLink = buildChannelSessionLink({ - agentId: input.config.agentId, - sessionId, - sessionLinkBaseUrl: input.config.sessionLinkBaseUrl, - }); - const workingMessage = await discord.sendMessage({ - channelId: input.trigger.channelId, - text: buildChannelWorkingText({ sessionLink }), - }); - await input.finalDeliveryScheduler.enqueue({ - bindingId: input.config.bindingId, - externalEventId: input.trigger.eventId, - payload: { - channelId: input.trigger.channelId, - provider: "discord", - workingMessage, - }, - provider: "discord", - runId, - sessionId, - }); - return { ok: true }; - } catch (error) { - if (error instanceof DiscordWebApiError) { - await markBindingErrorIfCredentialScoped({ - error, - sessionClient: input.sessionClient, - }); - } - - logChannelAdapterError("discord-first-party-adapter.failed", error, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - - try { - await discord.sendMessage({ - channelId: input.trigger.channelId, - text: CHANNEL_AGENT_FAILURE_TEXT, - }); - } catch (failureReplyError) { - if (failureReplyError instanceof DiscordWebApiError) { - await markBindingErrorIfCredentialScoped({ - error: failureReplyError, - sessionClient: input.sessionClient, - }); - } - - logChannelAdapterError( - "discord-first-party-adapter.failure_reply_failed", - failureReplyError, - { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }, - ); - } - - return { code: "discord_work_trigger_failed", ok: false }; - } -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-client.ts b/apps/api/src/modules/channels/discord/discord-gateway-client.ts deleted file mode 100644 index 689fbfed..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-client.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { DiscordGatewayDispatchEnvelope } from "./discord-events"; -import { parseDiscordGatewayDispatchEnvelope } from "./discord-events"; -import type { - DiscordGatewayRuntimeSnapshot, - DiscordGatewayRuntimeStatus, -} from "./discord-gateway-health"; - -const DISCORD_GATEWAY_OP_DISPATCH = 0; -const DISCORD_GATEWAY_OP_HEARTBEAT = 1; -const DISCORD_GATEWAY_OP_IDENTIFY = 2; -const DISCORD_GATEWAY_OP_RESUME = 6; -const DISCORD_GATEWAY_OP_RECONNECT = 7; -const DISCORD_GATEWAY_OP_INVALID_SESSION = 9; -const DISCORD_GATEWAY_OP_HELLO = 10; -const DISCORD_GATEWAY_OP_HEARTBEAT_ACK = 11; - -const DISCORD_GATEWAY_CLOSE_RECONNECT = 4000; - -export const DISCORD_GATEWAY_DEFAULT_INTENTS = - (1 << 0) /* Guilds */ | - (1 << 9) /* Guild messages */ | - (1 << 12) /* Direct messages */ | - (1 << 15); /* Message content */ - -export interface DiscordGatewayWritableSocket { - close(code?: number, reason?: string): void; - send(data: string): void; -} - -export interface DiscordGatewayResumeState { - resumeGatewayUrl: string | null; - sequence: number | null; - sessionId: string; -} - -interface CompleteDiscordGatewayResumeState { - resumeGatewayUrl: string; - sequence: number; - sessionId: string; -} - -export interface DiscordGatewayClientOptions { - intents?: number; - nowMs: () => number; - onDispatch: (dispatch: DiscordGatewayDispatchEnvelope) => void; - resumeState?: DiscordGatewayResumeState | null; - socket: DiscordGatewayWritableSocket; - token: string; -} - -export type DiscordGatewayClientAction = - | "dispatch" - | "heartbeat_ack" - | "heartbeat_requested" - | "identified" - | "ignored" - | "invalid_session" - | "protocol_error" - | "reconnect_requested" - | "resumed"; - -interface DiscordGatewayPayload { - data: unknown; - op: number; - sequence: number | null; - type: string | null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, field: string): string | null { - if (!isRecord(value)) { - return null; - } - - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readNumber(value: unknown, field: string): number | null { - if (!isRecord(value)) { - return null; - } - - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function parseGatewayPayload(rawMessage: string): DiscordGatewayPayload { - let parsed: unknown; - - try { - parsed = JSON.parse(rawMessage); - } catch { - throw new Error("Discord gateway message must be valid JSON."); - } - - if (!isRecord(parsed)) { - throw new Error("Discord gateway message must be a JSON object."); - } - - const op = readNumber(parsed, "op"); - - if (op === null) { - throw new Error("Discord gateway message op is required."); - } - - return { - data: parsed["d"], - op, - sequence: readNumber(parsed, "s"), - type: readString(parsed, "t"), - }; -} - -function readHeartbeatIntervalMs(data: unknown): number { - const interval = readNumber(data, "heartbeat_interval"); - - if (interval === null || interval <= 0) { - throw new Error("Discord gateway HELLO is missing heartbeat_interval."); - } - - return interval; -} - -function buildIdentifyFrame(input: { intents: number; token: string }): string { - return JSON.stringify({ - d: { - intents: input.intents, - properties: { - browser: "mosoo", - device: "mosoo", - os: "cloudflare", - }, - token: input.token, - }, - op: DISCORD_GATEWAY_OP_IDENTIFY, - }); -} - -function buildResumeFrame(input: { sequence: number; sessionId: string; token: string }): string { - return JSON.stringify({ - d: { - seq: input.sequence, - session_id: input.sessionId, - token: input.token, - }, - op: DISCORD_GATEWAY_OP_RESUME, - }); -} - -function buildHeartbeatFrame(sequence: number | null): string { - return JSON.stringify({ - d: sequence, - op: DISCORD_GATEWAY_OP_HEARTBEAT, - }); -} - -export class DiscordGatewayClient { - readonly #intents: number; - readonly #nowMs: () => number; - readonly #onDispatch: (dispatch: DiscordGatewayDispatchEnvelope) => void; - readonly #socket: DiscordGatewayWritableSocket; - readonly #token: string; - #snapshot: DiscordGatewayRuntimeSnapshot; - - constructor(options: DiscordGatewayClientOptions) { - this.#intents = options.intents ?? DISCORD_GATEWAY_DEFAULT_INTENTS; - this.#nowMs = options.nowMs; - this.#onDispatch = options.onDispatch; - this.#socket = options.socket; - this.#token = options.token; - const nowMs = options.nowMs(); - this.#snapshot = { - connectedAtMs: null, - heartbeatIntervalMs: null, - lastCloseCode: null, - lastDispatchAtMs: null, - lastErrorCode: null, - lastHeartbeatAckAtMs: null, - lastHeartbeatSentAtMs: null, - resumeGatewayUrl: options.resumeState?.resumeGatewayUrl ?? null, - sequence: options.resumeState?.sequence ?? null, - sessionId: options.resumeState?.sessionId ?? null, - status: "connecting", - statusChangedAtMs: nowMs, - }; - } - - getSnapshot(): DiscordGatewayRuntimeSnapshot { - return { ...this.#snapshot }; - } - - handleMessage(rawMessage: string): DiscordGatewayClientAction { - const payload = parseGatewayPayload(rawMessage); - - switch (payload.op) { - case DISCORD_GATEWAY_OP_HELLO: { - const nowMs = this.#nowMs(); - this.#snapshot = { - ...this.#snapshot, - connectedAtMs: nowMs, - heartbeatIntervalMs: readHeartbeatIntervalMs(payload.data), - status: "connected", - statusChangedAtMs: nowMs, - }; - - const resumeState = this.#getCompleteResumeState(); - - if (resumeState) { - this.#socket.send( - buildResumeFrame({ - sequence: resumeState.sequence, - sessionId: resumeState.sessionId, - token: this.#token, - }), - ); - return "resumed"; - } - - this.#clearResumeState(); - this.#socket.send( - buildIdentifyFrame({ - intents: this.#intents, - token: this.#token, - }), - ); - return "identified"; - } - case DISCORD_GATEWAY_OP_HEARTBEAT: { - this.sendHeartbeat(); - return "heartbeat_requested"; - } - case DISCORD_GATEWAY_OP_HEARTBEAT_ACK: { - this.#snapshot = { - ...this.#snapshot, - lastHeartbeatAckAtMs: this.#nowMs(), - }; - return "heartbeat_ack"; - } - case DISCORD_GATEWAY_OP_RECONNECT: { - this.#markClosing("reconnecting", DISCORD_GATEWAY_CLOSE_RECONNECT, null); - this.#socket.close(DISCORD_GATEWAY_CLOSE_RECONNECT, "Discord gateway requested reconnect."); - return "reconnect_requested"; - } - case DISCORD_GATEWAY_OP_INVALID_SESSION: { - const canResume = payload.data === true && this.#getCompleteResumeState() !== null; - this.#snapshot = { - ...this.#snapshot, - lastErrorCode: "invalid_session", - resumeGatewayUrl: canResume ? this.#snapshot.resumeGatewayUrl : null, - sequence: canResume ? this.#snapshot.sequence : null, - sessionId: canResume ? this.#snapshot.sessionId : null, - status: "reconnecting", - statusChangedAtMs: this.#nowMs(), - }; - this.#socket.close(DISCORD_GATEWAY_CLOSE_RECONNECT, "Discord gateway invalid session."); - return "invalid_session"; - } - case DISCORD_GATEWAY_OP_DISPATCH: { - if (payload.sequence === null) { - this.#snapshot = { - ...this.#snapshot, - lastErrorCode: "missing_dispatch_sequence", - status: "reconnecting", - statusChangedAtMs: this.#nowMs(), - }; - this.#socket.close( - DISCORD_GATEWAY_CLOSE_RECONNECT, - "Discord gateway dispatch is missing a sequence.", - ); - return "protocol_error"; - } - - this.#snapshot = { - ...this.#snapshot, - lastDispatchAtMs: this.#nowMs(), - sequence: payload.sequence, - }; - this.#recordReady(payload); - return this.#dispatchMessageCreate(rawMessage) ? "dispatch" : "ignored"; - } - default: { - return "ignored"; - } - } - } - - sendHeartbeat(): void { - this.#socket.send(buildHeartbeatFrame(this.#snapshot.sequence)); - this.#snapshot = { - ...this.#snapshot, - lastHeartbeatSentAtMs: this.#nowMs(), - }; - } - - handleClose(code: number): void { - if (this.#snapshot.status === "reconnecting" && code === DISCORD_GATEWAY_CLOSE_RECONNECT) { - this.#snapshot = { - ...this.#snapshot, - lastCloseCode: code, - }; - return; - } - - this.#markClosing("stopped", code, null); - } - - handleError(errorCode: string): void { - this.#snapshot = { - ...this.#snapshot, - lastErrorCode: errorCode, - status: "reconnecting", - statusChangedAtMs: this.#nowMs(), - }; - } - - recordRecoverableError(errorCode: string): void { - this.#snapshot = { - ...this.#snapshot, - lastErrorCode: errorCode, - }; - } - - #dispatchMessageCreate(rawMessage: string): boolean { - const parsed = parseDiscordGatewayDispatchEnvelope(rawMessage); - - if (!parsed.ok) { - return false; - } - - this.#onDispatch(parsed.envelope); - return true; - } - - #getCompleteResumeState(): CompleteDiscordGatewayResumeState | null { - if ( - this.#snapshot.resumeGatewayUrl === null || - this.#snapshot.sequence === null || - this.#snapshot.sessionId === null - ) { - return null; - } - - return { - resumeGatewayUrl: this.#snapshot.resumeGatewayUrl, - sequence: this.#snapshot.sequence, - sessionId: this.#snapshot.sessionId, - }; - } - - #clearResumeState(): void { - this.#snapshot = { - ...this.#snapshot, - resumeGatewayUrl: null, - sequence: null, - sessionId: null, - }; - } - - #markClosing( - status: DiscordGatewayRuntimeStatus, - closeCode: number, - errorCode: string | null, - ): void { - this.#snapshot = { - ...this.#snapshot, - lastCloseCode: closeCode, - lastErrorCode: errorCode, - status, - statusChangedAtMs: this.#nowMs(), - }; - } - - #recordReady(payload: DiscordGatewayPayload): void { - if (payload.type !== "READY") { - return; - } - - const sessionId = readString(payload.data, "session_id"); - - if (!sessionId) { - return; - } - - this.#snapshot = { - ...this.#snapshot, - resumeGatewayUrl: readString(payload.data, "resume_gateway_url"), - sessionId, - }; - } -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-connection-client.ts b/apps/api/src/modules/channels/discord/discord-gateway-connection-client.ts deleted file mode 100644 index 9c1d9ca7..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-connection-client.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { - startChannelConnection, - stopChannelConnection, -} from "../application/channel-connection-client"; -import type { DiscordGatewayStartResult, DiscordGatewayStopResult } from "./discord-gateway.do"; - -export async function startDiscordGatewayConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId }, -): Promise { - return await startChannelConnection(bindings, { - bindingId: input.bindingId, - provider: "discord", - }); -} - -export async function stopDiscordGatewayConnection( - bindings: Pick, - input: { bindingId: ChannelBindingId }, -): Promise { - return await stopChannelConnection(bindings, { bindingId: input.bindingId, provider: "discord" }); -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-health.ts b/apps/api/src/modules/channels/discord/discord-gateway-health.ts deleted file mode 100644 index 4c4c8b89..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-health.ts +++ /dev/null @@ -1,79 +0,0 @@ -export type DiscordGatewayRuntimeStatus = - | "connected" - | "connecting" - | "reconnecting" - | "stale" - | "stopped"; - -export interface DiscordGatewayRuntimeSnapshot { - connectedAtMs: number | null; - heartbeatIntervalMs: number | null; - lastCloseCode: number | null; - lastDispatchAtMs: number | null; - lastErrorCode: string | null; - lastHeartbeatAckAtMs: number | null; - lastHeartbeatSentAtMs: number | null; - resumeGatewayUrl: string | null; - sequence: number | null; - sessionId: string | null; - status: DiscordGatewayRuntimeStatus; - statusChangedAtMs: number; -} - -export interface DiscordGatewayHealthSummary { - reason: string | null; - stale: boolean; - status: DiscordGatewayRuntimeStatus; -} - -export function summarizeDiscordGatewayHealth( - snapshot: DiscordGatewayRuntimeSnapshot, - input: { - nowMs: number; - staleAfterMs: number; - }, -): DiscordGatewayHealthSummary { - if (snapshot.status !== "connected") { - const stale = - (snapshot.status === "connecting" || snapshot.status === "reconnecting") && - input.nowMs - snapshot.statusChangedAtMs > input.staleAfterMs; - - if (stale) { - return { - reason: snapshot.lastErrorCode ?? `${snapshot.status}_stale`, - stale: true, - status: "stale", - }; - } - - return { - reason: snapshot.lastErrorCode, - stale: false, - status: snapshot.status, - }; - } - - const heartbeatReferenceMs = snapshot.lastHeartbeatAckAtMs ?? snapshot.connectedAtMs; - - if (heartbeatReferenceMs === null) { - return { - reason: "missing_heartbeat_reference", - stale: true, - status: "stale", - }; - } - - if (input.nowMs - heartbeatReferenceMs > input.staleAfterMs) { - return { - reason: "heartbeat_ack_stale", - stale: true, - status: "stale", - }; - } - - return { - reason: null, - stale: false, - status: "connected", - }; -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-owner.ts b/apps/api/src/modules/channels/discord/discord-gateway-owner.ts deleted file mode 100644 index 41546654..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-owner.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { - claimChannelConnectionOwner, - readChannelConnectionOwnerState, - renewChannelConnectionOwnerLease, -} from "../application/channel-connection-state.service"; -import type { DiscordGatewayDispatchEnvelope } from "./discord-events"; -import { DiscordGatewayClient } from "./discord-gateway-client"; -import type { - DiscordGatewayClientAction, - DiscordGatewayClientOptions, - DiscordGatewayResumeState, - DiscordGatewayWritableSocket, -} from "./discord-gateway-client"; -import { createDiscordGatewayRelayRequest } from "./discord-gateway-relay"; -import type { DiscordGatewayRelayRequest } from "./discord-gateway-relay"; -import { - createDiscordGatewayRuntimeStatePayload, - parseDiscordGatewayResumeStateFromRuntimeState, -} from "./discord-gateway-runtime-state"; - -export class DiscordGatewayConnectionRelayError extends Error { - readonly code: string; - - constructor(code: string) { - super(`Discord Gateway connection relay failed: ${code}`); - this.code = code; - this.name = "DiscordGatewayConnectionRelayError"; - } -} - -class DiscordGatewayRuntimeLeaseLostError extends Error { - constructor() { - super("Discord Gateway runtime lost its lease."); - this.name = "DiscordGatewayRuntimeLeaseLostError"; - } -} - -export interface DiscordGatewayRuntimeOwnerOptions { - apiBaseUrl: string; - bindingId: ChannelBindingId; - bindings: Pick; - botToken: string; - leaseDurationMs: number; - nowMs: () => number; - ownerId: string; - relayFetch?: (request: DiscordGatewayRelayRequest) => Promise; - relaySecret: string; - resumeState?: DiscordGatewayResumeState | null; - resolveRelayChannelType: (envelope: DiscordGatewayDispatchEnvelope) => Promise; - socket: DiscordGatewayWritableSocket; -} - -async function defaultRelayFetch(request: DiscordGatewayRelayRequest): Promise { - return fetch(request.url, { - body: request.body, - headers: request.headers, - method: "POST", - }); -} - -function ensureRelayResponseOk(response: Response): void { - if (response.ok) { - return; - } - - throw new DiscordGatewayConnectionRelayError(`relay_http_${response.status}`); -} - -export class DiscordGatewayRuntimeOwner { - readonly #apiBaseUrl: string; - readonly #bindingId: ChannelBindingId; - readonly #bindings: Pick; - readonly #client: DiscordGatewayClient; - readonly #leaseDurationMs: number; - readonly #nowMs: () => number; - readonly #ownerId: string; - readonly #relayFetch: (request: DiscordGatewayRelayRequest) => Promise; - readonly #relaySecret: string; - readonly #resolveRelayChannelType: ( - envelope: DiscordGatewayDispatchEnvelope, - ) => Promise; - readonly #tasks: Promise[] = []; - - constructor(options: DiscordGatewayRuntimeOwnerOptions) { - this.#apiBaseUrl = options.apiBaseUrl; - this.#bindingId = options.bindingId; - this.#bindings = options.bindings; - this.#leaseDurationMs = options.leaseDurationMs; - this.#nowMs = options.nowMs; - this.#ownerId = options.ownerId; - this.#relayFetch = options.relayFetch ?? defaultRelayFetch; - this.#relaySecret = options.relaySecret; - this.#resolveRelayChannelType = options.resolveRelayChannelType; - const clientOptions: DiscordGatewayClientOptions = { - nowMs: options.nowMs, - onDispatch: (envelope) => { - this.#tasks.push(this.#relayDispatch(envelope)); - }, - socket: options.socket, - token: options.botToken, - }; - - if (options.resumeState !== undefined) { - clientOptions.resumeState = options.resumeState; - } - - this.#client = new DiscordGatewayClient(clientOptions); - } - - static async claim( - options: DiscordGatewayRuntimeOwnerOptions, - ): Promise { - const storedState = await readChannelConnectionOwnerState({ - bindingId: options.bindingId, - bindings: options.bindings, - provider: "discord", - }); - const resumeState = storedState - ? parseDiscordGatewayResumeStateFromRuntimeState(storedState.runtimeStateJson) - : null; - const owner = new DiscordGatewayRuntimeOwner({ - ...options, - resumeState, - }); - - const claimed = await claimChannelConnectionOwner({ - bindingId: options.bindingId, - bindings: options.bindings, - leaseDurationMs: options.leaseDurationMs, - nowMs: options.nowMs(), - ownerId: options.ownerId, - provider: "discord", - state: createDiscordGatewayRuntimeStatePayload(owner.#client.getSnapshot()), - }); - - return claimed ? owner : null; - } - - getSnapshot() { - return this.#client.getSnapshot(); - } - - async handleMessage(rawMessage: string): Promise { - let action: DiscordGatewayClientAction; - - try { - action = this.#client.handleMessage(rawMessage); - } catch (error) { - this.#client.handleError("gateway_protocol_error"); - await this.#persist(); - throw error; - } - - await this.#flushDispatchTasks(); - await this.#persist(); - return action; - } - - async sendHeartbeat(): Promise { - this.#client.sendHeartbeat(); - await this.#persist(); - } - - async handleClose(code: number): Promise { - this.#client.handleClose(code); - await this.#persist(); - } - - async handleError(errorCode: string): Promise { - this.#client.handleError(errorCode); - await this.#persist(); - } - - async #relayDispatch(envelope: DiscordGatewayDispatchEnvelope): Promise { - const relayChannelType = await this.#resolveRelayChannelType(envelope); - - if (relayChannelType === null) { - throw new DiscordGatewayConnectionRelayError("relay_channel_type_missing"); - } - - const request = await createDiscordGatewayRelayRequest({ - apiBaseUrl: this.#apiBaseUrl, - bindingId: this.#bindingId, - envelope, - nowSeconds: Math.floor(this.#nowMs() / 1000), - relayChannelType, - relaySecret: this.#relaySecret, - }); - ensureRelayResponseOk(await this.#relayFetch(request)); - } - - async #flushDispatchTasks(): Promise { - const tasks = this.#tasks.splice(0); - - if (tasks.length === 0) { - return; - } - - const results = await Promise.allSettled(tasks); - const failure = results.find((result) => result.status === "rejected"); - - if (!failure) { - return; - } - - const code = - failure.reason instanceof DiscordGatewayConnectionRelayError - ? failure.reason.code - : "relay_failed"; - this.#client.recordRecoverableError(code); - await this.#persist(); - throw new DiscordGatewayConnectionRelayError(code); - } - - async #persist(): Promise { - const renewed = await renewChannelConnectionOwnerLease({ - bindingId: this.#bindingId, - bindings: this.#bindings, - leaseDurationMs: this.#leaseDurationMs, - nowMs: this.#nowMs(), - ownerId: this.#ownerId, - provider: "discord", - state: createDiscordGatewayRuntimeStatePayload(this.#client.getSnapshot()), - }); - - if (!renewed) { - throw new DiscordGatewayRuntimeLeaseLostError(); - } - } -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-relay.ts b/apps/api/src/modules/channels/discord/discord-gateway-relay.ts deleted file mode 100644 index e14aa876..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-relay.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import type { ChannelBindingId } from "@mosoo/id"; - -import type { DiscordGatewayDispatchEnvelope } from "./discord-events"; -import { createDiscordRelaySignature } from "./discord-signing"; - -export interface DiscordGatewayRelayRequest { - body: string; - headers: Record; - url: string; -} - -function assertSafeInteger(value: number, label: string): void { - if (!Number.isSafeInteger(value)) { - throw new Error(`${label} must be a safe integer.`); - } -} - -export function buildDiscordGatewayRelayBody(input: { - envelope: DiscordGatewayDispatchEnvelope; - relayChannelType: number; -}): string { - assertSafeInteger(input.relayChannelType, "Discord relay channel type"); - - return JSON.stringify({ - d: { - author: { - bot: input.envelope.message.author.bot, - id: input.envelope.message.author.id, - username: input.envelope.message.author.username, - }, - channel_id: input.envelope.message.channelId, - content: input.envelope.message.content, - guild_id: input.envelope.message.guildId, - id: input.envelope.message.id, - relay_channel_type: input.relayChannelType, - }, - op: 0, - s: input.envelope.sequence, - t: "MESSAGE_CREATE", - }); -} - -export async function createDiscordGatewayRelayRequest(input: { - apiBaseUrl: string; - bindingId: ChannelBindingId; - envelope: DiscordGatewayDispatchEnvelope; - nowSeconds: number; - relayChannelType: number; - relaySecret: string; -}): Promise { - assertSafeInteger(input.nowSeconds, "Discord relay timestamp"); - - const body = buildDiscordGatewayRelayBody({ - envelope: input.envelope, - relayChannelType: input.relayChannelType, - }); - const timestamp = String(input.nowSeconds); - - return { - body, - headers: { - "content-type": "application/json; charset=utf-8", - "x-mosoo-discord-relay-signature": await createDiscordRelaySignature({ - body, - relaySecret: input.relaySecret, - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - url: buildAgentChannelWebhookUrl({ - bindingId: input.bindingId, - origin: input.apiBaseUrl, - provider: "discord", - }), - }; -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-runtime-state.ts b/apps/api/src/modules/channels/discord/discord-gateway-runtime-state.ts deleted file mode 100644 index 33377a49..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-runtime-state.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { ChannelConnectionOwnerSnapshot } from "../application/channel-connection-health"; -import type { ChannelConnectionStatePayload } from "../application/channel-connection-state.service"; -import type { DiscordGatewayResumeState } from "./discord-gateway-client"; -import type { - DiscordGatewayRuntimeSnapshot, - DiscordGatewayRuntimeStatus, -} from "./discord-gateway-health"; - -interface SerializedDiscordGatewayRuntimeState { - readonly connectedAtMs: number | null; - readonly heartbeatIntervalMs: number | null; - readonly lastCloseCode: number | null; - readonly lastDispatchAtMs: number | null; - readonly lastErrorCode: string | null; - readonly lastHeartbeatAckAtMs: number | null; - readonly lastHeartbeatSentAtMs: number | null; - readonly resumeGatewayUrl: string | null; - readonly sequence: number | null; - readonly sessionId: string | null; - readonly status: DiscordGatewayRuntimeStatus; - readonly statusChangedAtMs: number; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readNullableNumber(value: Record, field: string): number | null { - const candidate = value[field]; - - if (candidate === null || candidate === undefined) { - return null; - } - - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readDiscordGatewayStatus(value: unknown): DiscordGatewayRuntimeStatus | null { - switch (value) { - case "connected": - case "connecting": - case "reconnecting": - case "stale": - case "stopped": - return value; - default: - return null; - } -} - -function mapDiscordGatewayStatus( - status: DiscordGatewayRuntimeStatus, -): ChannelConnectionOwnerSnapshot["status"] { - switch (status) { - case "connected": - return "running"; - case "connecting": - return "starting"; - case "reconnecting": - return "reconnecting"; - case "stale": - return "stale"; - case "stopped": - return "stopped"; - } -} - -function parseSerializedDiscordGatewayRuntimeState( - value: unknown, -): SerializedDiscordGatewayRuntimeState | null { - if (!isRecord(value)) { - return null; - } - - const status = readDiscordGatewayStatus(value["status"]); - const statusChangedAtMs = readNullableNumber(value, "statusChangedAtMs"); - - if (status === null || statusChangedAtMs === null) { - return null; - } - - return { - connectedAtMs: readNullableNumber(value, "connectedAtMs"), - heartbeatIntervalMs: readNullableNumber(value, "heartbeatIntervalMs"), - lastCloseCode: readNullableNumber(value, "lastCloseCode"), - lastDispatchAtMs: readNullableNumber(value, "lastDispatchAtMs"), - lastErrorCode: readString(value, "lastErrorCode"), - lastHeartbeatAckAtMs: readNullableNumber(value, "lastHeartbeatAckAtMs"), - lastHeartbeatSentAtMs: readNullableNumber(value, "lastHeartbeatSentAtMs"), - resumeGatewayUrl: readString(value, "resumeGatewayUrl"), - sequence: readNullableNumber(value, "sequence"), - sessionId: readString(value, "sessionId"), - status, - statusChangedAtMs, - }; -} - -function serializeDiscordGatewayRuntimeState(snapshot: DiscordGatewayRuntimeSnapshot): string { - const state: SerializedDiscordGatewayRuntimeState = { - connectedAtMs: snapshot.connectedAtMs, - heartbeatIntervalMs: snapshot.heartbeatIntervalMs, - lastCloseCode: snapshot.lastCloseCode, - lastDispatchAtMs: snapshot.lastDispatchAtMs, - lastErrorCode: snapshot.lastErrorCode, - lastHeartbeatAckAtMs: snapshot.lastHeartbeatAckAtMs, - lastHeartbeatSentAtMs: snapshot.lastHeartbeatSentAtMs, - resumeGatewayUrl: snapshot.resumeGatewayUrl, - sequence: snapshot.sequence, - sessionId: snapshot.sessionId, - status: snapshot.status, - statusChangedAtMs: snapshot.statusChangedAtMs, - }; - - return JSON.stringify(state); -} - -export function createDiscordGatewayRuntimeStatePayload( - snapshot: DiscordGatewayRuntimeSnapshot, -): ChannelConnectionStatePayload { - return { - lastErrorCode: snapshot.lastErrorCode, - lastHeartbeatAtMs: snapshot.lastHeartbeatAckAtMs ?? snapshot.lastHeartbeatSentAtMs, - lastInboundAtMs: snapshot.lastDispatchAtMs, - lastPollAtMs: null, - runtimeStateJson: serializeDiscordGatewayRuntimeState(snapshot), - status: mapDiscordGatewayStatus(snapshot.status), - statusChangedAtMs: snapshot.statusChangedAtMs, - }; -} - -export function parseDiscordGatewayResumeStateFromRuntimeState( - runtimeStateJson: string, -): DiscordGatewayResumeState | null { - let parsed: unknown; - - try { - parsed = JSON.parse(runtimeStateJson); - } catch { - return null; - } - - const state = parseSerializedDiscordGatewayRuntimeState(parsed); - - if (!state || !state.resumeGatewayUrl || state.sequence === null || !state.sessionId) { - return null; - } - - return { - resumeGatewayUrl: state.resumeGatewayUrl, - sequence: state.sequence, - sessionId: state.sessionId, - }; -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway-socket.ts b/apps/api/src/modules/channels/discord/discord-gateway-socket.ts deleted file mode 100644 index 9c5f1f04..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway-socket.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { DiscordGatewayWritableSocket } from "./discord-gateway-client"; - -export interface DiscordGatewaySocket extends DiscordGatewayWritableSocket { - onClose(listener: (event: { code: number }) => void): void; - onError(listener: (event: Event) => void): void; - onMessage(listener: (event: { data: ArrayBuffer | string }) => void): void; -} - -export type DiscordGatewayConnect = (url: string) => DiscordGatewaySocket; - -export function createDefaultGatewaySocket(url: string): DiscordGatewaySocket { - const socket = new WebSocket(url); - - return { - onClose(listener) { - socket.addEventListener("close", (event) => listener({ code: event.code })); - }, - onError(listener) { - socket.addEventListener("error", listener); - }, - onMessage(listener) { - socket.addEventListener("message", (event) => { - if (typeof event.data === "string" || event.data instanceof ArrayBuffer) { - listener({ data: event.data }); - return; - } - - throw new Error("Discord Gateway message event must be text or binary."); - }); - }, - close(code?: number, reason?: string) { - socket.close(code, reason); - }, - send(data: string) { - socket.send(data); - }, - }; -} - -export function readGatewayMessageText(data: ArrayBuffer | string): string { - if (typeof data === "string") { - return data; - } - - return new TextDecoder().decode(data); -} diff --git a/apps/api/src/modules/channels/discord/discord-gateway.do.ts b/apps/api/src/modules/channels/discord/discord-gateway.do.ts deleted file mode 100644 index f731d2cc..00000000 --- a/apps/api/src/modules/channels/discord/discord-gateway.do.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, ChannelBindingId, AppId } from "@mosoo/id"; - -import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { currentTimestampMs } from "../../../time"; -import { recordAgentChannelBindingError } from "../application/agent-channel-binding-error"; -import { resolveAgentChannelBindingContextById } from "../application/channel-binding-context"; -import { - releaseChannelConnectionOwner, - readChannelConnectionOwnerState, -} from "../application/channel-connection-state.service"; -import { parseDiscordCredentials } from "./discord-credentials"; -import type { DiscordGatewayDispatchEnvelope } from "./discord-events"; -import type { DiscordGatewayRuntimeSnapshot } from "./discord-gateway-health"; -import { - DiscordGatewayConnectionRelayError, - DiscordGatewayRuntimeOwner, -} from "./discord-gateway-owner"; -import type { DiscordGatewayRuntimeOwnerOptions } from "./discord-gateway-owner"; -import type { DiscordGatewayRelayRequest } from "./discord-gateway-relay"; -import { parseDiscordGatewayResumeStateFromRuntimeState } from "./discord-gateway-runtime-state"; -import type { DiscordGatewayConnect, DiscordGatewaySocket } from "./discord-gateway-socket"; -import { createDefaultGatewaySocket, readGatewayMessageText } from "./discord-gateway-socket"; -import { DiscordWebApiClient, DiscordWebApiError } from "./discord-web-api"; - -const DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"; -const DISCORD_GATEWAY_OWNER_LEASE_DURATION_MS = 2 * 60 * 1000; -const DISCORD_GATEWAY_RECONNECT_DELAY_MS = 5 * 1000; -const DISCORD_GATEWAY_MIN_HEARTBEAT_DELAY_MS = 1 * 1000; -const DISCORD_GATEWAY_BINDING_STORAGE_KEY = "bindingId"; -const DISCORD_GATEWAY_AUTH_FAILED_CLOSE = 4004; -const DISCORD_GATEWAY_DISALLOWED_INTENTS_CLOSE = 4014; - -export type DiscordGatewayStartResult = - | { - bindingId: string; - status: "already_started" | "started"; - snapshot: DiscordGatewayRuntimeSnapshot; - } - | { - bindingId: string; - status: "binding_not_found" | "lease_held"; - }; - -export interface DiscordGatewayStopResult { - bindingId: string; - status: "not_started" | "stopped"; -} - -export interface DiscordGatewaySnapshotResult { - active: boolean; - bindingId: string | null; - snapshot: DiscordGatewayRuntimeSnapshot | null; -} - -export interface DiscordGatewayDurableObjectStorage { - delete(key: string): Promise; - deleteAlarm(): Promise; - get(key: string): Promise; - put(key: string, value: unknown): Promise; - setAlarm(scheduledTime: Date | number): Promise; -} - -export interface DiscordGatewayDurableObjectState { - storage: DiscordGatewayDurableObjectStorage; -} - -interface ActiveDiscordGatewayConnection { - agentId: AgentId; - bindingId: ChannelBindingId; - owner: DiscordGatewayRuntimeOwner; - appId: AppId; - socket: DiscordGatewaySocket; -} - -export interface DiscordGatewayConnectionRuntimeServiceOptions { - connectGateway?: DiscordGatewayConnect; - nowMs?: () => number; - relayFetch?: (request: DiscordGatewayRelayRequest) => Promise; -} - -function getApiBaseUrl(bindings: ApiBindings): string { - return bindings.MOSOO_API_BASE_URL ?? bindings.WEB_ORIGIN; -} - -function createOwnerId(bindingId: ChannelBindingId): string { - return `discord-gateway:${bindingId}`; -} - -function parseDiscordGatewayBindingId(bindingId: string): ChannelBindingId { - return parsePlatformId(bindingId, "Discord Gateway binding ID"); -} - -function toRelayChannelTypeError(error: DiscordWebApiError): DiscordGatewayConnectionRelayError { - return new DiscordGatewayConnectionRelayError(`channel_type_${error.code}`); -} - -function isFatalGatewayCloseCode(code: number): boolean { - return ( - code === DISCORD_GATEWAY_AUTH_FAILED_CLOSE || code === DISCORD_GATEWAY_DISALLOWED_INTENTS_CLOSE - ); -} - -function getFatalGatewayCloseErrorCode(code: number): string { - switch (code) { - case DISCORD_GATEWAY_AUTH_FAILED_CLOSE: - return "discord_gateway_authentication_failed"; - case DISCORD_GATEWAY_DISALLOWED_INTENTS_CLOSE: - return "discord_gateway_disallowed_intents"; - default: - return `discord_gateway_close_${code}`; - } -} - -function isHeartbeatAckOverdue(snapshot: DiscordGatewayRuntimeSnapshot, nowMs: number): boolean { - if (snapshot.heartbeatIntervalMs === null || snapshot.lastHeartbeatSentAtMs === null) { - return false; - } - - if ( - snapshot.lastHeartbeatAckAtMs !== null && - snapshot.lastHeartbeatAckAtMs >= snapshot.lastHeartbeatSentAtMs - ) { - return false; - } - - return nowMs - snapshot.lastHeartbeatSentAtMs >= snapshot.heartbeatIntervalMs; -} - -export class DiscordGatewayConnectionRuntimeService { - readonly #bindings: ApiBindings; - readonly #connectGateway: DiscordGatewayConnect; - readonly #nowMs: () => number; - readonly #relayFetch: ((request: DiscordGatewayRelayRequest) => Promise) | undefined; - readonly #state: DiscordGatewayDurableObjectState; - #active: ActiveDiscordGatewayConnection | null = null; - #stoppingBindingId: ChannelBindingId | null = null; - - constructor( - state: DiscordGatewayDurableObjectState, - bindings: ApiBindings, - options: DiscordGatewayConnectionRuntimeServiceOptions = {}, - ) { - this.#bindings = bindings; - this.#connectGateway = options.connectGateway ?? createDefaultGatewaySocket; - this.#nowMs = options.nowMs ?? currentTimestampMs; - this.#relayFetch = options.relayFetch; - this.#state = state; - } - - async start(bindingId: string): Promise { - const requestedBindingId = parseDiscordGatewayBindingId(bindingId); - - if (this.#stoppingBindingId === requestedBindingId) { - await this.#state.storage.deleteAlarm(); - await this.#state.storage.delete(DISCORD_GATEWAY_BINDING_STORAGE_KEY); - return { - bindingId: requestedBindingId, - status: "binding_not_found", - }; - } - - if (this.#active) { - if (this.#active.bindingId !== requestedBindingId) { - throw new Error("Discord Gateway Durable Object received a mismatched binding id."); - } - - return { - bindingId: requestedBindingId, - snapshot: this.#active.owner.getSnapshot(), - status: "already_started", - }; - } - - const binding = await resolveAgentChannelBindingContextById(this.#bindings, { - bindingId: requestedBindingId, - provider: "discord", - }); - - if (!binding || binding.agentStatus !== "published") { - await this.#state.storage.deleteAlarm(); - await this.#state.storage.delete(DISCORD_GATEWAY_BINDING_STORAGE_KEY); - return { - bindingId: requestedBindingId, - status: "binding_not_found", - }; - } - - const credentials = parseDiscordCredentials(binding.credentialsJson); - const storedState = await readChannelConnectionOwnerState({ - bindingId: requestedBindingId, - bindings: this.#bindings, - provider: "discord", - }); - const resumeState = storedState - ? parseDiscordGatewayResumeStateFromRuntimeState(storedState.runtimeStateJson) - : null; - const socket = this.#connectGateway(resumeState?.resumeGatewayUrl ?? DISCORD_GATEWAY_URL); - const ownerOptions: DiscordGatewayRuntimeOwnerOptions = { - apiBaseUrl: getApiBaseUrl(this.#bindings), - bindingId: requestedBindingId, - bindings: this.#bindings, - botToken: credentials.botToken, - leaseDurationMs: DISCORD_GATEWAY_OWNER_LEASE_DURATION_MS, - nowMs: this.#nowMs, - ownerId: createOwnerId(requestedBindingId), - relaySecret: credentials.relaySecret, - resolveRelayChannelType: (envelope) => - this.#resolveRelayChannelType(credentials.botToken, envelope), - socket, - }; - - if (this.#relayFetch) { - ownerOptions.relayFetch = this.#relayFetch; - } - - const owner = await DiscordGatewayRuntimeOwner.claim(ownerOptions); - - if (!owner) { - socket.close(4000, "Discord Gateway lease is held by another owner."); - await this.#scheduleReconnect(); - return { - bindingId: requestedBindingId, - status: "lease_held", - }; - } - - const active = { - agentId: binding.agentId, - bindingId: requestedBindingId, - owner, - appId: binding.appId, - socket, - }; - this.#active = active; - this.#attachSocketListeners(active); - await this.#state.storage.put(DISCORD_GATEWAY_BINDING_STORAGE_KEY, requestedBindingId); - await this.#scheduleNextAlarm(owner.getSnapshot()); - - return { - bindingId: requestedBindingId, - snapshot: owner.getSnapshot(), - status: "started", - }; - } - - async stop(bindingId: string): Promise { - const requestedBindingId = parseDiscordGatewayBindingId(bindingId); - const active = this.#active; - - if (active && active.bindingId !== requestedBindingId) { - throw new Error("Discord Gateway Durable Object received a mismatched binding id."); - } - - this.#stoppingBindingId = requestedBindingId; - try { - await this.#state.storage.deleteAlarm(); - await this.#state.storage.delete(DISCORD_GATEWAY_BINDING_STORAGE_KEY); - - if (!active) { - await this.#releaseOwner(requestedBindingId); - return { - bindingId: requestedBindingId, - status: "not_started", - }; - } - - active.socket.close(1000, "Discord Gateway connection stopped."); - this.#active = null; - await this.#releaseOwner(requestedBindingId); - } finally { - if (this.#stoppingBindingId === requestedBindingId) { - this.#stoppingBindingId = null; - } - } - - return { - bindingId: requestedBindingId, - status: "stopped", - }; - } - - snapshot(bindingId: string): DiscordGatewaySnapshotResult { - const requestedBindingId = parseDiscordGatewayBindingId(bindingId); - - if (!this.#active) { - return { - active: false, - bindingId: null, - snapshot: null, - }; - } - - if (this.#active.bindingId !== requestedBindingId) { - throw new Error("Discord Gateway Durable Object received a mismatched binding id."); - } - - return { - active: true, - bindingId: requestedBindingId, - snapshot: this.#active.owner.getSnapshot(), - }; - } - - async alarm(): Promise { - const active = this.#active; - - if (active) { - try { - if (active.owner.getSnapshot().heartbeatIntervalMs === null) { - await active.owner.handleError("hello_timeout"); - active.socket.close(4000, "Discord Gateway HELLO timed out."); - this.#active = null; - await this.#scheduleReconnect(); - return; - } - - if (isHeartbeatAckOverdue(active.owner.getSnapshot(), this.#nowMs())) { - await active.owner.handleError("heartbeat_ack_timeout"); - active.socket.close(4000, "Discord Gateway heartbeat ACK timed out."); - this.#active = null; - await this.#scheduleReconnect(); - return; - } - - await active.owner.sendHeartbeat(); - await this.#scheduleNextAlarm(active.owner.getSnapshot()); - } catch (error) { - logError("discord-gateway-do.heartbeat_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - }); - active.socket.close(4000, "Discord Gateway heartbeat failed."); - this.#active = null; - await this.#scheduleReconnect(); - } - return; - } - - const bindingId = await this.#state.storage.get(DISCORD_GATEWAY_BINDING_STORAGE_KEY); - - if (!bindingId) { - return; - } - - await this.start(bindingId); - } - - async #resolveRelayChannelType( - botToken: string, - envelope: DiscordGatewayDispatchEnvelope, - ): Promise { - try { - return await new DiscordWebApiClient(botToken).getChannelType({ - channelId: envelope.message.channelId, - }); - } catch (error) { - if (error instanceof DiscordWebApiError) { - throw toRelayChannelTypeError(error); - } - - throw error; - } - } - - #attachSocketListeners(active: ActiveDiscordGatewayConnection): void { - active.socket.onMessage((event) => { - void this.#handleSocketMessage(active, event); - }); - active.socket.onClose((event) => { - void this.#handleSocketClose(active, event); - }); - active.socket.onError(() => { - void this.#handleSocketError(active); - }); - } - - async #handleSocketMessage( - active: ActiveDiscordGatewayConnection, - event: { data: ArrayBuffer | string }, - ): Promise { - if (this.#active !== active) { - return; - } - - try { - const action = await active.owner.handleMessage(readGatewayMessageText(event.data)); - - if ( - action === "invalid_session" || - action === "protocol_error" || - action === "reconnect_requested" - ) { - this.#active = null; - await this.#scheduleReconnect(); - return; - } - - await this.#scheduleNextAlarm(active.owner.getSnapshot()); - } catch (error) { - if (error instanceof DiscordGatewayConnectionRelayError) { - logError("discord-gateway-do.relay_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - }); - await this.#scheduleNextAlarm(active.owner.getSnapshot()); - return; - } - - logError("discord-gateway-do.message_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - }); - active.socket.close(4000, "Discord Gateway message handling failed."); - this.#active = null; - await this.#scheduleReconnect(); - } - } - - async #handleSocketClose( - active: ActiveDiscordGatewayConnection, - event: { code: number }, - ): Promise { - if (this.#active !== active) { - return; - } - - const stopping = this.#stoppingBindingId === active.bindingId; - this.#active = null; - - if (isFatalGatewayCloseCode(event.code)) { - try { - await active.owner.handleError(`gateway_close_${event.code}`); - await this.#releaseOwner(active.bindingId, "failed"); - await recordAgentChannelBindingError(this.#bindings.DB, { - agentId: active.agentId, - bindingId: active.bindingId, - errorCode: getFatalGatewayCloseErrorCode(event.code), - appId: active.appId, - }); - } catch (error) { - logError("discord-gateway-do.fatal_close_persist_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - closeCode: event.code, - }); - } - try { - await this.#state.storage.delete(DISCORD_GATEWAY_BINDING_STORAGE_KEY); - await this.#state.storage.deleteAlarm(); - } catch (error) { - logError("discord-gateway-do.fatal_close_local_cleanup_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - closeCode: event.code, - }); - } - return; - } - - if (stopping) { - return; - } - - try { - await active.owner.handleClose(event.code); - } catch (error) { - logError("discord-gateway-do.close_persist_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - }); - } - - await this.#scheduleReconnect(); - } - - async #handleSocketError(active: ActiveDiscordGatewayConnection): Promise { - if (this.#active !== active) { - return; - } - - try { - await active.owner.handleError("socket_error"); - } catch (error) { - logError("discord-gateway-do.socket_error_persist_failed", { - ...createErrorLogContext(error), - bindingId: active.bindingId, - }); - } - - active.socket.close(4000, "Discord Gateway socket error."); - this.#active = null; - await this.#scheduleReconnect(); - } - - async #releaseOwner( - bindingId: ChannelBindingId, - status: "failed" | "stopped" = "stopped", - ): Promise { - await releaseChannelConnectionOwner({ - bindingId, - bindings: this.#bindings, - nowMs: this.#nowMs(), - ownerId: createOwnerId(bindingId), - provider: "discord", - status, - }); - } - - async #scheduleNextAlarm(snapshot: DiscordGatewayRuntimeSnapshot): Promise { - const delayMs = snapshot.heartbeatIntervalMs ?? DISCORD_GATEWAY_RECONNECT_DELAY_MS; - const boundedDelayMs = Math.max(DISCORD_GATEWAY_MIN_HEARTBEAT_DELAY_MS, delayMs); - await this.#state.storage.setAlarm(this.#nowMs() + boundedDelayMs); - } - - async #scheduleReconnect(): Promise { - await this.#state.storage.setAlarm(this.#nowMs() + DISCORD_GATEWAY_RECONNECT_DELAY_MS); - logInfo("discord-gateway-do.reconnect_scheduled", {}); - } -} diff --git a/apps/api/src/modules/channels/discord/discord-signing.ts b/apps/api/src/modules/channels/discord/discord-signing.ts deleted file mode 100644 index 0127e9c3..00000000 --- a/apps/api/src/modules/channels/discord/discord-signing.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -const SIGNATURE_VERSION = "v0"; -const MAX_CLOCK_SKEW_SECONDS = 60 * 5; -const DECIMAL_TIMESTAMP_PATTERN = /^\d+$/u; - -export interface DiscordRelaySignatureVerificationInput { - body: string; - headers: Headers; - nowSeconds?: number; - relaySecret: string; -} - -export interface DiscordRelaySignatureVerificationFailure { - code: "missing_header" | "signature_mismatch" | "stale_timestamp"; - message: string; - ok: false; - status: 400 | 401; -} - -export interface DiscordRelaySignatureVerificationSuccess { - ok: true; -} - -export type DiscordRelaySignatureVerificationResult = - | DiscordRelaySignatureVerificationFailure - | DiscordRelaySignatureVerificationSuccess; - -function bytesToHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -function timingSafeEqual(left: string, right: string): boolean { - const encoder = new TextEncoder(); - const leftBytes = encoder.encode(left); - const rightBytes = encoder.encode(right); - - if (leftBytes.length !== rightBytes.length) { - return false; - } - - let difference = 0; - - for (let index = 0; index < leftBytes.length; index += 1) { - difference |= (leftBytes.at(index) ?? 0) ^ (rightBytes.at(index) ?? 0); - } - - return difference === 0; -} - -export async function createDiscordRelaySignature(input: { - body: string; - relaySecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.relaySecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const baseString = `${SIGNATURE_VERSION}:${input.timestamp}:${input.body}`; - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(baseString)); - - return `${SIGNATURE_VERSION}=${bytesToHex(signature)}`; -} - -export async function verifyDiscordRelaySignature( - input: DiscordRelaySignatureVerificationInput, -): Promise { - const timestamp = input.headers.get("x-mosoo-discord-relay-timestamp"); - const signature = input.headers.get("x-mosoo-discord-relay-signature"); - - if (!isTruthy(timestamp) || !isTruthy(signature)) { - return { - code: "missing_header", - message: "Discord relay signature headers are required.", - ok: false, - status: 400, - }; - } - - if (!DECIMAL_TIMESTAMP_PATTERN.test(timestamp)) { - return { - code: "missing_header", - message: "Discord relay timestamp is invalid.", - ok: false, - status: 400, - }; - } - - const parsedTimestamp = Number.parseInt(timestamp, 10); - - if (!Number.isSafeInteger(parsedTimestamp)) { - return { - code: "missing_header", - message: "Discord relay timestamp is invalid.", - ok: false, - status: 400, - }; - } - - const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000); - - if (Math.abs(nowSeconds - parsedTimestamp) > MAX_CLOCK_SKEW_SECONDS) { - return { - code: "stale_timestamp", - message: "Discord relay timestamp is outside the accepted window.", - ok: false, - status: 401, - }; - } - - const expected = await createDiscordRelaySignature({ - body: input.body, - relaySecret: input.relaySecret, - timestamp, - }); - - if (!timingSafeEqual(expected, signature)) { - return { - code: "signature_mismatch", - message: "Discord relay signature is invalid.", - ok: false, - status: 401, - }; - } - - return { ok: true }; -} diff --git a/apps/api/src/modules/channels/discord/discord-web-api.ts b/apps/api/src/modules/channels/discord/discord-web-api.ts deleted file mode 100644 index a63d5bed..00000000 --- a/apps/api/src/modules/channels/discord/discord-web-api.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { fetchChannelWebApi, readChannelWebApiJson } from "../channel-fetch"; - -export interface DiscordSendMessageInput { - channelId: string; - text: string; -} - -export interface DiscordEditMessageInput { - channelId: string; - messageId: string; - text: string; -} - -export interface DiscordMessageReference { - channelId: string; - messageId: string; -} - -type DiscordWebApiOperation = - | "editMessage" - | "getChannelType" - | "getCurrentBotUser" - | "sendMessage"; - -const DISCORD_ALLOWED_MENTIONS_DISABLED = { parse: [] } as const; - -export class DiscordWebApiError extends Error { - readonly code: string; - readonly operation: DiscordWebApiOperation; - - constructor(operation: DiscordWebApiOperation, code: string) { - super(`Discord ${operation} failed: ${code}`); - this.code = code; - this.name = "DiscordWebApiError"; - this.operation = operation; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, key: string): string | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "string" && field.trim() ? field : null; -} - -function readBoolean(value: unknown, key: string): boolean { - return isRecord(value) && value[key] === true; -} - -function readNumber(value: unknown, key: string): number | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "number" && Number.isSafeInteger(field) ? field : null; -} - -function getDiscordErrorCode( - response: Response, - body: unknown, - operation: DiscordWebApiOperation, -): string { - const message = readString(body, "message"); - - if (message) { - return message; - } - - return response.statusText.trim() || `${operation}_failed`; -} - -export class DiscordWebApiClient { - readonly #botToken: string; - readonly #timeoutMs: number | undefined; - - constructor(botToken: string, options: { timeoutMs?: number } = {}) { - this.#botToken = botToken; - this.#timeoutMs = options.timeoutMs; - } - - async getCurrentBotUser(): Promise<{ - bot: boolean; - id: string; - username: string | null; - }> { - const response = await fetchChannelWebApi({ - init: { - headers: { - Authorization: `Bot ${this.#botToken}`, - }, - method: "GET", - }, - label: "Discord users/@me", - timeoutMs: this.#timeoutMs, - url: "https://discord.com/api/v10/users/@me", - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok) { - throw new DiscordWebApiError( - "getCurrentBotUser", - getDiscordErrorCode(response, body, "getCurrentBotUser"), - ); - } - - const id = readString(body, "id"); - - if (!id) { - throw new Error("Discord current user response did not include a user id."); - } - - return { - bot: readBoolean(body, "bot"), - id, - username: readString(body, "username"), - }; - } - - async getChannelType(input: { channelId: string }): Promise { - const response = await fetchChannelWebApi({ - init: { - headers: { - Authorization: `Bot ${this.#botToken}`, - }, - method: "GET", - }, - label: "Discord getChannelType", - timeoutMs: this.#timeoutMs, - url: `https://discord.com/api/v10/channels/${encodeURIComponent(input.channelId)}`, - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok) { - throw new DiscordWebApiError( - "getChannelType", - getDiscordErrorCode(response, body, "getChannelType"), - ); - } - - const channelType = readNumber(body, "type"); - - if (channelType === null) { - throw new Error("Discord channel response did not include a channel type."); - } - - return channelType; - } - - async sendMessage(input: DiscordSendMessageInput): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - allowed_mentions: DISCORD_ALLOWED_MENTIONS_DISABLED, - content: input.text, - }), - headers: { - Authorization: `Bot ${this.#botToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Discord sendMessage", - timeoutMs: this.#timeoutMs, - url: `https://discord.com/api/v10/channels/${encodeURIComponent(input.channelId)}/messages`, - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok) { - throw new DiscordWebApiError( - "sendMessage", - getDiscordErrorCode(response, body, "sendMessage"), - ); - } - - const messageId = readString(body, "id"); - - if (!messageId) { - throw new Error("Discord send message response did not include a message id."); - } - - return { - channelId: input.channelId, - messageId, - }; - } - - async editMessage(input: DiscordEditMessageInput): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - allowed_mentions: DISCORD_ALLOWED_MENTIONS_DISABLED, - content: input.text, - }), - headers: { - Authorization: `Bot ${this.#botToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "PATCH", - }, - label: "Discord editMessage", - timeoutMs: this.#timeoutMs, - url: `https://discord.com/api/v10/channels/${encodeURIComponent( - input.channelId, - )}/messages/${encodeURIComponent(input.messageId)}`, - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok) { - throw new DiscordWebApiError( - "editMessage", - getDiscordErrorCode(response, body, "editMessage"), - ); - } - } -} diff --git a/apps/api/src/modules/channels/graphql/channel-graphql.ts b/apps/api/src/modules/channels/graphql/channel-graphql.ts deleted file mode 100644 index 166ec25b..00000000 --- a/apps/api/src/modules/channels/graphql/channel-graphql.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppId } from "@mosoo/id"; - -import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; -import { channelGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { - createDiscordAgentChannelBinding, - createLarkAgentChannelBinding, - createSlackAgentChannelBinding, - createTelegramAgentChannelBinding, - deleteAgentChannelBinding, - listAgentChannelBindings, - pollLarkAgentChannelRegistration, - pollWeChatAgentChannelPairing, - startLarkAgentChannelRegistration, - startWeChatAgentChannelPairing, -} from "../application/agent-channel-binding.service"; - -interface AgentChannelBindingListArgs { - agentId: string; - appId: string; -} - -interface CreateSlackAgentChannelBindingArgs { - input: Parameters[2]; -} - -interface CreateLarkAgentChannelBindingArgs { - input: Parameters[2]; -} - -interface CreateTelegramAgentChannelBindingArgs { - input: Parameters[2]; -} - -interface CreateDiscordAgentChannelBindingArgs { - input: Parameters[2]; -} - -interface StartLarkAgentChannelRegistrationArgs { - input: Parameters[2]; -} - -interface PollLarkAgentChannelRegistrationArgs { - input: Parameters[2]; -} - -interface StartWeChatAgentChannelPairingArgs { - input: Parameters[2]; -} - -interface PollWeChatAgentChannelPairingArgs { - input: Parameters[2]; -} - -interface DeleteAgentChannelBindingArgs { - input: Parameters[2]; -} - -export const channelGraphQLModule = { - ...channelGraphQLSpec, - authenticatedMutationResolvers: { - createDiscordAgentChannelBinding: async ( - _parent, - args: CreateDiscordAgentChannelBindingArgs, - context, - ) => createDiscordAgentChannelBinding(context.bindings, context.viewer, args.input), - createLarkAgentChannelBinding: async ( - _parent, - args: CreateLarkAgentChannelBindingArgs, - context, - ) => createLarkAgentChannelBinding(context.bindings, context.viewer, args.input), - createSlackAgentChannelBinding: async ( - _parent, - args: CreateSlackAgentChannelBindingArgs, - context, - ) => createSlackAgentChannelBinding(context.bindings, context.viewer, args.input), - createTelegramAgentChannelBinding: async ( - _parent, - args: CreateTelegramAgentChannelBindingArgs, - context, - ) => createTelegramAgentChannelBinding(context.bindings, context.viewer, args.input), - pollLarkAgentChannelRegistration: async ( - _parent, - args: PollLarkAgentChannelRegistrationArgs, - context, - ) => pollLarkAgentChannelRegistration(context.bindings, context.viewer, args.input), - pollWeChatAgentChannelPairing: async ( - _parent, - args: PollWeChatAgentChannelPairingArgs, - context, - ) => pollWeChatAgentChannelPairing(context.bindings, context.viewer, args.input), - startLarkAgentChannelRegistration: async ( - _parent, - args: StartLarkAgentChannelRegistrationArgs, - context, - ) => startLarkAgentChannelRegistration(context.bindings, context.viewer, args.input), - startWeChatAgentChannelPairing: async ( - _parent, - args: StartWeChatAgentChannelPairingArgs, - context, - ) => startWeChatAgentChannelPairing(context.bindings, context.viewer, args.input), - deleteAgentChannelBinding: async (_parent, args: DeleteAgentChannelBindingArgs, context) => { - await deleteAgentChannelBinding(context.bindings, context.viewer, args.input); - return { ok: true } as const; - }, - }, - authenticatedQueryResolvers: { - agentChannelBindingList: async (_parent, args: AgentChannelBindingListArgs, context) => { - const agentId = parsePlatformId(args.agentId, "agent ID"); - const appId = parsePlatformId(args.appId, "app ID"); - return listAgentChannelBindings(context.bindings.DB, context.viewer, { - agentId, - appId, - }); - }, - }, -} satisfies GraphQLModule; diff --git a/apps/api/src/modules/channels/lark/lark-app-registration.ts b/apps/api/src/modules/channels/lark/lark-app-registration.ts deleted file mode 100644 index ee68cbf3..00000000 --- a/apps/api/src/modules/channels/lark/lark-app-registration.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { fetchChannelWebApi } from "../channel-fetch"; -import type { LarkDomain } from "./lark-events"; - -const FEISHU_ACCOUNTS_ORIGIN = "https://accounts.feishu.cn"; -const LARK_ACCOUNTS_ORIGIN = "https://accounts.larksuite.com"; -const LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration"; -const LARK_APP_REGISTRATION_TIMEOUT_MS = 10_000; - -export type LarkAppRegistrationStatus = - | "access_denied" - | "confirmed" - | "expired" - | "failed" - | "qr_pending" - | "slow_down"; - -export interface LarkAppRegistrationStartResult { - deviceCode: string; - domain: LarkDomain; - expireIn: number; - interval: number; - qrUrl: string; - status: "qr_pending"; - userCode: string; -} - -export interface LarkAppRegistrationPollResult { - appId: string | null; - appSecret: string | null; - domain: LarkDomain; - lastErrorCode: string | null; - openId: string | null; - status: Exclude | "failed"; -} - -interface LarkRegistrationInitResponse { - supported_auth_methods?: unknown; -} - -interface LarkRegistrationBeginResponse { - device_code?: unknown; - expire_in?: unknown; - interval?: unknown; - user_code?: unknown; - verification_uri_complete?: unknown; -} - -interface LarkRegistrationPollResponse { - client_id?: unknown; - client_secret?: unknown; - error?: unknown; - error_description?: unknown; - user_info?: unknown; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, key: string): string | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "string" && field.trim().length > 0 ? field.trim() : null; -} - -function accountsOrigin(domain: LarkDomain): string { - return domain === "lark" ? LARK_ACCOUNTS_ORIGIN : FEISHU_ACCOUNTS_ORIGIN; -} - -function registrationUrl(domain: LarkDomain): string { - return `${accountsOrigin(domain)}${LARK_APP_REGISTRATION_PATH}`; -} - -async function postRegistration( - domain: LarkDomain, - body: Record, -): Promise<{ - ok: boolean; - value: Record; -}> { - const response = await fetchChannelWebApi({ - init: { - body: new URLSearchParams(body).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - method: "POST", - }, - label: "Lark app registration", - timeoutMs: LARK_APP_REGISTRATION_TIMEOUT_MS, - url: registrationUrl(domain), - }); - const parsed: unknown = await response.json(); - - if (!isRecord(parsed)) { - throw new Error("Lark app registration response must be a JSON object."); - } - - return { - ok: response.ok, - value: parsed, - }; -} - -function supportsClientSecret(response: LarkRegistrationInitResponse): boolean { - return ( - Array.isArray(response.supported_auth_methods) && - response.supported_auth_methods.some((method) => method === "client_secret") - ); -} - -function requireString(value: unknown, label: string): string { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - - throw new Error(`${label} is required.`); -} - -function readIntervalSeconds(response: LarkRegistrationBeginResponse): number { - const interval = typeof response.interval === "number" ? response.interval : 5; - return Number.isFinite(interval) && interval > 0 ? interval : 5; -} - -function readExpireInSeconds(response: LarkRegistrationBeginResponse): number { - const expireIn = typeof response.expire_in === "number" ? response.expire_in : 600; - return Number.isFinite(expireIn) && expireIn > 0 ? expireIn : 600; -} - -function buildQrUrl(rawUrl: string): string { - const url = new URL(rawUrl); - url.searchParams.set("from", "mosoo_channel_setup"); - url.searchParams.set("tp", "ob_cli_app"); - return url.toString(); -} - -function readTenantBrand(response: LarkRegistrationPollResponse): LarkDomain | null { - const tenantBrand = readString(response.user_info, "tenant_brand"); - - if (tenantBrand === "feishu" || tenantBrand === "lark") { - return tenantBrand; - } - - return null; -} - -function readOpenId(response: LarkRegistrationPollResponse): string | null { - return readString(response.user_info, "open_id"); -} - -function toPollStatus(errorCode: string | null): LarkAppRegistrationPollResult["status"] { - switch (errorCode) { - case null: - case "authorization_pending": - return "qr_pending"; - case "slow_down": - return "slow_down"; - case "access_denied": - return "access_denied"; - case "expired_token": - case "expired": - return "expired"; - default: - return "failed"; - } -} - -export async function startLarkAppRegistration( - domain: LarkDomain, -): Promise { - const init = await postRegistration(domain, { action: "init" }); - - if (!init.ok || !supportsClientSecret(init.value)) { - throw new Error("Lark app registration does not support client_secret auth."); - } - - const begin = await postRegistration(domain, { - action: "begin", - archetype: "PersonalAgent", - auth_method: "client_secret", - request_user_info: "open_id", - }); - - if (!begin.ok) { - throw new Error("Lark app registration begin request failed."); - } - - const body = begin.value as LarkRegistrationBeginResponse; - - return { - deviceCode: requireString(body.device_code, "Lark app registration device code"), - domain, - expireIn: readExpireInSeconds(body), - interval: readIntervalSeconds(body), - qrUrl: buildQrUrl(requireString(body.verification_uri_complete, "Lark app registration URL")), - status: "qr_pending", - userCode: requireString(body.user_code, "Lark app registration user code"), - }; -} - -export async function pollLarkAppRegistration(input: { - deviceCode: string; - domain: LarkDomain; -}): Promise { - const deviceCode = input.deviceCode.trim(); - - if (!deviceCode) { - throw new Error("Lark app registration device code is required."); - } - - const poll = await postRegistration(input.domain, { - action: "poll", - device_code: deviceCode, - tp: "ob_app", - }); - const body = poll.value as LarkRegistrationPollResponse; - const tenantBrand = readTenantBrand(body); - - if (tenantBrand === "lark" && input.domain !== "lark") { - return pollLarkAppRegistration({ - deviceCode, - domain: "lark", - }); - } - - const appId = typeof body.client_id === "string" ? body.client_id.trim() : ""; - const appSecret = typeof body.client_secret === "string" ? body.client_secret.trim() : ""; - - if (appId && appSecret) { - return { - appId, - appSecret, - domain: tenantBrand ?? input.domain, - lastErrorCode: null, - openId: readOpenId(body), - status: "confirmed", - }; - } - - const errorCode = - typeof body.error === "string" && body.error.trim().length > 0 ? body.error.trim() : null; - const status = toPollStatus(errorCode); - // RFC 8628 device-flow polling reuses the `error` field for non-terminal - // signals (`authorization_pending`, `slow_down`). Only persist `lastErrorCode` - // when the status is actually a terminal failure — otherwise the UI surfaces - // a normal "still waiting" tick as a red error. - const isTerminalFailure = - status === "access_denied" || status === "expired" || status === "failed"; - - return { - appId: null, - appSecret: null, - domain: tenantBrand ?? input.domain, - lastErrorCode: isTerminalFailure - ? (errorCode ?? readString(body, "error_description") ?? "app_registration_failed") - : null, - openId: readOpenId(body), - status, - }; -} diff --git a/apps/api/src/modules/channels/lark/lark-credentials.ts b/apps/api/src/modules/channels/lark/lark-credentials.ts deleted file mode 100644 index b58f6f60..00000000 --- a/apps/api/src/modules/channels/lark/lark-credentials.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { API_ERROR_CODE, validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; -import type { LarkDomain } from "./lark-events"; - -export type LarkConnectionMode = "webhook" | "websocket"; - -export interface LarkChannelCredentials { - appId: string; - appSecret: string; - connectionMode: LarkConnectionMode; - domain: LarkDomain; - encryptKey: string | null; - verificationToken: string | null; -} - -export interface NormalizeLarkCredentialsInput { - appId: string; - appSecret: string; - connectionMode: LarkConnectionMode; - domain: LarkDomain; - encryptKey: string | null; - verificationToken: string | null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRequiredString(value: string, label: string): string { - const normalized = value.trim(); - - if (!isTruthy(normalized)) { - throw validationError(`${label} is required.`); - } - - return normalized; -} - -function normalizeOptionalString(value: string | null): string | null { - if (value === null) { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function readRequiredString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - throw new Error(`Lark channel credential ${field} is required.`); -} - -function readOptionalString(value: Record, field: string): string | null { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - return null; -} - -function readConnectionMode(value: Record): LarkConnectionMode { - const candidate = value["connectionMode"]; - - if (candidate === "websocket" || candidate === "webhook") { - return candidate; - } - - throw new Error("Lark channel credential connectionMode must be websocket or webhook."); -} - -function normalizeLarkDomainValue(value: unknown): LarkDomain { - if (value === "feishu" || value === "lark") { - return value; - } - - throw validationError("Lark domain must be lark or feishu.", "LARK_DOMAIN_INVALID"); -} - -function normalizeConnectionModeValue(value: unknown): LarkConnectionMode { - if (value === "webhook") { - return value; - } - - if (value === "websocket") { - throw validationError( - "Lark WebSocket mode is disabled until the sidecar path is end-to-end ready. Use webhook mode.", - API_ERROR_CODE.larkConnectionModeInvalid, - ); - } - - throw validationError( - "Lark connection mode must be websocket or webhook.", - "LARK_CONNECTION_MODE_INVALID", - ); -} - -export function normalizeLarkCredentials( - input: NormalizeLarkCredentialsInput, -): LarkChannelCredentials { - const connectionMode = normalizeConnectionModeValue(input.connectionMode); - const appId = normalizeRequiredString(input.appId, "Lark app id"); - const appSecret = normalizeRequiredString(input.appSecret, "Lark app secret"); - const domain = normalizeLarkDomainValue(input.domain); - - if (connectionMode === "websocket") { - return { - appId, - appSecret, - connectionMode, - domain, - encryptKey: normalizeOptionalString(input.encryptKey), - verificationToken: normalizeOptionalString(input.verificationToken), - }; - } - - return { - appId, - appSecret, - connectionMode, - domain, - encryptKey: normalizeRequiredString(input.encryptKey ?? "", "Lark encrypt key"), - verificationToken: normalizeRequiredString( - input.verificationToken ?? "", - "Lark verification token", - ), - }; -} - -export function serializeLarkCredentials(input: LarkChannelCredentials): string { - return JSON.stringify(input); -} - -export function parseLarkCredentials(value: string): LarkChannelCredentials { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("Lark channel credentials must be a JSON object."); - } - - const connectionMode = readConnectionMode(parsed); - - return { - appId: readRequiredString(parsed, "appId"), - appSecret: readRequiredString(parsed, "appSecret"), - connectionMode, - domain: normalizeLarkDomainValue(readRequiredString(parsed, "domain")), - encryptKey: - connectionMode === "webhook" - ? readRequiredString(parsed, "encryptKey") - : readOptionalString(parsed, "encryptKey"), - verificationToken: - connectionMode === "webhook" - ? readRequiredString(parsed, "verificationToken") - : readOptionalString(parsed, "verificationToken"), - }; -} diff --git a/apps/api/src/modules/channels/lark/lark-events.ts b/apps/api/src/modules/channels/lark/lark-events.ts deleted file mode 100644 index f984ad72..00000000 --- a/apps/api/src/modules/channels/lark/lark-events.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -export const LARK_EVENT_TYPE_RECEIVE_MESSAGE = "im.message.receive_v1"; - -export type LarkDomain = "feishu" | "lark"; - -interface LarkEventHeader { - eventId: string; - eventType: string; - tenantKey: string; - token: string | null; -} - -interface LarkReceiveMessageEvent { - chatId: string; - chatType: string | null; - messageId: string; - parentId: string | null; - rootId: string | null; - senderOpenId: string; - senderType: string; - senderUnionId: string | null; - senderUserId: string | null; - text: string; -} - -export interface LarkUrlVerificationEnvelope { - challenge: string; - type: "url_verification"; -} - -export interface LarkEventCallbackEnvelope { - event: LarkReceiveMessageEvent; - header: LarkEventHeader; - type: "event_callback"; -} - -export type LarkEventsEnvelope = LarkEventCallbackEnvelope | LarkUrlVerificationEnvelope; - -export interface LarkWorkTrigger { - chatId: string; - chatType: string | null; - eventId: string; - externalActorId: string; - externalMessageId: string; - externalThreadId: string; - messageId: string; - parentId: string | null; - rootId: string | null; - senderOpenId: string; - senderType: string; - senderUnionId: string | null; - senderUserId: string | null; - tenantKey: string; - text: string; -} - -export interface LarkEventsParseInput { - verificationToken: string; -} - -export interface LarkEventsParseFailure { - code: - | "invalid_json" - | "missing_challenge" - | "missing_event" - | "missing_header" - | "token_mismatch" - | "unsupported_type"; - message: string; - ok: false; -} - -export interface LarkEventsParseSuccess { - envelope: LarkEventsEnvelope; - ok: true; -} - -export type LarkEventsParseResult = LarkEventsParseFailure | LarkEventsParseSuccess; - -export interface LarkEventCallbackDecodeFailure { - code: "invalid_envelope" | "missing_event" | "missing_header" | "unsupported_type"; - message: string; - ok: false; -} - -export interface LarkEventCallbackDecodeSuccess { - envelope: LarkEventCallbackEnvelope; - ok: true; -} - -export type LarkEventCallbackDecodeResult = - | LarkEventCallbackDecodeFailure - | LarkEventCallbackDecodeSuccess; - -export interface LarkEventsBodyReadFailure { - code: "decrypt_failed" | "invalid_encrypt"; - message: string; - ok: false; -} - -export interface LarkEventsBodyReadSuccess { - body: string; - encrypted: boolean; - ok: true; -} - -export type LarkEventsBodyReadResult = LarkEventsBodyReadFailure | LarkEventsBodyReadSuccess; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readOptionalRecord( - value: Record, - field: string, -): Record { - const candidate = value[field]; - return isRecord(candidate) ? candidate : {}; -} - -function base64ToBytes(value: string): Uint8Array { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - - return bytes; -} - -function trimToJsonObject(value: string): string { - const start = value.indexOf("{"); - const end = value.lastIndexOf("}"); - - if (start < 0 || end < start) { - throw new Error("Lark encrypted payload did not contain a JSON object."); - } - - return value.slice(start, end + 1); -} - -async function decryptLarkEncryptedPayload(input: { - encrypt: string; - encryptKey: string; -}): Promise { - const encryptedBytes = base64ToBytes(input.encrypt); - - if (encryptedBytes.length <= 16) { - throw new Error("Lark encrypted payload is too short."); - } - - const encoder = new TextEncoder(); - const keyBytes = await crypto.subtle.digest("SHA-256", encoder.encode(input.encryptKey)); - const key = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-CBC" }, false, [ - "decrypt", - ]); - const decrypted = await crypto.subtle.decrypt( - { iv: encryptedBytes.slice(0, 16), name: "AES-CBC" }, - key, - encryptedBytes.slice(16), - ); - - return trimToJsonObject(new TextDecoder().decode(decrypted)); -} - -function readTextContent(rawContent: string | null): string { - if (!isTruthy(rawContent)) { - return ""; - } - - try { - const parsed: unknown = JSON.parse(rawContent); - - if (isRecord(parsed)) { - const text = readString(parsed, "text"); - return text ?? ""; - } - } catch { - return rawContent; - } - - return ""; -} - -function stripLeadingMention(text: string): string { - return text.replace(/^@\S+\s*/u, "").trim(); -} - -export async function readLarkEventsBody(input: { - body: string; - encryptKey: string; -}): Promise { - let parsed: unknown; - - try { - parsed = JSON.parse(input.body); - } catch { - return { body: input.body, encrypted: false, ok: true }; - } - - if (!isRecord(parsed) || !("encrypt" in parsed)) { - return { body: input.body, encrypted: false, ok: true }; - } - - const encrypt = readString(parsed, "encrypt"); - - if (!isTruthy(encrypt)) { - return { - code: "invalid_encrypt", - message: "Lark encrypted event body must include an encrypt string.", - ok: false, - }; - } - - try { - return { - body: await decryptLarkEncryptedPayload({ encrypt, encryptKey: input.encryptKey }), - encrypted: true, - ok: true, - }; - } catch { - return { - code: "decrypt_failed", - message: "Lark encrypted event body could not be decrypted.", - ok: false, - }; - } -} - -function readHeader(parsed: Record): LarkEventHeader | null { - const header = readOptionalRecord(parsed, "header"); - const eventId = readString(header, "event_id"); - const eventType = readString(header, "event_type"); - const tenantKey = readString(header, "tenant_key"); - - if (!isTruthy(eventId) || !isTruthy(eventType) || !isTruthy(tenantKey)) { - return null; - } - - return { - eventId, - eventType, - tenantKey, - token: readString(header, "token"), - }; -} - -function readReceiveMessageEvent(value: unknown): LarkReceiveMessageEvent | null { - if (!isRecord(value)) { - return null; - } - - const message = readOptionalRecord(value, "message"); - const sender = readOptionalRecord(value, "sender"); - const senderId = readOptionalRecord(sender, "sender_id"); - const messageId = readString(message, "message_id"); - const chatId = readString(message, "chat_id"); - const senderOpenId = readString(senderId, "open_id"); - const senderType = readString(sender, "sender_type"); - - if ( - !isTruthy(messageId) || - !isTruthy(chatId) || - !isTruthy(senderOpenId) || - !isTruthy(senderType) - ) { - return null; - } - - return { - chatId, - chatType: readString(message, "chat_type"), - messageId, - parentId: readString(message, "parent_id"), - rootId: readString(message, "root_id"), - senderOpenId, - senderType, - senderUnionId: readString(senderId, "union_id"), - senderUserId: readString(senderId, "user_id"), - text: stripLeadingMention(readTextContent(readString(message, "content"))), - }; -} - -function hasMatchingToken(input: { - parsed: Record; - token: string | null; - verificationToken: string; -}): boolean { - const expected = input.verificationToken.trim(); - const topLevelToken = readString(input.parsed, "token"); - - return input.token === expected || topLevelToken === expected; -} - -export function decodeLarkEventCallbackEnvelope(parsed: unknown): LarkEventCallbackDecodeResult { - if (!isRecord(parsed)) { - return { - code: "invalid_envelope", - message: "Lark event_callback envelope must be a JSON object.", - ok: false, - }; - } - - const header = readHeader(parsed); - - if (!header) { - return { - code: "missing_header", - message: "Lark event_callback header is required.", - ok: false, - }; - } - - if (header.eventType !== LARK_EVENT_TYPE_RECEIVE_MESSAGE) { - return { - code: "unsupported_type", - message: "Lark event type is not supported by this adapter.", - ok: false, - }; - } - - const event = readReceiveMessageEvent(parsed["event"]); - - if (!event) { - return { - code: "missing_event", - message: "Lark im.message.receive_v1 event is incomplete.", - ok: false, - }; - } - - return { - envelope: { - event, - header, - type: "event_callback", - }, - ok: true, - }; -} - -export function parseLarkEventsEnvelope( - body: string, - input: LarkEventsParseInput, -): LarkEventsParseResult { - let parsed: unknown; - - try { - parsed = JSON.parse(body); - } catch { - return { - code: "invalid_json", - message: "Lark request body must be valid JSON.", - ok: false, - }; - } - - if (!isRecord(parsed)) { - return { - code: "invalid_json", - message: "Lark request body must be a JSON object.", - ok: false, - }; - } - - if (parsed["type"] === "url_verification") { - const challenge = readString(parsed, "challenge"); - const token = readString(parsed, "token"); - - if (!isTruthy(challenge)) { - return { - code: "missing_challenge", - message: "Lark url_verification challenge is required.", - ok: false, - }; - } - - if (!hasMatchingToken({ parsed, token, verificationToken: input.verificationToken })) { - return { - code: "token_mismatch", - message: "Lark verification token is invalid.", - ok: false, - }; - } - - return { - envelope: { - challenge, - type: "url_verification", - }, - ok: true, - }; - } - - const header = readHeader(parsed); - - if (!header) { - return { - code: "missing_header", - message: "Lark event_callback header is required.", - ok: false, - }; - } - - if ( - !hasMatchingToken({ parsed, token: header.token, verificationToken: input.verificationToken }) - ) { - return { - code: "token_mismatch", - message: "Lark verification token is invalid.", - ok: false, - }; - } - - if (header.eventType !== LARK_EVENT_TYPE_RECEIVE_MESSAGE) { - return { - code: "unsupported_type", - message: "Lark event type is not supported by this adapter.", - ok: false, - }; - } - - const event = readReceiveMessageEvent(parsed["event"]); - - if (!event) { - return { - code: "missing_event", - message: "Lark im.message.receive_v1 event is incomplete.", - ok: false, - }; - } - - return { - envelope: { - event, - header, - type: "event_callback", - }, - ok: true, - }; -} - -export function normalizeLarkWorkTrigger(envelope: LarkEventCallbackEnvelope): LarkWorkTrigger { - const threadAnchor = envelope.event.rootId ?? envelope.event.parentId ?? envelope.event.messageId; - - return { - chatId: envelope.event.chatId, - chatType: envelope.event.chatType, - eventId: `lark:event:${envelope.header.eventId}`, - externalActorId: `lark:${envelope.event.senderOpenId}`, - externalMessageId: envelope.event.messageId, - externalThreadId: `${envelope.event.chatId}:${threadAnchor}`, - messageId: envelope.event.messageId, - parentId: envelope.event.parentId, - rootId: envelope.event.rootId, - senderOpenId: envelope.event.senderOpenId, - senderType: envelope.event.senderType, - senderUnionId: envelope.event.senderUnionId, - senderUserId: envelope.event.senderUserId, - tenantKey: envelope.header.tenantKey, - text: envelope.event.text, - }; -} diff --git a/apps/api/src/modules/channels/lark/lark-first-party-adapter.ts b/apps/api/src/modules/channels/lark/lark-first-party-adapter.ts deleted file mode 100644 index 6e28fc08..00000000 --- a/apps/api/src/modules/channels/lark/lark-first-party-adapter.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { AgentId, ChannelBindingId } from "@mosoo/id"; - -import { logChannelAdapterError } from "../application/channel-adapter-logger"; -import { - CHANNEL_AGENT_FAILURE_TEXT, - buildChannelSessionLink, - buildChannelWorkingText, -} from "../application/channel-agent-reply"; -import type { ChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import type { ChannelSessionCommandClient } from "../application/channel-session.types"; -import type { LarkConnectionMode } from "./lark-credentials"; -import { LARK_EVENT_TYPE_RECEIVE_MESSAGE } from "./lark-events"; -import type { LarkDomain, LarkWorkTrigger } from "./lark-events"; -import { isLarkCredentialScopedError, LarkWebApiClient, LarkWebApiError } from "./lark-web-api"; - -export const LARK_FIRST_PARTY_ADAPTER_MANIFEST = { - displayName: "Lark / Feishu", - id: "lark", - requires: { - auth: ["signing"], - credentials: ["app_id", "app_secret", "connection_mode"], - webhookCredentials: ["verification_token", "encrypt_key"], - }, - surfaceType: "im", - triggers: [LARK_EVENT_TYPE_RECEIVE_MESSAGE], -} as const; - -export interface LarkAdapterConfig { - agentId: AgentId; - appId: string; - appSecret: string; - bindingId: ChannelBindingId; - connectionMode: LarkConnectionMode; - domain: LarkDomain; - sessionLinkBaseUrl: string | null; -} - -function toMosooMessage(trigger: LarkWorkTrigger): string { - return [ - trigger.text, - "", - "---", - "Source: Lark / Feishu message", - `Lark chat: ${trigger.chatId}`, - `Lark thread: ${trigger.externalThreadId}`, - `Lark sender: ${trigger.senderOpenId}`, - ].join("\n"); -} - -export async function processLarkWorkTrigger(input: { - config: LarkAdapterConfig; - finalDeliveryScheduler: ChannelFinalDeliveryScheduler; - sessionClient: ChannelSessionCommandClient; - trigger: LarkWorkTrigger; -}): Promise { - const lark = new LarkWebApiClient({ - appId: input.config.appId, - appSecret: input.config.appSecret, - domain: input.config.domain, - }); - - try { - const sessionCommand = await input.sessionClient.createOrContinueSession({ - clientRequestId: input.trigger.eventId, - text: toMosooMessage(input.trigger), - trigger: { - eventId: input.trigger.eventId, - externalActorId: input.trigger.externalActorId, - externalMessageId: input.trigger.externalMessageId, - externalThreadId: input.trigger.externalThreadId, - externalWorkspaceId: input.trigger.tenantKey, - providerMetadata: { - chat_id: input.trigger.chatId, - chat_type: input.trigger.chatType, - connection_mode: input.config.connectionMode, - message_id: input.trigger.messageId, - parent_id: input.trigger.parentId, - root_id: input.trigger.rootId, - sender_open_id: input.trigger.senderOpenId, - sender_type: input.trigger.senderType, - sender_union_id: input.trigger.senderUnionId, - sender_user_id: input.trigger.senderUserId, - tenant_key: input.trigger.tenantKey, - }, - requiresExistingSession: false, - }, - }); - - if (sessionCommand.duplicate || sessionCommand.ignored) { - return; - } - - const sessionId = sessionCommand.sessionId; - - if (!sessionId) { - throw new Error("Lark channel session command did not return a session id."); - } - - const runId = sessionCommand.runId; - - if (!runId) { - throw new Error("Lark channel session command did not return a run id."); - } - - const tenantAccessToken = await lark.getTenantAccessToken(); - const sessionLink = buildChannelSessionLink({ - agentId: input.config.agentId, - sessionId, - sessionLinkBaseUrl: input.config.sessionLinkBaseUrl, - }); - await lark.replyMessage({ - messageId: input.trigger.messageId, - tenantAccessToken, - text: buildChannelWorkingText({ sessionLink }), - }); - await input.finalDeliveryScheduler.enqueue({ - bindingId: input.config.bindingId, - externalEventId: input.trigger.eventId, - payload: { - messageId: input.trigger.messageId, - provider: "lark", - }, - provider: "lark", - runId, - sessionId, - }); - } catch (error) { - const credentialScopedError = - error instanceof LarkWebApiError && isLarkCredentialScopedError(error); - - if (credentialScopedError) { - await input.sessionClient.markBindingError(error.code); - } - - logChannelAdapterError("lark-first-party-adapter.failed", error, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - - if (credentialScopedError) { - return; - } - - try { - const tenantAccessToken = await lark.getTenantAccessToken(); - await lark.replyMessage({ - messageId: input.trigger.messageId, - tenantAccessToken, - text: CHANNEL_AGENT_FAILURE_TEXT, - }); - } catch (failureReplyError) { - if ( - failureReplyError instanceof LarkWebApiError && - isLarkCredentialScopedError(failureReplyError) - ) { - await input.sessionClient.markBindingError(failureReplyError.code); - } - - logChannelAdapterError("lark-first-party-adapter.failure_reply_failed", failureReplyError, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - } - } -} diff --git a/apps/api/src/modules/channels/lark/lark-gateway-state.ts b/apps/api/src/modules/channels/lark/lark-gateway-state.ts deleted file mode 100644 index 6c6052bc..00000000 --- a/apps/api/src/modules/channels/lark/lark-gateway-state.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { ChannelConnectionStatePayload } from "../application/channel-connection-state.service"; -import { LARK_LC_DEFAULT_HEARTBEAT_INTERVAL_MS } from "./lark-long-connection-client"; - -export const LARK_GATEWAY_LEASE_DURATION_MS = 2 * 60 * 1000; -export const LARK_GATEWAY_RECONNECT_INITIAL_BACKOFF_MS = 5_000; -const LARK_GATEWAY_RECONNECT_MAX_BACKOFF_MS = 60_000; -export const LARK_GATEWAY_MAX_CONSECUTIVE_RECONNECT_FAILURES = 6; -export const LARK_GATEWAY_PROVIDER = "lark" as const; -export const LARK_GATEWAY_STORAGE_BINDING_KEY = "bindingId"; -export const LARK_GATEWAY_STORAGE_OWNER_KEY = "ownerId"; - -export type LarkGatewayStatus = "connected" | "connecting" | "reconnecting" | "stopped" | "stale"; - -export interface LarkGatewayResumeState { - readonly consecutiveReconnectFailures: number; - readonly heartbeatIntervalMs: number; - readonly lastConnectedAtMs: number | null; - readonly lastErrorCode: string | null; - readonly reconnectBackoffMs: number; - readonly status: LarkGatewayStatus; - readonly statusChangedAtMs: number; -} - -export function defaultResumeState(nowMs: number): LarkGatewayResumeState { - return { - consecutiveReconnectFailures: 0, - heartbeatIntervalMs: LARK_LC_DEFAULT_HEARTBEAT_INTERVAL_MS, - lastConnectedAtMs: null, - lastErrorCode: null, - reconnectBackoffMs: LARK_GATEWAY_RECONNECT_INITIAL_BACKOFF_MS, - status: "stopped", - statusChangedAtMs: nowMs, - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readNumber(value: Record, field: string): number | null { - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readStringOrNull(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.length > 0 ? candidate : null; -} - -function readGatewayStatus(value: unknown): LarkGatewayStatus | null { - switch (value) { - case "connected": - case "connecting": - case "reconnecting": - case "stale": - case "stopped": - return value; - default: - return null; - } -} - -export function parseResumeState(json: string, nowMs: number): LarkGatewayResumeState { - try { - const parsed: unknown = JSON.parse(json); - if (!isRecord(parsed)) { - return defaultResumeState(nowMs); - } - const status = readGatewayStatus(parsed["status"]); - const statusChangedAtMs = readNumber(parsed, "statusChangedAtMs"); - if (status === null || statusChangedAtMs === null) { - return defaultResumeState(nowMs); - } - return { - consecutiveReconnectFailures: readNumber(parsed, "consecutiveReconnectFailures") ?? 0, - heartbeatIntervalMs: - readNumber(parsed, "heartbeatIntervalMs") ?? LARK_LC_DEFAULT_HEARTBEAT_INTERVAL_MS, - lastConnectedAtMs: readNumber(parsed, "lastConnectedAtMs"), - lastErrorCode: readStringOrNull(parsed, "lastErrorCode"), - reconnectBackoffMs: - readNumber(parsed, "reconnectBackoffMs") ?? LARK_GATEWAY_RECONNECT_INITIAL_BACKOFF_MS, - status, - statusChangedAtMs, - }; - } catch { - return defaultResumeState(nowMs); - } -} - -export function serializeResumeState(state: LarkGatewayResumeState): string { - return JSON.stringify(state); -} - -export function mapResumeToRuntimeStatus( - status: LarkGatewayStatus, -): ChannelConnectionStatePayload["status"] { - switch (status) { - case "connected": - return "running"; - case "connecting": - return "starting"; - case "reconnecting": - return "reconnecting"; - case "stale": - return "stale"; - case "stopped": - return "stopped"; - } -} - -export function nextReconnectBackoff(previousMs: number): number { - const doubled = previousMs * 2; - return doubled > LARK_GATEWAY_RECONNECT_MAX_BACKOFF_MS - ? LARK_GATEWAY_RECONNECT_MAX_BACKOFF_MS - : doubled; -} diff --git a/apps/api/src/modules/channels/lark/lark-gateway-wiring.ts b/apps/api/src/modules/channels/lark/lark-gateway-wiring.ts deleted file mode 100644 index 223feebc..00000000 --- a/apps/api/src/modules/channels/lark/lark-gateway-wiring.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import { logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { resolveAgentChannelBindingContextById } from "../application/channel-binding-context"; -import { createChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import { createChannelSessionClient } from "../application/channel-session-command-client"; -import { parseLarkCredentials } from "./lark-credentials"; -import type { LarkChannelCredentials } from "./lark-credentials"; -import { processLarkWorkTrigger } from "./lark-first-party-adapter"; -import { LARK_LC_CONNECT_URL_PATH, LarkLongConnectionClient } from "./lark-long-connection-client"; -import type { - LarkLongConnectionCloseInfo, - LarkLongConnectionSocket, - LarkLongConnectionSocketFactory, - LarkLongConnectionTriggerHandler, -} from "./lark-long-connection-client"; -import { LarkWebApiClient, toLarkApiOrigin } from "./lark-web-api"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Worker-native gateway wiring is retained for ChannelConnection runtimes - * without the Node sidecar. Local dev skips it when the sidecar secret is set - * because the official SDK owns Lark's protobuf long-connection there. - */ -export async function resolveLarkLongConnectionUrl(input: { - credentials: LarkChannelCredentials; -}): Promise { - const apiClient = new LarkWebApiClient({ - appId: input.credentials.appId, - appSecret: input.credentials.appSecret, - domain: input.credentials.domain, - }); - const tenantAccessToken = await apiClient.getTenantAccessToken(); - const response = await fetch( - `${toLarkApiOrigin(input.credentials.domain)}${LARK_LC_CONNECT_URL_PATH}`, - { - body: JSON.stringify({ app_id: input.credentials.appId }), - headers: { - Authorization: `Bearer ${tenantAccessToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - ); - - if (!response.ok) { - throw new Error(`Lark long-connection URL fetch failed: HTTP ${response.status}`); - } - - const body: unknown = await response.json(); - - if (!isRecord(body)) { - throw new Error("Lark long-connection URL response is not a JSON object."); - } - - const data = body["data"]; - if (!isRecord(data)) { - throw new Error("Lark long-connection URL response missing data field."); - } - - const url = data["url"]; - if (typeof url !== "string" || url.trim().length === 0) { - throw new Error("Lark long-connection URL response missing data.url string."); - } - - return url; -} - -function createLarkTriggerDispatcher(input: { - bindingId: ChannelBindingId; - bindings: ApiBindings; -}): LarkLongConnectionTriggerHandler { - return async ({ trigger }) => { - const binding = await resolveAgentChannelBindingContextById(input.bindings, { - bindingId: input.bindingId, - provider: "lark", - }); - if (!binding) { - logError("lark.gateway.dispatch.binding_not_found", { bindingId: input.bindingId }); - return; - } - if (binding.agentStatus !== "published") { - logInfo("lark.gateway.dispatch.agent_unpublished", { - agentId: binding.agentId, - bindingId: binding.bindingId, - eventId: trigger.eventId, - }); - return; - } - const credentials = parseLarkCredentials(binding.credentialsJson); - await processLarkWorkTrigger({ - config: { - agentId: binding.agentId, - appId: credentials.appId, - appSecret: credentials.appSecret, - bindingId: binding.bindingId, - connectionMode: credentials.connectionMode, - domain: credentials.domain, - sessionLinkBaseUrl: input.bindings.WEB_ORIGIN, - }, - finalDeliveryScheduler: createChannelFinalDeliveryScheduler(input.bindings), - sessionClient: createChannelSessionClient({ - binding, - bindings: input.bindings, - executionContext: null, - requestUrl: "lark-gateway://owner", - }), - trigger, - }); - }; -} - -function workerSocketFactory(url: string): LarkLongConnectionSocket { - const socket = new WebSocket(url); - return { - addEventListener: socket.addEventListener.bind( - socket, - ) as LarkLongConnectionSocket["addEventListener"], - close: socket.close.bind(socket), - send: socket.send.bind(socket), - }; -} - -export function createLarkGatewayClient(input: { - bindingId: ChannelBindingId; - bindings: ApiBindings; - onClose(info: LarkLongConnectionCloseInfo): void; -}): LarkLongConnectionClient { - const factory: LarkLongConnectionSocketFactory = (url) => workerSocketFactory(url); - const client = new LarkLongConnectionClient({ socketFactory: factory }); - - client.onTrigger(createLarkTriggerDispatcher(input)); - client.onClose(input.onClose); - client.onProtocolError(({ code, detail }) => { - logError("lark.gateway.protocol_error", { - bindingId: input.bindingId, - code, - detail, - }); - }); - - return client; -} diff --git a/apps/api/src/modules/channels/lark/lark-gateway.do.ts b/apps/api/src/modules/channels/lark/lark-gateway.do.ts deleted file mode 100644 index 24e4aa6f..00000000 --- a/apps/api/src/modules/channels/lark/lark-gateway.do.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createPlatformId, parsePlatformId } from "@mosoo/id"; -import type { ChannelBindingId } from "@mosoo/id"; - -import { logError, logInfo } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { currentTimestampMs } from "../../../time"; -import { resolveAgentChannelBindingContextById } from "../application/channel-binding-context"; -import { - claimChannelConnectionOwner, - readChannelConnectionOwnerState, - releaseChannelConnectionOwner, - renewChannelConnectionOwnerLease, -} from "../application/channel-connection-state.service"; -import type { ChannelConnectionStatePayload } from "../application/channel-connection-state.service"; -import { parseLarkCredentials } from "./lark-credentials"; -import { - LARK_GATEWAY_LEASE_DURATION_MS, - LARK_GATEWAY_MAX_CONSECUTIVE_RECONNECT_FAILURES, - LARK_GATEWAY_PROVIDER, - LARK_GATEWAY_RECONNECT_INITIAL_BACKOFF_MS, - LARK_GATEWAY_STORAGE_BINDING_KEY, - LARK_GATEWAY_STORAGE_OWNER_KEY, - defaultResumeState, - mapResumeToRuntimeStatus, - nextReconnectBackoff, - parseResumeState, - serializeResumeState, -} from "./lark-gateway-state"; -import type { LarkGatewayResumeState } from "./lark-gateway-state"; -import { createLarkGatewayClient, resolveLarkLongConnectionUrl } from "./lark-gateway-wiring"; -import type { - LarkLongConnectionClient, - LarkLongConnectionCloseInfo, -} from "./lark-long-connection-client"; - -export type { LarkGatewayResumeState, LarkGatewayStatus } from "./lark-gateway-state"; - -export interface LarkGatewayStartResult { - readonly bindingId: string; - readonly status: "already_started" | "started" | "lease_held" | "binding_not_found"; -} - -export interface LarkGatewayStopResult { - readonly bindingId: string; - readonly status: "not_started" | "stopped"; -} - -export interface LarkGatewaySnapshot { - readonly bindingId: string | null; - readonly resume: LarkGatewayResumeState; -} - -function parseLarkGatewayBindingId(bindingId: string): ChannelBindingId { - return parsePlatformId(bindingId, "Lark Gateway binding ID"); -} - -export class LarkLongConnectionRuntimeService { - readonly #ctx: DurableObjectState; - readonly #env: ApiBindings; - #client: LarkLongConnectionClient | null = null; - #resume: LarkGatewayResumeState = defaultResumeState(0); - - constructor(ctx: DurableObjectState, env: ApiBindings) { - this.#ctx = ctx; - this.#env = env; - } - - async start(bindingId: string): Promise { - return await this.#start(parseLarkGatewayBindingId(bindingId)); - } - - async stop(bindingId: string): Promise { - return await this.#stop(parseLarkGatewayBindingId(bindingId)); - } - - async snapshot(bindingId: string): Promise { - return await this.#snapshot(parseLarkGatewayBindingId(bindingId)); - } - - async alarm(): Promise { - const nowMs = currentTimestampMs(); - const storedBindingId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_BINDING_KEY); - const ownerId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_OWNER_KEY); - - if (!storedBindingId || !ownerId) { - await this.#ctx.storage.deleteAlarm(); - return; - } - const bindingId = parseLarkGatewayBindingId(storedBindingId); - - await this.#loadResumeState(bindingId, nowMs); - this.#reconcileInMemoryClientState(nowMs); - - const renewed = await renewChannelConnectionOwnerLease({ - bindingId, - bindings: this.#env, - leaseDurationMs: LARK_GATEWAY_LEASE_DURATION_MS, - nowMs, - ownerId, - provider: LARK_GATEWAY_PROVIDER, - state: this.#runtimeStatePayload(nowMs), - }); - - if (!renewed) { - logError("lark.gateway.lease_lost", { bindingId, ownerId }); - await this.#forceStop(bindingId, ownerId, nowMs, "lease_lost"); - return; - } - - if (this.#resume.status === "connected" && this.#client) { - this.#client.sendPing(); - await this.#scheduleNextHeartbeat(); - return; - } - - if ( - this.#resume.status === "reconnecting" || - this.#resume.status === "stale" || - this.#resume.status === "connecting" - ) { - await this.#attemptReconnect(bindingId, ownerId, nowMs); - return; - } - - await this.#forceStop(bindingId, ownerId, nowMs, "idle_tick"); - } - - /** - * Align persisted health with the in-memory client after hello handshakes or - * DO hibernation. A persisted connected state without a live client must - * reconnect, not release the lease. - */ - #reconcileInMemoryClientState(nowMs: number): void { - if (this.#client) { - const clientState = this.#client.snapshot().state; - if (clientState === "connected" && this.#resume.status !== "connected") { - this.#resume = { - ...this.#resume, - consecutiveReconnectFailures: 0, - lastConnectedAtMs: nowMs, - reconnectBackoffMs: LARK_GATEWAY_RECONNECT_INITIAL_BACKOFF_MS, - status: "connected", - statusChangedAtMs: nowMs, - }; - } - return; - } - - if (this.#resume.status === "connected") { - this.#resume = { - ...this.#resume, - status: "reconnecting", - statusChangedAtMs: nowMs, - }; - } - } - - async #start(bindingId: ChannelBindingId): Promise { - const nowMs = currentTimestampMs(); - const existingBindingId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_BINDING_KEY); - - if (existingBindingId === bindingId && this.#resume.status === "connected") { - return { bindingId, status: "already_started" }; - } - - const ownerId = `lark-gateway:${bindingId}:${createPlatformId()}`; - const claimed = await claimChannelConnectionOwner({ - bindingId, - bindings: this.#env, - leaseDurationMs: LARK_GATEWAY_LEASE_DURATION_MS, - nowMs, - ownerId, - provider: LARK_GATEWAY_PROVIDER, - state: { status: "starting", statusChangedAtMs: nowMs }, - }); - - if (!claimed) { - return { bindingId, status: "lease_held" }; - } - - await this.#ctx.storage.put(LARK_GATEWAY_STORAGE_BINDING_KEY, bindingId); - await this.#ctx.storage.put(LARK_GATEWAY_STORAGE_OWNER_KEY, ownerId); - - this.#resume = { - ...defaultResumeState(nowMs), - status: "connecting", - }; - - try { - await this.#openClient(bindingId); - } catch (error) { - const detail = error instanceof Error ? error.message : "unknown"; - this.#resume = { - ...this.#resume, - lastErrorCode: "connect_failed", - status: "reconnecting", - statusChangedAtMs: nowMs, - }; - logError("lark.gateway.connect_failed", { bindingId, detail }); - await this.#persistResumeState(bindingId, ownerId, nowMs); - await this.#ctx.storage.setAlarm(nowMs + this.#resume.reconnectBackoffMs); - return { bindingId, status: "started" }; - } - - await this.#persistResumeState(bindingId, ownerId, nowMs); - await this.#scheduleNextHeartbeat(); - return { bindingId, status: "started" }; - } - - async #stop(requestedBindingId: ChannelBindingId): Promise { - const storedBindingId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_BINDING_KEY); - const ownerId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_OWNER_KEY); - - if (!storedBindingId || !ownerId) { - return { bindingId: requestedBindingId, status: "not_started" }; - } - const bindingId = parseLarkGatewayBindingId(storedBindingId); - - if (bindingId !== requestedBindingId) { - throw new Error("Lark long-connection runtime received a mismatched binding id."); - } - - await this.#forceStop(bindingId, ownerId, currentTimestampMs(), "explicit_stop"); - return { bindingId, status: "stopped" }; - } - - async #snapshot(requestedBindingId: ChannelBindingId): Promise { - const storedBindingId = await this.#ctx.storage.get(LARK_GATEWAY_STORAGE_BINDING_KEY); - const bindingId = storedBindingId ? parseLarkGatewayBindingId(storedBindingId) : null; - - if (bindingId && bindingId !== requestedBindingId) { - throw new Error("Lark long-connection runtime received a mismatched binding id."); - } - - return { bindingId: bindingId ?? null, resume: this.#resume }; - } - - async #openClient(bindingId: ChannelBindingId): Promise { - const binding = await resolveAgentChannelBindingContextById(this.#env, { - bindingId, - provider: "lark", - }); - if (!binding) { - throw new Error(`Lark binding ${bindingId} not found.`); - } - const credentials = parseLarkCredentials(binding.credentialsJson); - if (credentials.connectionMode !== "websocket") { - throw new Error( - `Lark binding ${bindingId} is connectionMode=${credentials.connectionMode}; long-connection runtime only runs websocket-mode bindings.`, - ); - } - - const wsUrl = await resolveLarkLongConnectionUrl({ credentials }); - - // Closing the old client can synchronously fire #onClientClose. The - // reconnect path overwrites its near-term alarm, so setAlarm remains - // last-write-wins. - this.#client?.close(); - this.#client = null; - - const client = createLarkGatewayClient({ - bindingId, - bindings: this.#env, - onClose: (info) => { - this.#onClientClose(bindingId, info); - }, - }); - - client.connect(wsUrl); - this.#client = client; - // The client is observable as connected only after the Lark hello frame. - // Until then, the gateway stays connecting/reconnecting. - } - - #onClientClose(bindingId: ChannelBindingId, info: LarkLongConnectionCloseInfo): void { - const nowMs = currentTimestampMs(); - this.#client = null; - - if (info.kind === "auth_failed" || info.kind === "client_initiated") { - this.#resume = { - ...this.#resume, - lastErrorCode: info.kind === "auth_failed" ? "auth_failed" : null, - status: "stopped", - statusChangedAtMs: nowMs, - }; - logInfo("lark.gateway.close", { - bindingId, - code: info.code, - kind: info.kind, - reason: info.reason, - }); - // Persist the stopped state and release the lease before hibernation. - void this.#ctx.storage.setAlarm(nowMs + 100); - return; - } - - this.#resume = { - ...this.#resume, - lastErrorCode: info.errorDetail ?? `close_${info.code}`, - reconnectBackoffMs: nextReconnectBackoff(this.#resume.reconnectBackoffMs), - status: "reconnecting", - statusChangedAtMs: nowMs, - }; - logInfo("lark.gateway.reconnect_scheduled", { - bindingId, - code: info.code, - kind: info.kind, - reconnectBackoffMs: this.#resume.reconnectBackoffMs, - }); - // Reconnect through alarm so the synchronous close callback does no I/O. - void this.#ctx.storage.setAlarm(nowMs + 100); - } - - async #attemptReconnect( - bindingId: ChannelBindingId, - ownerId: string, - nowMs: number, - ): Promise { - try { - await this.#openClient(bindingId); - // A constructed WebSocket is not a completed Lark handshake. Reset the - // failure count only after the client reports connected. - this.#resume = { - ...this.#resume, - status: this.#resume.status === "stopped" ? "connecting" : this.#resume.status, - statusChangedAtMs: nowMs, - }; - } catch (error) { - const detail = error instanceof Error ? error.message : "unknown"; - const failures = this.#resume.consecutiveReconnectFailures + 1; - this.#resume = { - ...this.#resume, - consecutiveReconnectFailures: failures, - lastErrorCode: "reconnect_failed", - reconnectBackoffMs: nextReconnectBackoff(this.#resume.reconnectBackoffMs), - statusChangedAtMs: nowMs, - }; - logError("lark.gateway.reconnect_failed", { bindingId, detail, failures }); - - if (failures >= LARK_GATEWAY_MAX_CONSECUTIVE_RECONNECT_FAILURES) { - logError("lark.gateway.reconnect_giving_up", { bindingId, failures }); - await this.#forceStop(bindingId, ownerId, nowMs, "reconnect_exhausted"); - return; - } - } - - await this.#persistResumeState(bindingId, ownerId, nowMs); - await this.#ctx.storage.setAlarm(nowMs + this.#resume.reconnectBackoffMs); - } - - async #forceStop( - bindingId: ChannelBindingId, - ownerId: string, - nowMs: number, - reason: string, - ): Promise { - this.#client?.close(); - this.#client = null; - const stoppedErrorCode = - reason === "lease_lost" || reason === "reconnect_exhausted" ? reason : null; - const releaseStatus: "failed" | "stopped" = - reason === "reconnect_exhausted" ? "failed" : "stopped"; - this.#resume = { - ...this.#resume, - consecutiveReconnectFailures: 0, - lastErrorCode: stoppedErrorCode ?? this.#resume.lastErrorCode, - status: "stopped", - statusChangedAtMs: nowMs, - }; - await releaseChannelConnectionOwner({ - bindingId, - bindings: this.#env, - nowMs, - ownerId, - provider: LARK_GATEWAY_PROVIDER, - status: releaseStatus, - }); - await this.#ctx.storage.delete(LARK_GATEWAY_STORAGE_BINDING_KEY); - await this.#ctx.storage.delete(LARK_GATEWAY_STORAGE_OWNER_KEY); - await this.#ctx.storage.deleteAlarm(); - logInfo("lark.gateway.stopped", { bindingId, reason }); - } - - async #scheduleNextHeartbeat(): Promise { - await this.#ctx.storage.setAlarm(currentTimestampMs() + this.#resume.heartbeatIntervalMs); - } - - async #loadResumeState(bindingId: ChannelBindingId, nowMs: number): Promise { - const record = await readChannelConnectionOwnerState({ - bindingId, - bindings: this.#env, - provider: LARK_GATEWAY_PROVIDER, - }); - if (record) { - this.#resume = parseResumeState(record.runtimeStateJson, nowMs); - } - } - - async #persistResumeState( - bindingId: ChannelBindingId, - ownerId: string, - nowMs: number, - ): Promise { - await renewChannelConnectionOwnerLease({ - bindingId, - bindings: this.#env, - leaseDurationMs: LARK_GATEWAY_LEASE_DURATION_MS, - nowMs, - ownerId, - provider: LARK_GATEWAY_PROVIDER, - state: this.#runtimeStatePayload(nowMs), - }); - } - - #runtimeStatePayload(nowMs: number): ChannelConnectionStatePayload { - return { - lastErrorCode: this.#resume.lastErrorCode, - lastHeartbeatAtMs: nowMs, - runtimeStateJson: serializeResumeState(this.#resume), - status: mapResumeToRuntimeStatus(this.#resume.status), - statusChangedAtMs: this.#resume.statusChangedAtMs, - }; - } -} diff --git a/apps/api/src/modules/channels/lark/lark-long-connection-client.ts b/apps/api/src/modules/channels/lark/lark-long-connection-client.ts deleted file mode 100644 index 2e68e810..00000000 --- a/apps/api/src/modules/channels/lark/lark-long-connection-client.ts +++ /dev/null @@ -1,432 +0,0 @@ -// Lark Long Connection protocol client. -// -// Scope (PRD §4): handshake → frame decode → ping/pong primitives → close -// signaling. The client owns wire-level concerns only. It does NOT own -// reconnect, lease, heartbeat scheduling, or persistence — those belong to -// ChannelConnection drives `sendPing()` from a Durable Object -// `alarm()` so the connection survives DO hibernation (PRD §3 architecture -// note: never use interval-based timers inside a DO host). -// -// Protocol field names live in LARK_LC_PROTOCOL below — single truth source -// for wire strings; the receive-message event type comes from lark-events.ts -// so the manifest and the webhook parser agree on the same literal. If the -// L-005 live spike reveals different names, update LARK_LC_PROTOCOL in one -// place. - -import { - decodeLarkEventCallbackEnvelope, - LARK_EVENT_TYPE_RECEIVE_MESSAGE, - normalizeLarkWorkTrigger, -} from "./lark-events"; -import type { LarkEventCallbackEnvelope, LarkWorkTrigger } from "./lark-events"; - -export const LARK_LC_CONNECT_URL_PATH = "/open-apis/im/v1/long-connection/connect_url" as const; - -const LARK_LC_PROTOCOL = { - connectPath: LARK_LC_CONNECT_URL_PATH, - eventTypeField: "event_type", - frameTypeField: "type", - frameTypes: { - event: "event", - hello: "hello", - pong: "pong", - reconnect: "reconnect", - }, - heartbeatIntervalField: "heartbeat_interval", - outboundPing: { type: "ping" }, - payloadField: "payload", - reconnectUrlField: "url", -} as const; - -const LARK_LC_AUTH_FAILED_CLOSE_RANGE = { max: 4999, min: 4000 } as const; -export const LARK_LC_DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; -const LARK_LC_MIN_HEARTBEAT_INTERVAL_MS = 1_000; - -export interface LarkLongConnectionSocket { - addEventListener( - type: "close", - listener: (event: { code: number; reason: string }) => void, - ): void; - addEventListener(type: "error", listener: (event: unknown) => void): void; - addEventListener(type: "message", listener: (event: { data: string }) => void): void; - addEventListener(type: "open", listener: () => void): void; - close(code?: number, reason?: string): void; - send(data: string): void; -} - -export type LarkLongConnectionSocketFactory = (url: string) => LarkLongConnectionSocket; - -export type LarkLongConnectionState = "closed" | "connected" | "handshake_pending" | "idle"; - -export type LarkLongConnectionCloseKind = - | "auth_failed" - | "client_initiated" - | "protocol_violation" - | "server_reconnect" - | "transient"; - -export interface LarkLongConnectionCloseInfo { - code: number; - errorDetail: string | null; - kind: LarkLongConnectionCloseKind; - reason: string; - serverReconnectUrl: string | null; -} - -export interface LarkLongConnectionClientOptions { - readonly defaultHeartbeatIntervalMs?: number; - readonly nowMs?: () => number; - readonly socketFactory: LarkLongConnectionSocketFactory; -} - -export interface LarkLongConnectionRuntimeSnapshot { - heartbeatIntervalMs: number; - lastPongAtMs: number | null; - state: LarkLongConnectionState; -} - -export interface LarkLongConnectionTriggerHandler { - (input: { envelope: LarkEventCallbackEnvelope; trigger: LarkWorkTrigger }): Promise | void; -} - -export interface LarkLongConnectionCloseHandler { - (info: LarkLongConnectionCloseInfo): void; -} - -export type LarkLongConnectionProtocolErrorCode = - | "connect_reentry" - | "decode_failed" - | "envelope_invalid" - | "frame_invalid_json" - | "send_failed" - | "socket_error" - | "trigger_handler_failed" - | "unsupported_frame_type"; - -export interface LarkLongConnectionProtocolErrorHandler { - (input: { code: LarkLongConnectionProtocolErrorCode; detail: string }): void; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readNumber(value: Record, field: string): number | null { - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : null; -} - -function describeUnknown(value: unknown): string { - if (value instanceof Error) { - return value.message; - } - - if (typeof value === "string") { - return value; - } - - if (isRecord(value) && typeof value["message"] === "string") { - return value["message"]; - } - - try { - return JSON.stringify(value); - } catch { - return ""; - } -} - -function classifyCloseKind(input: { code: number }): LarkLongConnectionCloseKind { - if ( - input.code >= LARK_LC_AUTH_FAILED_CLOSE_RANGE.min && - input.code <= LARK_LC_AUTH_FAILED_CLOSE_RANGE.max - ) { - return "auth_failed"; - } - - return "transient"; -} - -export class LarkLongConnectionClient { - readonly #socketFactory: LarkLongConnectionSocketFactory; - readonly #defaultHeartbeatIntervalMs: number; - readonly #nowMs: () => number; - - #closeHandler: LarkLongConnectionCloseHandler | null = null; - #heartbeatIntervalMs: number; - #lastPongAtMs: number | null = null; - #protocolErrorHandler: LarkLongConnectionProtocolErrorHandler | null = null; - #socket: LarkLongConnectionSocket | null = null; - #state: LarkLongConnectionState = "idle"; - #triggerHandler: LarkLongConnectionTriggerHandler | null = null; - - constructor(options: LarkLongConnectionClientOptions) { - this.#socketFactory = options.socketFactory; - this.#defaultHeartbeatIntervalMs = - options.defaultHeartbeatIntervalMs ?? LARK_LC_DEFAULT_HEARTBEAT_INTERVAL_MS; - this.#heartbeatIntervalMs = this.#defaultHeartbeatIntervalMs; - this.#nowMs = options.nowMs ?? (() => Date.now()); - } - - snapshot(): LarkLongConnectionRuntimeSnapshot { - return { - heartbeatIntervalMs: this.#heartbeatIntervalMs, - lastPongAtMs: this.#lastPongAtMs, - state: this.#state, - }; - } - - onTrigger(handler: LarkLongConnectionTriggerHandler): void { - this.#triggerHandler = handler; - } - - onClose(handler: LarkLongConnectionCloseHandler): void { - this.#closeHandler = handler; - } - - onProtocolError(handler: LarkLongConnectionProtocolErrorHandler): void { - this.#protocolErrorHandler = handler; - } - - connect(url: string): void { - if (this.#state !== "idle" && this.#state !== "closed") { - this.#protocolErrorHandler?.({ - code: "connect_reentry", - detail: `connect() called while client is in state '${this.#state}'.`, - }); - return; - } - - this.#state = "handshake_pending"; - this.#lastPongAtMs = null; - this.#heartbeatIntervalMs = this.#defaultHeartbeatIntervalMs; - - const socket = this.#socketFactory(url); - this.#socket = socket; - - socket.addEventListener("open", () => { - // Wait for hello frame to confirm handshake; nothing to send yet. - }); - - socket.addEventListener("message", (event) => { - this.#onMessage(event.data); - }); - - socket.addEventListener("close", (event) => { - this.#onSocketClose({ code: event.code, reason: event.reason }); - }); - - socket.addEventListener("error", (event) => { - const detail = describeUnknown(event); - this.#protocolErrorHandler?.({ code: "socket_error", detail }); - this.#emitClose({ - code: 1006, - errorDetail: detail, - kind: "transient", - reason: "socket error", - serverReconnectUrl: null, - }); - }); - } - - close(code = 1000, reason = "client_initiated"): void { - if (this.#state === "closed" || this.#state === "idle") { - return; - } - - const socket = this.#socket; - this.#emitClose({ - code, - errorDetail: null, - kind: "client_initiated", - reason, - serverReconnectUrl: null, - }); - - if (socket) { - socket.close(code, reason); - } - } - - /** - * Send one ping frame. The caller drives scheduling - * from a Durable Object alarm so the heartbeat survives DO hibernation. - * No-op when not in `connected` state or when no socket exists. - */ - sendPing(): void { - const socket = this.#socket; - - if (!socket || this.#state !== "connected") { - return; - } - - try { - socket.send(JSON.stringify(LARK_LC_PROTOCOL.outboundPing)); - } catch (error) { - this.#protocolErrorHandler?.({ - code: "send_failed", - detail: describeUnknown(error), - }); - } - } - - #onMessage(raw: string): void { - let parsed: unknown; - - try { - parsed = JSON.parse(raw); - } catch { - this.#protocolErrorHandler?.({ - code: "frame_invalid_json", - detail: "Lark long-connection frame must be valid JSON.", - }); - return; - } - - if (!isRecord(parsed)) { - this.#protocolErrorHandler?.({ - code: "frame_invalid_json", - detail: "Lark long-connection frame must be a JSON object.", - }); - return; - } - - const frameType = readString(parsed, LARK_LC_PROTOCOL.frameTypeField); - - if (frameType === LARK_LC_PROTOCOL.frameTypes.hello) { - this.#onHello(parsed); - return; - } - - if (frameType === LARK_LC_PROTOCOL.frameTypes.pong) { - this.#onPong(); - return; - } - - if (frameType === LARK_LC_PROTOCOL.frameTypes.event) { - this.#onEvent(parsed); - return; - } - - if (frameType === LARK_LC_PROTOCOL.frameTypes.reconnect) { - this.#onReconnect(parsed); - return; - } - - this.#protocolErrorHandler?.({ - code: "unsupported_frame_type", - detail: `Unknown Lark long-connection frame type: ${frameType ?? ""}.`, - }); - } - - #onHello(parsed: Record): void { - const interval = readNumber(parsed, LARK_LC_PROTOCOL.heartbeatIntervalField); - - if (interval === null || interval < LARK_LC_MIN_HEARTBEAT_INTERVAL_MS) { - const socket = this.#socket; - this.#emitClose({ - code: 1002, - errorDetail: `Lark hello frame missing or invalid ${LARK_LC_PROTOCOL.heartbeatIntervalField}.`, - kind: "protocol_violation", - reason: "invalid hello frame", - serverReconnectUrl: null, - }); - - if (socket) { - socket.close(1002, "invalid hello frame"); - } - - return; - } - - this.#heartbeatIntervalMs = interval; - this.#state = "connected"; - this.#lastPongAtMs = this.#nowMs(); - } - - #onPong(): void { - this.#lastPongAtMs = this.#nowMs(); - } - - #onEvent(parsed: Record): void { - const eventType = readString(parsed, LARK_LC_PROTOCOL.eventTypeField); - - if (eventType !== LARK_EVENT_TYPE_RECEIVE_MESSAGE) { - this.#protocolErrorHandler?.({ - code: "envelope_invalid", - detail: `Unsupported event_type for trigger: ${eventType ?? ""}.`, - }); - return; - } - - const payload = parsed[LARK_LC_PROTOCOL.payloadField]; - const decoded = decodeLarkEventCallbackEnvelope(payload); - - if (!decoded.ok) { - this.#protocolErrorHandler?.({ - code: "decode_failed", - detail: `Lark long-connection event payload could not be decoded: ${decoded.code}.`, - }); - return; - } - - const trigger = normalizeLarkWorkTrigger(decoded.envelope); - const handler = this.#triggerHandler; - - if (!handler) { - return; - } - - Promise.resolve(handler({ envelope: decoded.envelope, trigger })).catch((error: unknown) => { - this.#protocolErrorHandler?.({ - code: "trigger_handler_failed", - detail: describeUnknown(error), - }); - }); - } - - #onReconnect(parsed: Record): void { - const reconnectUrl = readString(parsed, LARK_LC_PROTOCOL.reconnectUrlField); - const socket = this.#socket; - this.#emitClose({ - code: 1000, - errorDetail: null, - kind: "server_reconnect", - reason: "server requested reconnect", - serverReconnectUrl: reconnectUrl, - }); - - if (socket) { - socket.close(1000, "server reconnect"); - } - } - - #onSocketClose(input: { code: number; reason: string }): void { - if (this.#state === "closed") { - return; - } - - this.#emitClose({ - code: input.code, - errorDetail: null, - kind: classifyCloseKind(input), - reason: input.reason, - serverReconnectUrl: null, - }); - } - - #emitClose(info: LarkLongConnectionCloseInfo): void { - if (this.#state === "closed") { - return; - } - - this.#state = "closed"; - this.#socket = null; - this.#closeHandler?.(info); - } -} diff --git a/apps/api/src/modules/channels/lark/lark-sidecar-registry.ts b/apps/api/src/modules/channels/lark/lark-sidecar-registry.ts deleted file mode 100644 index 080f19e9..00000000 --- a/apps/api/src/modules/channels/lark/lark-sidecar-registry.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Lark Sidecar Registry — read model for the internal route used by -// the Node `lark-ws-sidecar.ts` process. -// -// The sidecar runs the official `@larksuiteoapi/node-sdk` `WSClient` (which -// can't run inside workerd). It calls -// `GET /api/v1/internal/lark-gateway/bindings` on a short polling -// interval to discover which Lark channel bindings need a long-connection. -// This module owns that read. -// -// The WebSocket sidecar path is intentionally disabled until it is -// end-to-end ready. This registry keeps the internal route callable, but it -// does not expose binding credentials to a sidecar process. -// -// When re-enabled, it should return bindings whose: -// - provider = "lark" -// - status = "active" -// - agent.status = "published" -// - credentials.connectionMode = "websocket" -// -// We also decrypt and surface the appId + appSecret so the sidecar can -// hand them straight to the SDK; the wire is the loopback HTTP call inside -// `just dev`, gated by the shared `MOSOO_LARK_SIDECAR_SECRET`, so the -// credentials never leave the developer's machine. - -import { agentChannelBindingsTable, agentsTable } from "@mosoo/db"; -import type { AgentId, ChannelBindingId } from "@mosoo/id"; -import { and, asc, eq, gt } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { resolveAgentChannelBindingContextById } from "../application/channel-binding-context"; -import { parseLarkCredentials } from "./lark-credentials"; -import type { LarkChannelCredentials } from "./lark-credentials"; - -const LARK_SIDECAR_BINDING_BATCH_SIZE = 50; - -export interface LarkSidecarBindingDescriptor { - readonly agentId: AgentId; - readonly agentStatus: string; - readonly bindingId: ChannelBindingId; - readonly credentials: LarkChannelCredentials; -} - -async function listLarkBindingIdPage( - bindings: ApiBindings, - input: { afterBindingId: ChannelBindingId | null }, -): Promise { - const rows = await getAppDatabase(bindings.DB) - .select({ bindingId: agentChannelBindingsTable.id }) - .from(agentChannelBindingsTable) - .innerJoin(agentsTable, eq(agentsTable.id, agentChannelBindingsTable.agentId)) - .where( - and( - eq(agentChannelBindingsTable.provider, "lark"), - eq(agentChannelBindingsTable.appId, agentsTable.appId), - eq(agentChannelBindingsTable.status, "active"), - eq(agentsTable.status, "published"), - ...(input.afterBindingId ? [gt(agentChannelBindingsTable.id, input.afterBindingId)] : []), - ), - ) - .orderBy(asc(agentChannelBindingsTable.id)) - .limit(LARK_SIDECAR_BINDING_BATCH_SIZE) - .all(); - return rows.map((row) => row.bindingId); -} - -export async function listPublishedWebsocketLarkBindingsForSidecar( - bindings: ApiBindings, -): Promise { - const descriptors: LarkSidecarBindingDescriptor[] = []; - let afterBindingId: ChannelBindingId | null = null; - - for (;;) { - const page = await listLarkBindingIdPage(bindings, { afterBindingId }); - if (page.length === 0) { - return descriptors; - } - - for (const bindingId of page) { - try { - const context = await resolveAgentChannelBindingContextById(bindings, { - bindingId, - provider: "lark", - }); - if (!context) { - continue; - } - const credentials = parseLarkCredentials(context.credentialsJson); - if (credentials.connectionMode !== "websocket") { - continue; - } - continue; - } catch (error) { - logError("lark.sidecar.registry.parse_failed", { - ...createErrorLogContext(error), - bindingId, - }); - } - } - - afterBindingId = page[page.length - 1] ?? null; - if (page.length < LARK_SIDECAR_BINDING_BATCH_SIZE) { - return descriptors; - } - } -} diff --git a/apps/api/src/modules/channels/lark/lark-signing.ts b/apps/api/src/modules/channels/lark/lark-signing.ts deleted file mode 100644 index 2b157c8b..00000000 --- a/apps/api/src/modules/channels/lark/lark-signing.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -const LARK_SIGNATURE_MAX_SKEW_MS = 5 * 60 * 1000; - -export interface LarkSignatureVerificationInput { - body: string; - encryptKey: string; - headers: Headers; - nowMs?: number; -} - -export interface LarkSignatureVerificationFailure { - code: "invalid_timestamp" | "missing_header" | "signature_mismatch" | "stale_timestamp"; - message: string; - ok: false; - status: 400 | 401; -} - -export interface LarkSignatureVerificationSuccess { - ok: true; -} - -export type LarkSignatureVerificationResult = - | LarkSignatureVerificationFailure - | LarkSignatureVerificationSuccess; - -function bytesToHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -function timingSafeEqual(left: string, right: string): boolean { - const encoder = new TextEncoder(); - const leftBytes = encoder.encode(left); - const rightBytes = encoder.encode(right); - - if (leftBytes.length !== rightBytes.length) { - return false; - } - - let difference = 0; - - for (let index = 0; index < leftBytes.length; index += 1) { - difference |= (leftBytes.at(index) ?? 0) ^ (rightBytes.at(index) ?? 0); - } - - return difference === 0; -} - -async function computeLarkSignature(input: { - body: string; - encryptKey: string; - nonce: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const digest = await crypto.subtle.digest( - "SHA-256", - encoder.encode(input.timestamp + input.nonce + input.encryptKey + input.body), - ); - - return bytesToHex(digest); -} - -function parseLarkTimestampMs(timestamp: string): number | null { - if (!/^\d+$/.test(timestamp)) { - return null; - } - - const seconds = Number(timestamp); - - if (!Number.isSafeInteger(seconds)) { - return null; - } - - return seconds * 1000; -} - -export async function verifyLarkSignature( - input: LarkSignatureVerificationInput, -): Promise { - const timestamp = input.headers.get("x-lark-request-timestamp"); - const nonce = input.headers.get("x-lark-request-nonce"); - const signature = input.headers.get("x-lark-signature"); - - if (!isTruthy(timestamp) || !isTruthy(nonce) || !isTruthy(signature)) { - return { - code: "missing_header", - message: "Lark signature headers are required.", - ok: false, - status: 400, - }; - } - - const timestampMs = parseLarkTimestampMs(timestamp); - - if (timestampMs === null) { - return { - code: "invalid_timestamp", - message: "Lark signature timestamp must be an integer Unix timestamp.", - ok: false, - status: 400, - }; - } - - const nowMs = input.nowMs ?? Date.now(); - - if (Math.abs(nowMs - timestampMs) > LARK_SIGNATURE_MAX_SKEW_MS) { - return { - code: "stale_timestamp", - message: "Lark request timestamp is outside the allowed window.", - ok: false, - status: 401, - }; - } - - const expected = await computeLarkSignature({ - body: input.body, - encryptKey: input.encryptKey, - nonce, - timestamp, - }); - - if (!timingSafeEqual(expected, signature)) { - return { - code: "signature_mismatch", - message: "Lark request signature is invalid.", - ok: false, - status: 401, - }; - } - - return { ok: true }; -} diff --git a/apps/api/src/modules/channels/lark/lark-web-api.ts b/apps/api/src/modules/channels/lark/lark-web-api.ts deleted file mode 100644 index e2a8cbdb..00000000 --- a/apps/api/src/modules/channels/lark/lark-web-api.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { fetchChannelWebApi, readChannelWebApiJson } from "../channel-fetch"; -import type { LarkDomain } from "./lark-events"; - -interface LarkApiBaseConfig { - appId: string; - appSecret: string; - domain: LarkDomain; - timeoutMs?: number; -} - -interface LarkOpenApiResponse { - code?: number; - data?: unknown; - msg?: string; - // Feishu/Lark Open APIs return endpoint-specific payloads at the top level - // (e.g. `tenant_access_token`, `bot`), not always wrapped under `data`. - // Allow indexed access so callers can read those fields without re-casting. - [key: string]: unknown; -} - -export type LarkWebApiOperation = "bot.info" | "im.message.reply" | "tenant_access_token"; - -export class LarkWebApiError extends Error { - readonly apiCode: number | null; - readonly apiMessage: string | null; - readonly code: string; - readonly operation: LarkWebApiOperation; - - constructor(input: { - apiCode: number | null; - apiMessage: string | null; - code: string; - operation: LarkWebApiOperation; - }) { - super(`Lark ${input.operation} failed: ${input.apiMessage ?? input.code}`); - this.apiCode = input.apiCode; - this.apiMessage = input.apiMessage; - this.code = input.code; - this.name = "LarkWebApiError"; - this.operation = input.operation; - } -} - -const LARK_PERMISSION_ERROR_CODES = new Set([ - 230001, // Bot not in chat. - 230035, // Send Message Permission deny. -]); - -export function isLarkCredentialScopedError(error: LarkWebApiError): boolean { - if (error.operation === "tenant_access_token" || error.operation === "bot.info") { - return true; - } - - if (error.apiCode !== null && LARK_PERMISSION_ERROR_CODES.has(error.apiCode)) { - return true; - } - - const message = (error.apiMessage ?? error.code).toLowerCase(); - return ( - message.includes("access denied") || - message.includes("forbidden") || - message.includes("not authorized") || - message.includes("permission") || - message.includes("scope") || - message.includes("token") - ); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, key: string): string | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "string" && field.trim() ? field : null; -} - -function readNumber(value: unknown, key: string): number | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "number" && Number.isSafeInteger(field) ? field : null; -} - -export function toLarkApiOrigin(domain: LarkDomain): string { - return domain === "feishu" ? "https://open.feishu.cn" : "https://open.larksuite.com"; -} - -async function readLarkResponse( - response: Response, - operation: LarkWebApiOperation, -): Promise { - const parsed = await readChannelWebApiJson(response); - - if (!isRecord(parsed)) { - const code = response.statusText.trim() || `${operation}_failed`; - throw new LarkWebApiError({ - apiCode: null, - apiMessage: null, - code, - operation, - }); - } - - const body = parsed as LarkOpenApiResponse; - const apiCode = readNumber(body, "code"); - const apiMessage = readString(body, "msg"); - - if (!response.ok || body.code !== 0) { - throw new LarkWebApiError({ - apiCode, - apiMessage, - code: apiCode === null ? (apiMessage ?? `${operation}_failed`) : `lark_${apiCode}`, - operation, - }); - } - - return body; -} - -export class LarkWebApiClient { - readonly #appId: string; - readonly #appSecret: string; - readonly #origin: string; - readonly #timeoutMs: number | undefined; - - constructor(config: LarkApiBaseConfig) { - this.#appId = config.appId; - this.#appSecret = config.appSecret; - this.#origin = toLarkApiOrigin(config.domain); - this.#timeoutMs = config.timeoutMs; - } - - async getTenantAccessToken(): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - app_id: this.#appId, - app_secret: this.#appSecret, - }), - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Lark tenant_access_token", - timeoutMs: this.#timeoutMs, - url: `${this.#origin}/open-apis/auth/v3/tenant_access_token/internal`, - }); - const body = await readLarkResponse(response, "tenant_access_token"); - // Per Feishu/Lark docs the token is returned at the top level alongside - // `code`, `msg`, `expire` — it is NOT nested under a `data` envelope. - const token = readString(body, "tenant_access_token"); - - if (!token) { - throw new Error("Lark tenant_access_token response did not include a token."); - } - - return token; - } - - async getBotInfo(tenantAccessToken: string): Promise<{ - appName: string | null; - botOpenId: string; - }> { - const response = await fetchChannelWebApi({ - init: { - headers: { - Authorization: `Bearer ${tenantAccessToken}`, - }, - method: "GET", - }, - label: "Lark bot.info", - timeoutMs: this.#timeoutMs, - url: `${this.#origin}/open-apis/bot/v3/info`, - }); - const body = await readLarkResponse(response, "bot.info"); - // The `bot` object is at the top level of the response (verified against - // the official larksuite/oapi-sdk-go bindings), not under `data`. - const bot = body["bot"]; - const botOpenId = readString(bot, "open_id"); - - if (!botOpenId) { - throw new Error("Lark bot info response did not include bot.open_id."); - } - - return { - appName: readString(bot, "app_name") ?? readString(bot, "name"), - botOpenId, - }; - } - - async replyMessage(input: { - messageId: string; - tenantAccessToken: string; - text: string; - }): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - content: JSON.stringify({ text: input.text }), - msg_type: "text", - }), - headers: { - Authorization: `Bearer ${input.tenantAccessToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Lark im.message.reply", - timeoutMs: this.#timeoutMs, - url: `${this.#origin}/open-apis/im/v1/messages/${encodeURIComponent(input.messageId)}/reply`, - }); - await readLarkResponse(response, "im.message.reply"); - } -} diff --git a/apps/api/src/modules/channels/slack/slack-adapter-logger.ts b/apps/api/src/modules/channels/slack/slack-adapter-logger.ts deleted file mode 100644 index f3bca459..00000000 --- a/apps/api/src/modules/channels/slack/slack-adapter-logger.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { logChannelAdapterError } from "../application/channel-adapter-logger"; - -export function logSlackAdapterError( - message: string, - error: unknown, - context: Record = {}, -): void { - logChannelAdapterError(message, error, context); -} diff --git a/apps/api/src/modules/channels/slack/slack-agent-reply.ts b/apps/api/src/modules/channels/slack/slack-agent-reply.ts deleted file mode 100644 index 4e3d8e41..00000000 --- a/apps/api/src/modules/channels/slack/slack-agent-reply.ts +++ /dev/null @@ -1 +0,0 @@ -export type { ChannelAgentReplyPollClient as SlackAgentReplyPollClient } from "../application/channel-agent-reply"; diff --git a/apps/api/src/modules/channels/slack/slack-credentials.ts b/apps/api/src/modules/channels/slack/slack-credentials.ts deleted file mode 100644 index 4cdb2fbd..00000000 --- a/apps/api/src/modules/channels/slack/slack-credentials.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; - -export interface SlackChannelCredentials { - appLevelToken: string | null; - botToken: string; - signingSecret: string; - threadRepliesRequireMention: boolean; -} - -export interface NormalizeSlackCredentialsInput { - appLevelToken?: string | null; - botToken: string; - signingSecret: string; - threadRepliesRequireMention?: boolean | null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRequiredString(value: string, label: string): string { - const normalized = value.trim(); - - if (!isTruthy(normalized)) { - throw validationError(`${label} is required.`); - } - - return normalized; -} - -function normalizeOptionalString(value: string | null | undefined): string | null { - const normalized = value?.trim(); - return isTruthy(normalized) ? normalized : null; -} - -function readRequiredString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - throw new Error(`Slack channel credential ${field} is required.`); -} - -function readOptionalString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : null; -} - -export function normalizeSlackCredentials( - input: NormalizeSlackCredentialsInput, -): SlackChannelCredentials { - return { - appLevelToken: normalizeOptionalString(input.appLevelToken), - botToken: normalizeRequiredString(input.botToken, "Slack bot token"), - signingSecret: normalizeRequiredString(input.signingSecret, "Slack signing secret"), - threadRepliesRequireMention: input.threadRepliesRequireMention === true, - }; -} - -export function serializeSlackCredentials(input: SlackChannelCredentials): string { - return JSON.stringify(input); -} - -export function parseSlackCredentials(value: string): SlackChannelCredentials { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("Slack channel credentials must be a JSON object."); - } - - return { - appLevelToken: readOptionalString(parsed, "appLevelToken"), - botToken: readRequiredString(parsed, "botToken"), - signingSecret: readRequiredString(parsed, "signingSecret"), - threadRepliesRequireMention: parsed["threadRepliesRequireMention"] === true, - }; -} diff --git a/apps/api/src/modules/channels/slack/slack-events.ts b/apps/api/src/modules/channels/slack/slack-events.ts deleted file mode 100644 index 12c33679..00000000 --- a/apps/api/src/modules/channels/slack/slack-events.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; -export interface SlackUrlVerificationEnvelope { - challenge: string; - type: "url_verification"; -} - -export interface SlackEventCallbackEnvelope { - botUserId: string | null; - enterpriseId: string | null; - event: Record; - eventId: string; - isEnterpriseInstall: boolean; - teamId: string | null; - type: "event_callback"; -} - -export type SlackEventsEnvelope = SlackEventCallbackEnvelope | SlackUrlVerificationEnvelope; - -export interface SlackWorkTrigger { - botUserId: string | null; - channelId: string; - enterpriseId: string | null; - eventId: string; - isEnterpriseInstall: boolean; - messageTs: string; - requiresExistingSession: boolean; - teamId: string | null; - text: string; - threadTs: string; - triggerType: "app_mention" | "channel_thread_message" | "dm_message"; - userId: string; -} - -export interface SlackEventsParseFailure { - code: - | "invalid_json" - | "missing_challenge" - | "missing_event" - | "missing_event_id" - | "unsupported_type"; - message: string; - ok: false; -} - -export interface SlackEventsParseSuccess { - envelope: SlackEventsEnvelope; - ok: true; -} - -export type SlackEventsParseResult = SlackEventsParseFailure | SlackEventsParseSuccess; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readText(value: Record, field: string): string { - const candidate = value[field]; - - return typeof candidate === "string" ? candidate : ""; -} - -function readOptionalString(value: Record, field: string): string | null { - const candidate = value[field]; - - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readBotUserId(parsed: Record): string | null { - const authorizations = parsed["authorizations"]; - - if (!Array.isArray(authorizations)) { - return null; - } - - for (const authorization of authorizations) { - if (isRecord(authorization)) { - const userId = readOptionalString(authorization, "user_id"); - - if (isTruthy(userId)) { - return userId; - } - } - } - - return null; -} - -function stripLeadingBotMentions(text: string): string { - return text.replace(/^(?:<@[A-Z0-9-]+>\s*)+/u, "").trim(); -} - -function containsBotMention(text: string, botUserId: string | null): boolean { - return isTruthy(botUserId) && text.includes(`<@${botUserId}>`); -} - -export function parseSlackEventsEnvelope(body: string): SlackEventsParseResult { - let parsed: unknown; - - try { - parsed = JSON.parse(body); - } catch { - return { - code: "invalid_json", - message: "Slack request body must be valid JSON.", - ok: false, - }; - } - - if (!isRecord(parsed)) { - return { - code: "invalid_json", - message: "Slack request body must be a JSON object.", - ok: false, - }; - } - - if (parsed["type"] === "url_verification") { - const challenge = readString(parsed, "challenge"); - - if (!isTruthy(challenge)) { - return { - code: "missing_challenge", - message: "Slack url_verification challenge is required.", - ok: false, - }; - } - - return { - envelope: { - challenge, - type: "url_verification", - }, - ok: true, - }; - } - - if (parsed["type"] !== "event_callback") { - return { - code: "unsupported_type", - message: "Slack request type is not supported by this adapter spike.", - ok: false, - }; - } - - const { event } = parsed; - const eventId = readString(parsed, "event_id"); - - if (!isRecord(event)) { - return { - code: "missing_event", - message: "Slack event_callback requires event.", - ok: false, - }; - } - - if (!isTruthy(eventId)) { - return { - code: "missing_event_id", - message: "Slack event_callback requires event_id.", - ok: false, - }; - } - - return { - envelope: { - botUserId: readBotUserId(parsed), - enterpriseId: readOptionalString(parsed, "enterprise_id"), - event, - eventId, - isEnterpriseInstall: parsed["is_enterprise_install"] === true, - teamId: readOptionalString(parsed, "team_id"), - type: "event_callback", - }, - ok: true, - }; -} - -export function normalizeSlackWorkTrigger( - envelope: SlackEventCallbackEnvelope, -): SlackWorkTrigger | null { - const eventType = readString(envelope.event, "type"); - const channelId = readString(envelope.event, "channel"); - const messageTs = readString(envelope.event, "ts"); - const userId = readString(envelope.event, "user"); - const text = readText(envelope.event, "text"); - - if (!isTruthy(channelId) || !isTruthy(messageTs) || !isTruthy(userId)) { - return null; - } - - if ( - (readOptionalString(envelope.event, "bot_id") !== null && - readOptionalString(envelope.event, "bot_id") !== undefined && - readOptionalString(envelope.event, "bot_id") !== "") || - (readOptionalString(envelope.event, "subtype") !== null && - readOptionalString(envelope.event, "subtype") !== undefined && - readOptionalString(envelope.event, "subtype") !== "") - ) { - return null; - } - - const threadTs = readOptionalString(envelope.event, "thread_ts") ?? messageTs; - - if (eventType === "app_mention") { - return { - botUserId: envelope.botUserId, - channelId, - enterpriseId: envelope.enterpriseId, - eventId: envelope.eventId, - isEnterpriseInstall: envelope.isEnterpriseInstall, - messageTs, - requiresExistingSession: false, - teamId: envelope.teamId, - text: stripLeadingBotMentions(text), - threadTs, - triggerType: "app_mention", - userId, - }; - } - - if (eventType === "message" && readOptionalString(envelope.event, "channel_type") === "im") { - return { - botUserId: envelope.botUserId, - channelId, - enterpriseId: envelope.enterpriseId, - eventId: envelope.eventId, - isEnterpriseInstall: envelope.isEnterpriseInstall, - messageTs, - requiresExistingSession: false, - teamId: envelope.teamId, - text: text.trim(), - threadTs, - triggerType: "dm_message", - userId, - }; - } - - if ( - eventType === "message" && - threadTs !== messageTs && - !containsBotMention(text, envelope.botUserId) - ) { - return { - botUserId: envelope.botUserId, - channelId, - enterpriseId: envelope.enterpriseId, - eventId: envelope.eventId, - isEnterpriseInstall: envelope.isEnterpriseInstall, - messageTs, - requiresExistingSession: true, - teamId: envelope.teamId, - text: text.trim(), - threadTs, - triggerType: "channel_thread_message", - userId, - }; - } - - return null; -} diff --git a/apps/api/src/modules/channels/slack/slack-first-party-adapter.ts b/apps/api/src/modules/channels/slack/slack-first-party-adapter.ts deleted file mode 100644 index adfd9fa7..00000000 --- a/apps/api/src/modules/channels/slack/slack-first-party-adapter.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { AgentId, ChannelBindingId, SessionId, SessionRunId } from "@mosoo/id"; - -import { isTruthy } from "../../../shared/truthiness"; -import { - CHANNEL_AGENT_FAILURE_TEXT, - buildChannelSessionLink, - buildChannelWorkingText, -} from "../application/channel-agent-reply"; -import type { ChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import { logSlackAdapterError } from "./slack-adapter-logger"; -import type { SlackAgentReplyPollClient } from "./slack-agent-reply"; -import type { SlackWorkTrigger } from "./slack-events"; -import { SlackWebApiClient, SlackWebApiError } from "./slack-web-api"; -import type { SlackMessageReference } from "./slack-web-api"; - -export const SLACK_FIRST_PARTY_ADAPTER_MANIFEST = { - activityModels: ["thread_reply"], - contextModels: ["im_thread"], - displayName: "Slack", - id: "slack", - identityModels: ["bot_user"], - requires: { - auth: ["bot_install"], - credentials: ["bot_token", "signing_secret"], - }, - surfaceType: "im", - toolModels: ["mcp_only"], - triggers: ["app_mention", "channel_thread_message", "dm_received"], -} as const; - -export interface SlackAdapterConfig { - agentId: AgentId; - bindingId: ChannelBindingId; - sessionLinkBaseUrl: string | null; - slackBotToken: string; -} - -export interface SlackSessionCommandClient extends SlackAgentReplyPollClient { - createOrContinueSession(input: { - clientRequestId: string; - text: string; - trigger: SlackWorkTrigger; - }): Promise<{ - duplicate: boolean; - ignored?: boolean; - runId: SessionRunId | null; - sessionId: SessionId | null; - }>; - markBindingError(errorCode: string): Promise; -} - -function toMosooMessage(trigger: SlackWorkTrigger): string { - return [ - isTruthy(trigger.text) ? trigger.text : "(no text)", - "", - "---", - `Source: Slack ${trigger.triggerType}`, - `Slack team: ${trigger.teamId ?? "unknown"}`, - `Slack channel: ${trigger.channelId}`, - `Slack thread: ${trigger.threadTs}`, - `Slack user: ${trigger.userId}`, - ].join("\n"); -} - -function shouldMarkBindingError(error: SlackWebApiError): boolean { - return ( - error.code === "account_inactive" || - error.code === "invalid_auth" || - error.code === "missing_scope" || - error.code === "not_authed" || - error.code === "token_revoked" - ); -} - -async function markBindingErrorIfCredentialScoped(input: { - error: SlackWebApiError; - sessionClient: SlackSessionCommandClient; -}): Promise { - if (!shouldMarkBindingError(input.error)) { - return; - } - - await input.sessionClient.markBindingError(input.error.code); -} - -async function writeSlackFinalReply(input: { - channelId: string; - slack: SlackWebApiClient; - text: string; - threadTs: string; - workingMessage: SlackMessageReference | null; -}): Promise { - if (input.workingMessage) { - await input.slack.updateMessage({ - channelId: input.workingMessage.channelId, - text: input.text, - ts: input.workingMessage.ts, - }); - return; - } - - await input.slack.postChatMessage({ - channelId: input.channelId, - text: input.text, - threadTs: input.threadTs, - }); -} - -export async function processSlackWorkTrigger(input: { - config: SlackAdapterConfig; - finalDeliveryScheduler: ChannelFinalDeliveryScheduler; - sessionClient: SlackSessionCommandClient; - trigger: SlackWorkTrigger; -}): Promise { - const slack = new SlackWebApiClient(input.config.slackBotToken); - let workingMessage: SlackMessageReference | null = null; - - try { - const sessionCommand = await input.sessionClient.createOrContinueSession({ - clientRequestId: `slack:event:${input.trigger.eventId}`, - text: toMosooMessage(input.trigger), - trigger: input.trigger, - }); - - if (sessionCommand.duplicate || sessionCommand.ignored) { - return; - } - - const sessionId = sessionCommand.sessionId; - - if (!sessionId) { - throw new Error("Slack channel session command did not return a session id."); - } - - const runId = sessionCommand.runId; - - if (!runId) { - throw new Error("Slack channel session command did not return a run id."); - } - - const sessionLink = buildChannelSessionLink({ - agentId: input.config.agentId, - sessionId, - sessionLinkBaseUrl: input.config.sessionLinkBaseUrl, - }); - workingMessage = await slack.postChatMessage({ - channelId: input.trigger.channelId, - text: buildChannelWorkingText({ linkLabel: sessionId, sessionLink }), - threadTs: input.trigger.threadTs, - }); - await input.finalDeliveryScheduler.enqueue({ - bindingId: input.config.bindingId, - externalEventId: `slack:event:${input.trigger.eventId}`, - payload: { - channelId: input.trigger.channelId, - provider: "slack", - threadTs: input.trigger.threadTs, - workingMessage, - }, - provider: "slack", - runId, - sessionId, - }); - } catch (error) { - if (error instanceof SlackWebApiError) { - await markBindingErrorIfCredentialScoped({ - error, - sessionClient: input.sessionClient, - }); - } - - logSlackAdapterError("slack-first-party-adapter.failed", error, { - bindingId: input.config.bindingId, - channelId: input.trigger.channelId, - eventId: input.trigger.eventId, - teamId: input.trigger.teamId, - triggerType: input.trigger.triggerType, - }); - - try { - await writeSlackFinalReply({ - channelId: input.trigger.channelId, - slack, - text: CHANNEL_AGENT_FAILURE_TEXT, - threadTs: input.trigger.threadTs, - workingMessage, - }); - } catch (failureReplyError) { - if (failureReplyError instanceof SlackWebApiError) { - await markBindingErrorIfCredentialScoped({ - error: failureReplyError, - sessionClient: input.sessionClient, - }); - } - - logSlackAdapterError("slack-first-party-adapter.failure_reply_failed", failureReplyError, { - bindingId: input.config.bindingId, - channelId: input.trigger.channelId, - eventId: input.trigger.eventId, - teamId: input.trigger.teamId, - triggerType: input.trigger.triggerType, - }); - } - } -} diff --git a/apps/api/src/modules/channels/slack/slack-signing.ts b/apps/api/src/modules/channels/slack/slack-signing.ts deleted file mode 100644 index 3a8d4d2d..00000000 --- a/apps/api/src/modules/channels/slack/slack-signing.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; -const SIGNATURE_VERSION = "v0"; -const MAX_CLOCK_SKEW_SECONDS = 60 * 5; - -export interface SlackSignatureVerificationInput { - body: string; - headers: Headers; - nowSeconds?: number; - signingSecret: string; -} - -export interface SlackSignatureVerificationFailure { - code: "missing_header" | "stale_timestamp" | "signature_mismatch"; - message: string; - ok: false; - status: 400 | 401; -} - -export interface SlackSignatureVerificationSuccess { - ok: true; -} - -export type SlackSignatureVerificationResult = - | SlackSignatureVerificationFailure - | SlackSignatureVerificationSuccess; - -function bytesToHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -function timingSafeEqual(left: string, right: string): boolean { - const encoder = new TextEncoder(); - const leftBytes = encoder.encode(left); - const rightBytes = encoder.encode(right); - - if (leftBytes.length !== rightBytes.length) { - return false; - } - - let difference = 0; - - for (let index = 0; index < leftBytes.length; index += 1) { - difference |= (leftBytes.at(index) ?? 0) ^ (rightBytes.at(index) ?? 0); - } - - return difference === 0; -} - -async function computeSlackSignature(input: { - body: string; - signingSecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.signingSecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const baseString = `${SIGNATURE_VERSION}:${input.timestamp}:${input.body}`; - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(baseString)); - - return `${SIGNATURE_VERSION}=${bytesToHex(signature)}`; -} - -export async function verifySlackSignature( - input: SlackSignatureVerificationInput, -): Promise { - const timestamp = input.headers.get("x-slack-request-timestamp"); - const signature = input.headers.get("x-slack-signature"); - - if (!isTruthy(timestamp) || !isTruthy(signature)) { - return { - code: "missing_header", - message: "Slack signature headers are required.", - ok: false, - status: 400, - }; - } - - const parsedTimestamp = Number.parseInt(timestamp, 10); - - if (!Number.isSafeInteger(parsedTimestamp)) { - return { - code: "missing_header", - message: "Slack request timestamp is invalid.", - ok: false, - status: 400, - }; - } - - const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000); - - if (Math.abs(nowSeconds - parsedTimestamp) > MAX_CLOCK_SKEW_SECONDS) { - return { - code: "stale_timestamp", - message: "Slack request timestamp is outside the accepted window.", - ok: false, - status: 401, - }; - } - - const expected = await computeSlackSignature({ - body: input.body, - signingSecret: input.signingSecret, - timestamp, - }); - - if (!timingSafeEqual(expected, signature)) { - return { - code: "signature_mismatch", - message: "Slack request signature is invalid.", - ok: false, - status: 401, - }; - } - - return { ok: true }; -} diff --git a/apps/api/src/modules/channels/slack/slack-web-api.ts b/apps/api/src/modules/channels/slack/slack-web-api.ts deleted file mode 100644 index 9cc6cf59..00000000 --- a/apps/api/src/modules/channels/slack/slack-web-api.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { fetchChannelWebApi, readChannelWebApiJson } from "../channel-fetch"; - -export interface SlackPostMessageInput { - channelId: string; - text: string; - threadTs: string; -} - -export interface SlackUpdateMessageInput { - channelId: string; - text: string; - ts: string; -} - -export interface SlackMessageReference { - channelId: string; - ts: string; -} - -interface SlackWebApiOkResponse { - channel?: string; - ok: true; - ts?: string; -} - -interface SlackWebApiErrorResponse { - error?: string; - ok: false; -} - -type SlackWebApiResponse = SlackWebApiErrorResponse | SlackWebApiOkResponse; - -type SlackWebApiOperation = "auth.test" | "chat.postMessage" | "chat.update"; - -export class SlackWebApiError extends Error { - readonly code: string; - readonly operation: SlackWebApiOperation; - - constructor(operation: SlackWebApiOperation, code: string) { - super(`Slack ${operation} failed: ${code}`); - this.code = code; - this.name = "SlackWebApiError"; - this.operation = operation; - } -} - -function isJsonObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isSlackWebApiResponse(value: unknown): value is SlackWebApiResponse { - if (!isJsonObject(value)) { - return false; - } - - const ok = value["ok"]; - - return ok === true || ok === false; -} - -function readNonEmptyString(value: unknown, key: string): string | null { - if (!isJsonObject(value)) { - return null; - } - - const field = value[key]; - return typeof field === "string" && field.trim() ? field : null; -} - -function getSlackErrorCode( - response: Response, - body: unknown, - operation: SlackWebApiOperation, -): string { - if (isSlackWebApiResponse(body) && !body.ok && typeof body.error === "string") { - return body.error; - } - - return response.statusText.trim() || `${operation}_failed`; -} - -export class SlackWebApiClient { - readonly #botToken: string; - readonly #timeoutMs: number | undefined; - - constructor(botToken: string, options: { timeoutMs?: number } = {}) { - this.#botToken = botToken; - this.#timeoutMs = options.timeoutMs; - } - - async authTest(): Promise<{ - botId: string | null; - team: string | null; - teamId: string | null; - user: string | null; - userId: string | null; - }> { - const response = await fetchChannelWebApi({ - init: { - headers: { - Authorization: `Bearer ${this.#botToken}`, - }, - method: "POST", - }, - label: "Slack auth.test", - timeoutMs: this.#timeoutMs, - url: "https://slack.com/api/auth.test", - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok || !isSlackWebApiResponse(body) || !body.ok) { - throw new SlackWebApiError("auth.test", getSlackErrorCode(response, body, "auth.test")); - } - - return { - botId: readNonEmptyString(body, "bot_id"), - team: readNonEmptyString(body, "team"), - teamId: readNonEmptyString(body, "team_id"), - user: readNonEmptyString(body, "user"), - userId: readNonEmptyString(body, "user_id"), - }; - } - - async postChatMessage(input: SlackPostMessageInput): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - channel: input.channelId, - text: input.text, - thread_ts: input.threadTs, - unfurl_links: false, - unfurl_media: false, - }), - headers: { - Authorization: `Bearer ${this.#botToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Slack chat.postMessage", - timeoutMs: this.#timeoutMs, - url: "https://slack.com/api/chat.postMessage", - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok || !isSlackWebApiResponse(body) || !body.ok) { - throw new SlackWebApiError( - "chat.postMessage", - getSlackErrorCode(response, body, "chat.postMessage"), - ); - } - - if (typeof body.ts !== "string" || !body.ts.trim()) { - throw new Error("Slack chat.postMessage response did not include a message timestamp."); - } - - return { - channelId: - typeof body.channel === "string" && body.channel.trim() ? body.channel : input.channelId, - ts: body.ts, - }; - } - - async updateMessage(input: SlackUpdateMessageInput): Promise { - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify({ - channel: input.channelId, - text: input.text, - ts: input.ts, - unfurl_links: false, - unfurl_media: false, - }), - headers: { - Authorization: `Bearer ${this.#botToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Slack chat.update", - timeoutMs: this.#timeoutMs, - url: "https://slack.com/api/chat.update", - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok || !isSlackWebApiResponse(body) || !body.ok) { - throw new SlackWebApiError("chat.update", getSlackErrorCode(response, body, "chat.update")); - } - } -} diff --git a/apps/api/src/modules/channels/telegram/telegram-credentials.ts b/apps/api/src/modules/channels/telegram/telegram-credentials.ts deleted file mode 100644 index 35ecc3ab..00000000 --- a/apps/api/src/modules/channels/telegram/telegram-credentials.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { validationError } from "../../../platform/errors"; -import { isTruthy } from "../../../shared/truthiness"; - -export interface TelegramChannelCredentials { - botToken: string; - webhookSecret: string; -} - -export interface NormalizeTelegramCredentialsInput { - botToken: string; - webhookSecret: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRequiredString(value: string, label: string): string { - const normalized = value.trim(); - - if (!isTruthy(normalized)) { - throw validationError(`${label} is required.`); - } - - return normalized; -} - -function readRequiredString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate; - } - - throw new Error(`Telegram channel credential ${field} is required.`); -} - -export function normalizeTelegramCredentials( - input: NormalizeTelegramCredentialsInput, -): TelegramChannelCredentials { - return { - botToken: normalizeRequiredString(input.botToken, "Telegram bot token"), - webhookSecret: normalizeRequiredString(input.webhookSecret, "Telegram webhook secret"), - }; -} - -export function serializeTelegramCredentials(input: TelegramChannelCredentials): string { - return JSON.stringify(input); -} - -export function parseTelegramCredentials(value: string): TelegramChannelCredentials { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("Telegram channel credentials must be a JSON object."); - } - - return { - botToken: readRequiredString(parsed, "botToken"), - webhookSecret: readRequiredString(parsed, "webhookSecret"), - }; -} diff --git a/apps/api/src/modules/channels/telegram/telegram-events.ts b/apps/api/src/modules/channels/telegram/telegram-events.ts deleted file mode 100644 index 996aa397..00000000 --- a/apps/api/src/modules/channels/telegram/telegram-events.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -interface TelegramUser { - first_name?: string; - id: number; - is_bot?: boolean; - username?: string; -} - -interface TelegramChat { - id: number | string; - title?: string; - type?: string; -} - -interface TelegramMessage { - caption?: string; - chat: TelegramChat; - from?: TelegramUser; - message_id: number; - message_thread_id?: number; - sender_chat?: TelegramChat; - text?: string; -} - -export interface TelegramUpdateEnvelope { - message: TelegramMessage; - updateId: number; -} - -export interface TelegramWorkTrigger { - chatId: string; - chatTitle: string | null; - chatType: string | null; - eventId: string; - externalActorId: string; - externalMessageId: string; - externalThreadId: string; - messageId: number; - messageThreadId: number | null; - text: string; - userDisplayName: string | null; - userId: string | null; - username: string | null; -} - -export interface TelegramUpdateParseFailure { - code: "invalid_json" | "missing_message" | "missing_update_id"; - message: string; - ok: false; -} - -export interface TelegramUpdateParseSuccess { - envelope: TelegramUpdateEnvelope; - ok: true; -} - -export type TelegramUpdateParseResult = TelegramUpdateParseFailure | TelegramUpdateParseSuccess; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readNumber(value: Record, field: string): number | null { - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readChat(value: unknown): TelegramChat | null { - if (!isRecord(value)) { - return null; - } - - const id = value["id"]; - - if (!(typeof id === "string" || (typeof id === "number" && Number.isSafeInteger(id)))) { - return null; - } - - const chat: TelegramChat = { - id, - }; - const title = readString(value, "title"); - const type = readString(value, "type"); - - if (title) { - chat.title = title; - } - - if (type) { - chat.type = type; - } - - return chat; -} - -function readUser(value: unknown): TelegramUser | null { - if (!isRecord(value)) { - return null; - } - - const id = readNumber(value, "id"); - - if (id === null) { - return null; - } - - const user: TelegramUser = { - id, - is_bot: value["is_bot"] === true, - }; - const firstName = readString(value, "first_name"); - const username = readString(value, "username"); - - if (firstName) { - user.first_name = firstName; - } - - if (username) { - user.username = username; - } - - return user; -} - -function readMessage(value: unknown): TelegramMessage | null { - if (!isRecord(value)) { - return null; - } - - const chat = readChat(value["chat"]); - const messageId = readNumber(value, "message_id"); - - if (!chat || messageId === null) { - return null; - } - - const messageThreadId = readNumber(value, "message_thread_id"); - - const message: TelegramMessage = { - chat, - message_id: messageId, - }; - const caption = readString(value, "caption"); - const from = readUser(value["from"]); - const senderChat = readChat(value["sender_chat"]); - const text = readString(value, "text"); - - if (caption) { - message.caption = caption; - } - - if (from) { - message.from = from; - } - - if (messageThreadId !== null) { - message.message_thread_id = messageThreadId; - } - - if (senderChat) { - message.sender_chat = senderChat; - } - - if (text) { - message.text = text; - } - - return message; -} - -function stripLeadingCommand(text: string): string { - return text.replace(/^\/[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_]+)?\s*/u, "").trim(); -} - -export function parseTelegramUpdateEnvelope(body: string): TelegramUpdateParseResult { - let parsed: unknown; - - try { - parsed = JSON.parse(body); - } catch { - return { - code: "invalid_json", - message: "Telegram update body must be valid JSON.", - ok: false, - }; - } - - if (!isRecord(parsed)) { - return { - code: "invalid_json", - message: "Telegram update body must be a JSON object.", - ok: false, - }; - } - - const updateId = readNumber(parsed, "update_id"); - - if (updateId === null) { - return { - code: "missing_update_id", - message: "Telegram update_id is required.", - ok: false, - }; - } - - const message = readMessage(parsed["message"] ?? parsed["channel_post"]); - - if (!message) { - return { - code: "missing_message", - message: "Telegram update does not contain a supported message.", - ok: false, - }; - } - - return { - envelope: { - message, - updateId, - }, - ok: true, - }; -} - -export function normalizeTelegramWorkTrigger( - envelope: TelegramUpdateEnvelope, -): TelegramWorkTrigger | null { - const rawText = envelope.message.text ?? envelope.message.caption ?? ""; - const text = stripLeadingCommand(rawText); - - if (!isTruthy(text)) { - return null; - } - - const chatId = String(envelope.message.chat.id); - const messageThreadId = envelope.message.message_thread_id ?? null; - const threadPart = messageThreadId === null ? "main" : String(messageThreadId); - const from = envelope.message.from; - const senderChat = envelope.message.sender_chat; - const actorId = from ? `telegram:user:${from.id}` : `telegram:chat:${senderChat?.id ?? chatId}`; - - return { - chatId, - chatTitle: envelope.message.chat.title ?? null, - chatType: envelope.message.chat.type ?? null, - eventId: `telegram:update:${envelope.updateId}`, - externalActorId: actorId, - externalMessageId: `${chatId}:${envelope.message.message_id}`, - externalThreadId: `${chatId}:${threadPart}`, - messageId: envelope.message.message_id, - messageThreadId, - text, - userDisplayName: from?.first_name ?? null, - userId: from ? String(from.id) : null, - username: from?.username ?? null, - }; -} diff --git a/apps/api/src/modules/channels/telegram/telegram-first-party-adapter.ts b/apps/api/src/modules/channels/telegram/telegram-first-party-adapter.ts deleted file mode 100644 index 1e65f8cd..00000000 --- a/apps/api/src/modules/channels/telegram/telegram-first-party-adapter.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { AgentId, ChannelBindingId } from "@mosoo/id"; - -import { logChannelAdapterError } from "../application/channel-adapter-logger"; -import { - CHANNEL_AGENT_FAILURE_TEXT, - buildChannelSessionLink, - buildChannelWorkingText, -} from "../application/channel-agent-reply"; -import type { ChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import type { ChannelSessionCommandClient } from "../application/channel-session.types"; -import type { TelegramWorkTrigger } from "./telegram-events"; -import { - isTelegramCredentialScopedError, - TelegramWebApiClient, - TelegramWebApiError, -} from "./telegram-web-api"; - -export const TELEGRAM_FIRST_PARTY_ADAPTER_MANIFEST = { - displayName: "Telegram", - id: "telegram", - requires: { - auth: ["webhook_secret"], - credentials: ["bot_token", "webhook_secret"], - }, - surfaceType: "im", - triggers: ["message", "channel_post"], -} as const; - -export interface TelegramAdapterConfig { - agentId: AgentId; - bindingId: ChannelBindingId; - botToken: string; - sessionLinkBaseUrl: string | null; -} - -async function markBindingErrorIfCredentialScoped(input: { - error: TelegramWebApiError; - sessionClient: ChannelSessionCommandClient; -}): Promise { - if (!isTelegramCredentialScopedError(input.error)) { - return false; - } - await input.sessionClient.markBindingError(input.error.code); - return true; -} - -function toMosooMessage(trigger: TelegramWorkTrigger): string { - return [ - trigger.text, - "", - "---", - "Source: Telegram message", - `Telegram chat: ${trigger.chatId}`, - `Telegram thread: ${trigger.externalThreadId}`, - `Telegram user: ${trigger.userId ?? "unknown"}`, - ].join("\n"); -} - -export async function processTelegramWorkTrigger(input: { - config: TelegramAdapterConfig; - finalDeliveryScheduler: ChannelFinalDeliveryScheduler; - sessionClient: ChannelSessionCommandClient; - trigger: TelegramWorkTrigger; -}): Promise { - const telegram = new TelegramWebApiClient(input.config.botToken); - - try { - const sessionCommand = await input.sessionClient.createOrContinueSession({ - clientRequestId: input.trigger.eventId, - text: toMosooMessage(input.trigger), - trigger: { - eventId: input.trigger.eventId, - externalActorId: input.trigger.externalActorId, - externalMessageId: input.trigger.externalMessageId, - externalThreadId: input.trigger.externalThreadId, - externalWorkspaceId: input.trigger.chatId, - providerMetadata: { - chat_id: input.trigger.chatId, - chat_title: input.trigger.chatTitle, - chat_type: input.trigger.chatType, - message_id: input.trigger.messageId, - message_thread_id: input.trigger.messageThreadId, - user_display_name: input.trigger.userDisplayName, - username: input.trigger.username, - }, - requiresExistingSession: false, - }, - }); - - if (sessionCommand.duplicate || sessionCommand.ignored) { - return; - } - - const sessionId = sessionCommand.sessionId; - - if (!sessionId) { - throw new Error("Telegram channel session command did not return a session id."); - } - - const runId = sessionCommand.runId; - - if (!runId) { - throw new Error("Telegram channel session command did not return a run id."); - } - - const sessionLink = buildChannelSessionLink({ - agentId: input.config.agentId, - sessionId, - sessionLinkBaseUrl: input.config.sessionLinkBaseUrl, - }); - await telegram.sendMessage({ - chatId: input.trigger.chatId, - messageThreadId: input.trigger.messageThreadId, - text: buildChannelWorkingText({ sessionLink }), - }); - await input.finalDeliveryScheduler.enqueue({ - bindingId: input.config.bindingId, - externalEventId: input.trigger.eventId, - payload: { - chatId: input.trigger.chatId, - messageThreadId: input.trigger.messageThreadId, - provider: "telegram", - }, - provider: "telegram", - runId, - sessionId, - }); - } catch (error) { - if (error instanceof TelegramWebApiError) { - const markedCredentialError = await markBindingErrorIfCredentialScoped({ - error, - sessionClient: input.sessionClient, - }); - if (markedCredentialError) { - return; - } - } - - logChannelAdapterError("telegram-first-party-adapter.failed", error, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - - try { - await telegram.sendMessage({ - chatId: input.trigger.chatId, - messageThreadId: input.trigger.messageThreadId, - text: CHANNEL_AGENT_FAILURE_TEXT, - }); - } catch (failureReplyError) { - if (failureReplyError instanceof TelegramWebApiError) { - await markBindingErrorIfCredentialScoped({ - error: failureReplyError, - sessionClient: input.sessionClient, - }); - } - - logChannelAdapterError( - "telegram-first-party-adapter.failure_reply_failed", - failureReplyError, - { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }, - ); - } - } -} diff --git a/apps/api/src/modules/channels/telegram/telegram-signing.ts b/apps/api/src/modules/channels/telegram/telegram-signing.ts deleted file mode 100644 index ca34216e..00000000 --- a/apps/api/src/modules/channels/telegram/telegram-signing.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; - -export interface TelegramWebhookSecretVerificationInput { - headers: Headers; - webhookSecret: string; -} - -export interface TelegramWebhookSecretVerificationFailure { - code: "missing_secret" | "secret_mismatch"; - message: string; - ok: false; - status: 400 | 401; -} - -export interface TelegramWebhookSecretVerificationSuccess { - ok: true; -} - -export type TelegramWebhookSecretVerificationResult = - | TelegramWebhookSecretVerificationFailure - | TelegramWebhookSecretVerificationSuccess; - -function timingSafeEqual(left: string, right: string): boolean { - const encoder = new TextEncoder(); - const leftBytes = encoder.encode(left); - const rightBytes = encoder.encode(right); - - if (leftBytes.length !== rightBytes.length) { - return false; - } - - let difference = 0; - - for (let index = 0; index < leftBytes.length; index += 1) { - difference |= (leftBytes.at(index) ?? 0) ^ (rightBytes.at(index) ?? 0); - } - - return difference === 0; -} - -export function verifyTelegramWebhookSecret( - input: TelegramWebhookSecretVerificationInput, -): TelegramWebhookSecretVerificationResult { - const expected = input.webhookSecret.trim(); - const actual = input.headers.get("x-telegram-bot-api-secret-token")?.trim() ?? ""; - - if (!isTruthy(expected) || !isTruthy(actual)) { - return { - code: "missing_secret", - message: "Telegram webhook secret token is required.", - ok: false, - status: 400, - }; - } - - if (!timingSafeEqual(actual, expected)) { - return { - code: "secret_mismatch", - message: "Telegram webhook secret token is invalid.", - ok: false, - status: 401, - }; - } - - return { ok: true }; -} diff --git a/apps/api/src/modules/channels/telegram/telegram-web-api.ts b/apps/api/src/modules/channels/telegram/telegram-web-api.ts deleted file mode 100644 index 79646eea..00000000 --- a/apps/api/src/modules/channels/telegram/telegram-web-api.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { fetchChannelWebApi, readChannelWebApiJson } from "../channel-fetch"; - -export interface TelegramSendMessageInput { - chatId: string; - messageThreadId: number | null; - text: string; -} - -export interface TelegramMessageReference { - chatId: string; - messageId: number; -} - -interface TelegramWebApiOkResponse { - ok: true; - result?: unknown; -} - -interface TelegramWebApiErrorResponse { - description?: string; - ok: false; -} - -type TelegramWebApiResponse = TelegramWebApiErrorResponse | TelegramWebApiOkResponse; - -type TelegramWebApiOperation = "getMe" | "sendMessage"; - -export class TelegramWebApiError extends Error { - readonly code: string; - readonly operation: TelegramWebApiOperation; - - constructor(operation: TelegramWebApiOperation, code: string) { - super(`Telegram ${operation} failed: ${code}`); - this.code = code; - this.name = "TelegramWebApiError"; - this.operation = operation; - } -} - -export function isTelegramCredentialScopedError(error: TelegramWebApiError): boolean { - if (error.operation === "getMe") { - return true; - } - - const code = error.code.toLowerCase(); - - return code.includes("unauthorized") || code.includes("token"); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isTelegramWebApiResponse(value: unknown): value is TelegramWebApiResponse { - if (!isRecord(value)) { - return false; - } - - const ok = value["ok"]; - return ok === true || ok === false; -} - -function readString(value: unknown, key: string): string | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "string" && field.trim() ? field : null; -} - -function readNumber(value: unknown, key: string): number | null { - if (!isRecord(value)) { - return null; - } - - const field = value[key]; - return typeof field === "number" && Number.isSafeInteger(field) ? field : null; -} - -function getTelegramErrorCode( - response: Response, - body: unknown, - operation: TelegramWebApiOperation, -): string { - if (isTelegramWebApiResponse(body) && !body.ok && typeof body.description === "string") { - return body.description; - } - - return response.statusText.trim() || `${operation}_failed`; -} - -export class TelegramWebApiClient { - readonly #botToken: string; - readonly #timeoutMs: number | undefined; - - constructor(botToken: string, options: { timeoutMs?: number } = {}) { - this.#botToken = botToken; - this.#timeoutMs = options.timeoutMs; - } - - async getMe(): Promise<{ - firstName: string | null; - id: string; - username: string | null; - }> { - const response = await fetchChannelWebApi({ - init: { - method: "POST", - }, - label: "Telegram getMe", - timeoutMs: this.#timeoutMs, - url: `https://api.telegram.org/bot${this.#botToken}/getMe`, - }); - const body = await readChannelWebApiJson(response); - - if (!response.ok || !isTelegramWebApiResponse(body) || !body.ok) { - throw new TelegramWebApiError("getMe", getTelegramErrorCode(response, body, "getMe")); - } - - const id = readNumber(body.result, "id"); - - if (id === null) { - throw new Error("Telegram getMe response did not include a bot id."); - } - - return { - firstName: readString(body.result, "first_name"), - id: String(id), - username: readString(body.result, "username"), - }; - } - - async sendMessage(input: TelegramSendMessageInput): Promise { - const body: Record = { - chat_id: input.chatId, - text: input.text, - }; - - if (input.messageThreadId !== null) { - body["message_thread_id"] = input.messageThreadId; - } - - const response = await fetchChannelWebApi({ - init: { - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }, - label: "Telegram sendMessage", - timeoutMs: this.#timeoutMs, - url: `https://api.telegram.org/bot${this.#botToken}/sendMessage`, - }); - const parsed = await readChannelWebApiJson(response); - - if (!response.ok || !isTelegramWebApiResponse(parsed) || !parsed.ok) { - throw new TelegramWebApiError( - "sendMessage", - getTelegramErrorCode(response, parsed, "sendMessage"), - ); - } - - const messageId = readNumber(parsed.result, "message_id"); - - if (messageId === null) { - throw new Error("Telegram sendMessage response did not include a message id."); - } - - return { - chatId: input.chatId, - messageId, - }; - } -} diff --git a/apps/api/src/modules/channels/wechat/wechat-context-token-secret-store.ts b/apps/api/src/modules/channels/wechat/wechat-context-token-secret-store.ts deleted file mode 100644 index fa52f4b0..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-context-token-secret-store.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { vaultSecretsTable, wechatChannelAccountsTable, wechatContextTokensTable } from "@mosoo/db"; -import type { ChannelBindingId, PlatformId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../../platform/db/drizzle"; -import { validationError } from "../../../platform/errors"; -import { readSecretOutcome, storeSecret } from "../../vault/application/vault-secret-store"; - -export type WeChatContextTokenSecretReadPurpose = "wechat_reply"; - -interface WeChatContextTokenSecretOwner { - readonly accountId: ChannelBindingId; - readonly peerId: string; -} - -function toWeChatContextTokenSecretKind(owner: WeChatContextTokenSecretOwner): string { - return `wechat_context_token:${owner.accountId}:${owner.peerId}`; -} - -export async function storeWeChatContextTokenSecret( - bindings: ApiBindings, - input: WeChatContextTokenSecretOwner & { - readonly value: string; - }, -): Promise { - return storeSecret(bindings.DB, bindings, { - kind: toWeChatContextTokenSecretKind(input), - value: input.value, - }); -} - -export async function readWeChatContextTokenSecret( - bindings: ApiBindings, - input: WeChatContextTokenSecretOwner & { - readonly externalAccountId: string; - readonly purpose: WeChatContextTokenSecretReadPurpose; - readonly secretId: PlatformId; - }, -): Promise { - if (input.purpose !== "wechat_reply") { - throw validationError("WeChat context token purpose is invalid."); - } - - const row = - (await getAppDatabase(bindings.DB) - .select({ - secretKind: vaultSecretsTable.kind, - }) - .from(wechatContextTokensTable) - .innerJoin( - wechatChannelAccountsTable, - eq(wechatChannelAccountsTable.id, wechatContextTokensTable.accountId), - ) - .innerJoin( - vaultSecretsTable, - eq(vaultSecretsTable.id, wechatContextTokensTable.encryptedContextTokenSecretId), - ) - .where( - and( - eq(wechatContextTokensTable.accountId, input.accountId), - eq(wechatContextTokensTable.externalAccountId, input.externalAccountId), - eq(wechatContextTokensTable.peerId, input.peerId), - eq(wechatContextTokensTable.encryptedContextTokenSecretId, input.secretId), - eq(wechatChannelAccountsTable.externalAccountId, input.externalAccountId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row || row.secretKind !== toWeChatContextTokenSecretKind(input)) { - throw validationError("WeChat context token is unavailable."); - } - - const secret = await readSecretOutcome(bindings.DB, bindings, input.secretId); - - if (secret.status === "missing") { - throw validationError("WeChat context token is unavailable."); - } - - return secret.value; -} diff --git a/apps/api/src/modules/channels/wechat/wechat-credentials.ts b/apps/api/src/modules/channels/wechat/wechat-credentials.ts deleted file mode 100644 index 2519426d..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-credentials.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; -import { normalizeWeChatIlinkBaseUrl } from "./wechat-ilink-base-url"; -import type { WeChatQrPairingSnapshot } from "./wechat-runtime"; - -export interface WeChatChannelCredentials { - baseUrl: string; - botToken: string; - ilinkBotId: string; - ilinkUserId: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readRequiredString(value: Record, field: string): string { - const candidate = value[field]; - - if (typeof candidate === "string" && isTruthy(candidate.trim())) { - return candidate.trim(); - } - - throw new Error(`WeChat credentials ${field} is required.`); -} - -function normalizeRequiredString(value: string | null | undefined, field: string): string { - const normalized = value?.trim() ?? ""; - - if (!isTruthy(normalized)) { - throw new Error(`WeChat credentials ${field} is required.`); - } - - return normalized; -} - -function normalizeWeChatChannelCredentials(input: { - baseUrl: string | null | undefined; - botToken: string | null | undefined; - ilinkBotId: string | null | undefined; - ilinkUserId: string | null | undefined; -}): WeChatChannelCredentials { - return { - baseUrl: normalizeWeChatIlinkBaseUrl(normalizeRequiredString(input.baseUrl, "baseUrl")), - botToken: normalizeRequiredString(input.botToken, "botToken"), - ilinkBotId: normalizeRequiredString(input.ilinkBotId, "ilinkBotId"), - ilinkUserId: normalizeRequiredString(input.ilinkUserId, "ilinkUserId"), - }; -} - -export function normalizeWeChatChannelCredentialsFromSnapshot( - snapshot: WeChatQrPairingSnapshot, -): WeChatChannelCredentials { - if (snapshot.status !== "confirmed") { - throw new Error("WeChat QR pairing must be confirmed before credentials can be persisted."); - } - - return normalizeWeChatChannelCredentials({ - baseUrl: snapshot.baseUrl, - botToken: snapshot.botToken, - ilinkBotId: snapshot.ilinkBotId, - ilinkUserId: snapshot.ilinkUserId, - }); -} - -export function serializeWeChatChannelCredentials(credentials: WeChatChannelCredentials): string { - return JSON.stringify(credentials); -} - -export function parseWeChatChannelCredentials(value: string): WeChatChannelCredentials { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("WeChat credentials must be a JSON object."); - } - - return normalizeWeChatChannelCredentials({ - baseUrl: readRequiredString(parsed, "baseUrl"), - botToken: readRequiredString(parsed, "botToken"), - ilinkBotId: readRequiredString(parsed, "ilinkBotId"), - ilinkUserId: readRequiredString(parsed, "ilinkUserId"), - }); -} diff --git a/apps/api/src/modules/channels/wechat/wechat-events.ts b/apps/api/src/modules/channels/wechat/wechat-events.ts deleted file mode 100644 index 52f81d40..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-events.ts +++ /dev/null @@ -1,370 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import { isTruthy } from "../../../shared/truthiness"; -import { createWeChatReplyRoute } from "./wechat-runtime"; -import type { WeChatReplyRoute } from "./wechat-runtime"; - -const WECHAT_ILINK_RET_SESSION_EXPIRED = -14; -const WECHAT_MESSAGE_TYPE_USER = 1; -const WECHAT_MESSAGE_TYPE_BOT = 2; -const WECHAT_MESSAGE_STATE_FINISH = 2; -const WECHAT_MESSAGE_ITEM_TEXT = 1; - -interface WeChatIlinkMessageItem { - text: string | null; - type: number | null; -} - -export interface WeChatIlinkRawMessage { - chatRoomId: string | null; - clientId: string | null; - contextToken: string; - createTimeMs: number | null; - fromUserId: string; - itemList: WeChatIlinkMessageItem[]; - messageId: string; - messageState: number | null; - messageType: number; - msgType: number | null; - roomId: string | null; - toUserId: string; -} - -export interface WeChatIlinkPollEnvelope { - errcode: number | null; - errmsg: string | null; - messages: WeChatIlinkRawMessage[]; - nextCursor: string; - ret: number | null; - suggestedLongPollTimeoutMs: number | null; -} - -export interface WeChatIlinkWorkTrigger { - eventId: string; - externalActorId: string; - externalMessageId: string; - externalThreadId: string; - messageId: string; - peerId: string; - replyRoute: WeChatReplyRoute; - text: string; -} - -export interface WeChatProviderMetadata { - [key: string]: string | number | boolean | null; - chatType: "dm"; - peerId: string; -} - -export interface WeChatPollRuntimeSummary { - nextCursor: string | null; - reason: string | null; - status: "ok" | "provider_error" | "relogin_required"; -} - -export interface WeChatIlinkPollParseFailure { - code: "invalid_json" | "invalid_messages" | "missing_response"; - message: string; - ok: false; -} - -export interface WeChatIlinkPollParseSuccess { - envelope: WeChatIlinkPollEnvelope; - ok: true; -} - -export type WeChatIlinkPollParseResult = WeChatIlinkPollParseFailure | WeChatIlinkPollParseSuccess; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: Record, field: string): string | null { - const candidate = value[field]; - return typeof candidate === "string" && candidate.trim() ? candidate : null; -} - -function readNumber(value: Record, field: string): number | null { - const candidate = value[field]; - return typeof candidate === "number" && Number.isSafeInteger(candidate) ? candidate : null; -} - -function readStringOrNumberAsString(value: Record, field: string): string | null { - const candidate = value[field]; - - if (typeof candidate === "string" && candidate.trim()) { - return candidate; - } - - if (typeof candidate === "number" && Number.isSafeInteger(candidate)) { - return String(candidate); - } - - return null; -} - -function readMessageItem(value: unknown): WeChatIlinkMessageItem | null { - if (!isRecord(value)) { - return null; - } - - const type = readNumber(value, "type"); - if (type === null) { - return null; - } - - const textItem = value["text_item"]; - - return { - text: isRecord(textItem) ? readString(textItem, "text") : null, - type, - }; -} - -function readMessageItems(value: unknown): WeChatIlinkMessageItem[] | null { - if (!Array.isArray(value)) { - return null; - } - - const items: WeChatIlinkMessageItem[] = []; - - for (const item of value) { - const parsed = readMessageItem(item); - - if (!parsed) { - return null; - } - - items.push(parsed); - } - - return items; -} - -function readRawMessage(value: unknown): WeChatIlinkRawMessage | null { - if (!isRecord(value)) { - return null; - } - - const contextToken = readString(value, "context_token"); - const fromUserId = readString(value, "from_user_id"); - const messageId = readStringOrNumberAsString(value, "message_id"); - const messageItems = readMessageItems(value["item_list"]); - const messageState = readNumber(value, "message_state"); - const messageType = readNumber(value, "message_type"); - const toUserId = readString(value, "to_user_id"); - - if ( - !contextToken || - !fromUserId || - !messageId || - !messageItems || - messageState === null || - messageType === null || - !toUserId - ) { - return null; - } - - return { - chatRoomId: readString(value, "chat_room_id"), - clientId: readString(value, "client_id"), - contextToken, - createTimeMs: readNumber(value, "create_time_ms"), - fromUserId, - itemList: messageItems, - messageId, - messageState, - messageType, - msgType: readNumber(value, "msg_type"), - roomId: readString(value, "room_id"), - toUserId, - }; -} - -function readMessages(value: unknown): WeChatIlinkRawMessage[] | null { - if (!Array.isArray(value)) { - return null; - } - - const messages: WeChatIlinkRawMessage[] = []; - - for (const item of value) { - const message = readRawMessage(item); - - if (!message) { - return null; - } - - messages.push(message); - } - - return messages; -} - -function extractText(items: WeChatIlinkMessageItem[]): string { - return items - .filter((item) => item.type === WECHAT_MESSAGE_ITEM_TEXT && item.text !== null) - .map((item) => item.text) - .join("\n") - .trim(); -} - -function hasExplicitGroupShape(message: WeChatIlinkRawMessage): boolean { - return Boolean(message.roomId || message.chatRoomId); -} - -function isSessionExpiredCode(value: number | null): boolean { - return value === WECHAT_ILINK_RET_SESSION_EXPIRED; -} - -// iLink ships `message_id` (and related identifiers) as JSON integers that exceed -// Number.MAX_SAFE_INTEGER (2^53 - 1), so JSON.parse silently rounds them and the -// rounded value reads back as not-a-safe-integer. Quote any unsigned integer with -// 16+ digits at a JSON value position so it round-trips losslessly as a string. -function quoteUnsafeJsonIntegers(body: string): string { - return body.replace(/([:[,]\s*)(\d{16,})(\s*[,}\]])/g, '$1"$2"$3'); -} - -export function parseWeChatIlinkPollEnvelope(body: string): WeChatIlinkPollParseResult { - let parsed: unknown; - - try { - parsed = JSON.parse(quoteUnsafeJsonIntegers(body)); - } catch { - return { - code: "invalid_json", - message: "WeChat iLink poll body must be valid JSON.", - ok: false, - }; - } - - if (!isRecord(parsed)) { - return { - code: "invalid_json", - message: "WeChat iLink poll body must be a JSON object.", - ok: false, - }; - } - - const errcode = readNumber(parsed, "errcode"); - const ret = readNumber(parsed, "ret"); - const nextCursor = readString(parsed, "get_updates_buf"); - const parsedMessages = readMessages(parsed["msgs"]); - const providerReturnedError = (ret !== null && ret !== 0) || (errcode !== null && errcode !== 0); - - if (nextCursor === null) { - return { - code: "missing_response", - message: "WeChat iLink poll body must include get_updates_buf.", - ok: false, - }; - } - - if (!providerReturnedError && !parsedMessages) { - return { - code: "invalid_messages", - message: "WeChat iLink poll body must include a valid msgs array.", - ok: false, - }; - } - - return { - envelope: { - errcode, - errmsg: readString(parsed, "errmsg"), - messages: parsedMessages ?? [], - nextCursor, - ret, - suggestedLongPollTimeoutMs: readNumber(parsed, "longpolling_timeout_ms"), - }, - ok: true, - }; -} - -export function summarizeWeChatPollRuntime( - envelope: WeChatIlinkPollEnvelope, -): WeChatPollRuntimeSummary { - if (isSessionExpiredCode(envelope.ret) || isSessionExpiredCode(envelope.errcode)) { - return { - nextCursor: null, - reason: "session_expired", - status: "relogin_required", - }; - } - - if ( - (envelope.ret !== null && envelope.ret !== 0) || - (envelope.errcode !== null && envelope.errcode !== 0) - ) { - return { - nextCursor: null, - reason: envelope.errmsg ?? "provider_error", - status: "provider_error", - }; - } - - return { - nextCursor: envelope.nextCursor, - reason: null, - status: "ok", - }; -} - -export function normalizeWeChatIlinkWorkTrigger( - message: WeChatIlinkRawMessage, - input: { - accountId: string; - bindingId: ChannelBindingId; - botId: string; - }, -): WeChatIlinkWorkTrigger | null { - if (message.messageType === WECHAT_MESSAGE_TYPE_BOT) { - return null; - } - - if (message.messageType !== WECHAT_MESSAGE_TYPE_USER) { - return null; - } - - if (message.messageState !== null && message.messageState !== WECHAT_MESSAGE_STATE_FINISH) { - return null; - } - - if (hasExplicitGroupShape(message) || message.toUserId !== input.botId) { - return null; - } - - const text = extractText(message.itemList); - - if (!isTruthy(text)) { - return null; - } - - const replyRoute = createWeChatReplyRoute({ - accountId: input.accountId, - bindingId: input.bindingId, - contextToken: message.contextToken, - peerId: message.fromUserId, - }); - - return { - eventId: `wechat:message:${message.messageId}`, - externalActorId: `wechat:user:${message.fromUserId}`, - externalMessageId: `${message.fromUserId}:${message.messageId}`, - externalThreadId: `wechat:dm:${message.fromUserId}`, - messageId: message.messageId, - peerId: message.fromUserId, - replyRoute, - text, - }; -} - -export function createWeChatProviderMetadata( - trigger: WeChatIlinkWorkTrigger, -): WeChatProviderMetadata { - return { - chatType: "dm", - peerId: trigger.peerId, - }; -} diff --git a/apps/api/src/modules/channels/wechat/wechat-first-party-adapter.ts b/apps/api/src/modules/channels/wechat/wechat-first-party-adapter.ts deleted file mode 100644 index e7cc1110..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-first-party-adapter.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { AgentId, ChannelBindingId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { logChannelAdapterError } from "../application/channel-adapter-logger"; -import { - CHANNEL_AGENT_FAILURE_TEXT, - buildChannelSessionLink, - buildChannelWorkingText, -} from "../application/channel-agent-reply"; -import type { ChannelFinalDeliveryScheduler } from "../application/channel-final-delivery.service"; -import type { ChannelSessionCommandClient } from "../application/channel-session.types"; -import { createWeChatProviderMetadata } from "./wechat-events"; -import type { WeChatIlinkWorkTrigger } from "./wechat-events"; -import { sendWeChatStoredContextReply } from "./wechat-reply.service"; - -export interface WeChatAdapterConfig { - agentId: AgentId; - bindingId: ChannelBindingId; - sessionLinkBaseUrl: string | null; -} - -function toMosooMessage(trigger: WeChatIlinkWorkTrigger): string { - return [ - trigger.text, - "", - "---", - "Source: Personal WeChat DM", - `WeChat peer: ${trigger.peerId}`, - `WeChat thread: ${trigger.externalThreadId}`, - ].join("\n"); -} - -export async function processWeChatWorkTrigger(input: { - bindings: ApiBindings; - config: WeChatAdapterConfig; - finalDeliveryScheduler: ChannelFinalDeliveryScheduler; - sessionClient: ChannelSessionCommandClient; - trigger: WeChatIlinkWorkTrigger; -}): Promise { - try { - const sessionCommand = await input.sessionClient.createOrContinueSession({ - clientRequestId: input.trigger.eventId, - text: toMosooMessage(input.trigger), - trigger: { - eventId: input.trigger.eventId, - externalActorId: input.trigger.externalActorId, - externalMessageId: input.trigger.externalMessageId, - externalThreadId: input.trigger.externalThreadId, - externalWorkspaceId: input.trigger.peerId, - providerMetadata: createWeChatProviderMetadata(input.trigger), - requiresExistingSession: false, - }, - }); - - if (sessionCommand.duplicate || sessionCommand.ignored) { - return; - } - - const sessionId = sessionCommand.sessionId; - - if (!sessionId) { - throw new Error("WeChat channel session command did not return a session id."); - } - - const runId = sessionCommand.runId; - - if (!runId) { - throw new Error("WeChat channel session command did not return a run id."); - } - - await input.finalDeliveryScheduler.enqueue({ - bindingId: input.config.bindingId, - externalEventId: input.trigger.eventId, - payload: { - peerId: input.trigger.peerId, - provider: "wechat", - }, - provider: "wechat", - runId, - sessionId, - }); - - try { - const sessionLink = buildChannelSessionLink({ - agentId: input.config.agentId, - sessionId, - sessionLinkBaseUrl: input.config.sessionLinkBaseUrl, - }); - await sendWeChatStoredContextReply(input.bindings, { - accountId: input.config.bindingId, - peerId: input.trigger.peerId, - text: buildChannelWorkingText({ sessionLink }), - }); - } catch (workingReplyError) { - logChannelAdapterError("wechat-first-party-adapter.working_reply_failed", workingReplyError, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - } - } catch (error) { - logChannelAdapterError("wechat-first-party-adapter.failed", error, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - - try { - await sendWeChatStoredContextReply(input.bindings, { - accountId: input.config.bindingId, - peerId: input.trigger.peerId, - text: CHANNEL_AGENT_FAILURE_TEXT, - }); - } catch (failureReplyError) { - logChannelAdapterError("wechat-first-party-adapter.failure_reply_failed", failureReplyError, { - bindingId: input.config.bindingId, - eventId: input.trigger.eventId, - }); - } - - throw error; - } -} diff --git a/apps/api/src/modules/channels/wechat/wechat-ilink-base-url.ts b/apps/api/src/modules/channels/wechat/wechat-ilink-base-url.ts deleted file mode 100644 index c82e2adc..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-ilink-base-url.ts +++ /dev/null @@ -1,37 +0,0 @@ -const WECHAT_ILINK_DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; - -const WECHAT_ILINK_ALLOWED_HOSTS = new Set(["ilinkai.weixin.qq.com"]); -const WECHAT_ILINK_LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); - -export function normalizeWeChatIlinkBaseUrl(value: string | null | undefined): string { - const rawValue = value?.trim() || WECHAT_ILINK_DEFAULT_BASE_URL; - let url: URL; - - try { - url = new URL(rawValue); - } catch { - throw new Error("WeChat iLink baseUrl must be a valid URL."); - } - - if (url.username || url.password || url.search || url.hash) { - throw new Error("WeChat iLink baseUrl must not include credentials, query, or fragment."); - } - - if (url.pathname !== "" && url.pathname !== "/") { - throw new Error("WeChat iLink baseUrl must not include a path."); - } - - const isOfficial = - url.protocol === "https:" && !url.port && WECHAT_ILINK_ALLOWED_HOSTS.has(url.hostname); - const isLoopback = - (url.protocol === "http:" || url.protocol === "https:") && - WECHAT_ILINK_LOOPBACK_HOSTS.has(url.hostname); - - if (!isOfficial && !isLoopback) { - throw new Error( - "WeChat iLink baseUrl must target the official iLink HTTPS origin or a loopback host.", - ); - } - - return url.origin; -} diff --git a/apps/api/src/modules/channels/wechat/wechat-ilink-client.ts b/apps/api/src/modules/channels/wechat/wechat-ilink-client.ts deleted file mode 100644 index 3f545318..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-ilink-client.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { isTruthy } from "../../../shared/truthiness"; -import { normalizeWeChatIlinkBaseUrl } from "./wechat-ilink-base-url"; -import type { WeChatIlinkQrStatusResponse } from "./wechat-runtime"; - -const WECHAT_ILINK_CHANNEL_VERSION = "2.2.0"; -const WECHAT_ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0; -const WECHAT_ILINK_APP_ID = "bot"; - -const WECHAT_ILINK_ENDPOINT = { - getBotQr: "ilink/bot/get_bot_qrcode", - getQrStatus: "ilink/bot/get_qrcode_status", - getUpdates: "ilink/bot/getupdates", - sendMessage: "ilink/bot/sendmessage", -} as const; - -interface WeChatIlinkBaseInfo { - channel_version: string; -} - -interface WeChatIlinkApiResult { - errcode?: number; - errmsg?: string; - msg?: string; - ret?: number; -} - -interface WeChatIlinkSendMessagePayload { - msg: { - client_id: string; - context_token: string; - from_user_id: ""; - item_list: Array<{ - text_item: { - text: string; - }; - type: 1; - }>; - message_state: 2; - message_type: 2; - to_user_id: string; - }; -} - -export interface WeChatIlinkClientOptions { - baseUrl?: string; - botToken?: string; - fetchImpl?: typeof fetch; - randomUin?: () => string; -} - -export interface WeChatIlinkBotQrResponse { - qrCodeImageContent: string | null; - qrToken: string; -} - -export interface WeChatIlinkGetUpdatesInput { - cursor: string; - timeoutMs: number; -} - -export interface WeChatIlinkSendTextInput { - clientId: string; - contextToken: string; - text: string; - toUserId: string; -} - -export class WeChatIlinkHttpError extends Error { - readonly bodyPreview: string; - readonly endpoint: string; - readonly status: number; - - constructor(input: { bodyPreview: string; endpoint: string; status: number }) { - super(`WeChat iLink ${input.endpoint} HTTP ${input.status}.`); - this.name = "WeChatIlinkHttpError"; - this.bodyPreview = input.bodyPreview; - this.endpoint = input.endpoint; - this.status = input.status; - } -} - -export class WeChatIlinkApiError extends Error { - readonly code: string; - readonly endpoint: string; - - constructor(input: { code: string; endpoint: string; message: string }) { - super(input.message); - this.name = "WeChatIlinkApiError"; - this.code = input.code; - this.endpoint = input.endpoint; - } -} - -function createRandomUin(): string { - const values = new Uint32Array(1); - crypto.getRandomValues(values); - return String(values[0]); -} - -function endpointUrl(baseUrl: string, endpoint: string): string { - return `${baseUrl}/${endpoint.replace(/^\/+/, "")}`; -} - -function createRequestBody(payload: object): string { - return JSON.stringify({ - ...payload, - base_info: { - channel_version: WECHAT_ILINK_CHANNEL_VERSION, - } satisfies WeChatIlinkBaseInfo, - }); -} - -function readApiResult(value: unknown): WeChatIlinkApiResult | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - - const record = value as Record; - const result: WeChatIlinkApiResult = {}; - - if (typeof record["errcode"] === "number") { - result.errcode = record["errcode"]; - } - - if (typeof record["errmsg"] === "string") { - result.errmsg = record["errmsg"]; - } - - if (typeof record["msg"] === "string") { - result.msg = record["msg"]; - } - - if (typeof record["ret"] === "number") { - result.ret = record["ret"]; - } - - return result; -} - -function ensureIlinkOk(endpoint: string, value: unknown): void { - const result = readApiResult(value); - - if (!result) { - return; - } - - const ret = result.ret ?? 0; - const errcode = result.errcode ?? 0; - - if (ret === 0 && errcode === 0) { - return; - } - - const code = `ilink_${ret !== 0 ? ret : errcode}`; - const message = result.errmsg ?? result.msg ?? "WeChat iLink API returned an error."; - - throw new WeChatIlinkApiError({ - code, - endpoint, - message: `WeChat iLink ${endpoint} failed: ${message}`, - }); -} - -function parseJsonObject(body: string, endpoint: string): Record { - let parsed: unknown; - - try { - parsed = JSON.parse(body); - } catch { - throw new WeChatIlinkApiError({ - code: "invalid_json", - endpoint, - message: `WeChat iLink ${endpoint} response must be valid JSON.`, - }); - } - - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new WeChatIlinkApiError({ - code: "invalid_json", - endpoint, - message: `WeChat iLink ${endpoint} response must be a JSON object.`, - }); - } - - return parsed as Record; -} - -export class WeChatIlinkClient { - readonly #baseUrl: string; - readonly #botToken: string | null; - readonly #fetch: typeof fetch; - readonly #randomUin: () => string; - - constructor(options: WeChatIlinkClientOptions = {}) { - this.#baseUrl = normalizeWeChatIlinkBaseUrl(options.baseUrl); - this.#botToken = options.botToken?.trim() || null; - this.#fetch = options.fetchImpl ?? fetch.bind(globalThis); - this.#randomUin = options.randomUin ?? createRandomUin; - } - - async getBotQr(input: { botType?: string } = {}): Promise { - const botType = input.botType?.trim() || "3"; - const endpoint = `${WECHAT_ILINK_ENDPOINT.getBotQr}?bot_type=${encodeURIComponent(botType)}`; - const response = await this.#get(endpoint, { timeoutMs: 35_000 }); - const qrToken = typeof response["qrcode"] === "string" ? response["qrcode"].trim() : ""; - - if (!isTruthy(qrToken)) { - throw new WeChatIlinkApiError({ - code: "missing_qrcode", - endpoint: WECHAT_ILINK_ENDPOINT.getBotQr, - message: "WeChat iLink QR response did not include qrcode.", - }); - } - - return { - qrCodeImageContent: - typeof response["qrcode_img_content"] === "string" ? response["qrcode_img_content"] : null, - qrToken, - }; - } - - async getQrStatus(input: { qrToken: string }): Promise { - const qrToken = input.qrToken.trim(); - - if (!isTruthy(qrToken)) { - throw new WeChatIlinkApiError({ - code: "missing_qrcode", - endpoint: WECHAT_ILINK_ENDPOINT.getQrStatus, - message: "WeChat iLink QR status requires qrcode.", - }); - } - - const endpoint = `${WECHAT_ILINK_ENDPOINT.getQrStatus}?qrcode=${encodeURIComponent(qrToken)}`; - const response = await this.#get(endpoint, { timeoutMs: 35_000 }); - const status = response["status"]; - - if ( - status !== "confirmed" && - status !== "expired" && - status !== "scaned" && - status !== "wait" - ) { - throw new WeChatIlinkApiError({ - code: "unsupported_qr_status", - endpoint: WECHAT_ILINK_ENDPOINT.getQrStatus, - message: "WeChat iLink QR status response did not include a supported status.", - }); - } - - const qrStatus: WeChatIlinkQrStatusResponse = { status }; - - if (typeof response["baseurl"] === "string") { - qrStatus.baseurl = response["baseurl"]; - } - - if (typeof response["bot_token"] === "string") { - qrStatus.bot_token = response["bot_token"]; - } - - if (typeof response["ilink_bot_id"] === "string") { - qrStatus.ilink_bot_id = response["ilink_bot_id"]; - } - - if (typeof response["ilink_user_id"] === "string") { - qrStatus.ilink_user_id = response["ilink_user_id"]; - } - - return qrStatus; - } - - async getUpdates(input: WeChatIlinkGetUpdatesInput): Promise { - return this.#postRaw(WECHAT_ILINK_ENDPOINT.getUpdates, { - payload: { - get_updates_buf: input.cursor, - }, - timeoutMs: input.timeoutMs, - token: this.#requireBotToken(WECHAT_ILINK_ENDPOINT.getUpdates), - }); - } - - async sendText(input: WeChatIlinkSendTextInput): Promise { - const text = input.text.trim(); - const contextToken = input.contextToken.trim(); - - if (!isTruthy(text)) { - throw new WeChatIlinkApiError({ - code: "empty_text", - endpoint: WECHAT_ILINK_ENDPOINT.sendMessage, - message: "WeChat iLink send text requires non-empty text.", - }); - } - - if (!isTruthy(contextToken)) { - throw new WeChatIlinkApiError({ - code: "missing_context_token", - endpoint: WECHAT_ILINK_ENDPOINT.sendMessage, - message: "WeChat iLink send text requires context_token.", - }); - } - - const payload: WeChatIlinkSendMessagePayload = { - msg: { - client_id: input.clientId, - context_token: contextToken, - from_user_id: "", - item_list: [{ text_item: { text }, type: 1 }], - message_state: 2, - message_type: 2, - to_user_id: input.toUserId, - }, - }; - - const response = await this.#postJson(WECHAT_ILINK_ENDPOINT.sendMessage, { - payload, - timeoutMs: 15_000, - token: this.#requireBotToken(WECHAT_ILINK_ENDPOINT.sendMessage), - }); - - ensureIlinkOk(WECHAT_ILINK_ENDPOINT.sendMessage, response); - } - - async #get(endpoint: string, input: { timeoutMs: number }): Promise> { - const response = await this.#fetch(endpointUrl(this.#baseUrl, endpoint), { - headers: { - "iLink-App-ClientVersion": String(WECHAT_ILINK_APP_CLIENT_VERSION), - "iLink-App-Id": WECHAT_ILINK_APP_ID, - }, - signal: AbortSignal.timeout(input.timeoutMs), - }); - const body = await response.text(); - - if (!response.ok) { - throw new WeChatIlinkHttpError({ - bodyPreview: body.slice(0, 200), - endpoint, - status: response.status, - }); - } - - return parseJsonObject(body, endpoint); - } - - async #postJson( - endpoint: string, - input: { payload: object; timeoutMs: number; token: string }, - ): Promise> { - const body = await this.#postRaw(endpoint, input); - return parseJsonObject(body, endpoint); - } - - async #postRaw( - endpoint: string, - input: { payload: object; timeoutMs: number; token: string }, - ): Promise { - const body = createRequestBody(input.payload); - const response = await this.#fetch(endpointUrl(this.#baseUrl, endpoint), { - body, - headers: { - Authorization: `Bearer ${input.token}`, - AuthorizationType: "ilink_bot_token", - "Content-Length": String(new TextEncoder().encode(body).byteLength), - "Content-Type": "application/json", - "iLink-App-ClientVersion": String(WECHAT_ILINK_APP_CLIENT_VERSION), - "iLink-App-Id": WECHAT_ILINK_APP_ID, - "X-WECHAT-UIN": this.#randomUin(), - }, - method: "POST", - signal: AbortSignal.timeout(input.timeoutMs), - }); - const responseBody = await response.text(); - - if (!response.ok) { - throw new WeChatIlinkHttpError({ - bodyPreview: responseBody.slice(0, 200), - endpoint, - status: response.status, - }); - } - - return responseBody; - } - - #requireBotToken(endpoint: string): string { - if (!this.#botToken) { - throw new WeChatIlinkApiError({ - code: "missing_bot_token", - endpoint, - message: "WeChat iLink bot token is required.", - }); - } - - return this.#botToken; - } -} diff --git a/apps/api/src/modules/channels/wechat/wechat-polling-owner-store.ts b/apps/api/src/modules/channels/wechat/wechat-polling-owner-store.ts deleted file mode 100644 index 6e9c8d32..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-polling-owner-store.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { wechatChannelAccountsTable, wechatContextTokensTable } from "@mosoo/db"; -import type { WeChatChannelAccountRow, WeChatContextTokenId } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { ChannelBindingId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { deleteSecret } from "../../vault/application/vault-secret-store"; -import { storeWeChatContextTokenSecret } from "./wechat-context-token-secret-store"; -import type { - WeChatPollingOwnerContextTokenRecord, - WeChatPollingOwnerCursorRecord, - WeChatPollingOwnerRuntimeStateRecord, - WeChatPollingOwnerStorageKey, - WeChatPollingOwnerStore, -} from "./wechat-polling-owner"; - -type WeChatChannelAccountPatch = Partial< - Pick< - typeof wechatChannelAccountsTable.$inferInsert, - | "cursor" - | "lastErrorCode" - | "lastHeartbeatAt" - | "lastInboundAt" - | "lastPollAt" - | "runtimeStateJson" - | "status" - | "statusChangedAt" - | "updatedAt" - > ->; - -async function requireWeChatChannelAccountRow( - database: D1Database, - input: WeChatPollingOwnerStorageKey, -): Promise { - const row = - (await getAppDatabase(database) - .select() - .from(wechatChannelAccountsTable) - .where( - and( - eq(wechatChannelAccountsTable.id, input.bindingId), - eq(wechatChannelAccountsTable.externalAccountId, input.accountId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - throw new Error("WeChat channel account not found."); - } - - return row; -} - -async function updateWeChatAccountOrThrow( - database: D1Database, - input: { - bindingId: ChannelBindingId; - externalAccountId: string; - fields: WeChatChannelAccountPatch; - }, -): Promise { - const result = await getAppDatabase(database) - .update(wechatChannelAccountsTable) - .set(input.fields) - .where( - and( - eq(wechatChannelAccountsTable.id, input.bindingId), - eq(wechatChannelAccountsTable.externalAccountId, input.externalAccountId), - ), - ) - .run(); - - if (getD1ChangeCount(result) === 0) { - throw new Error("WeChat channel account not found."); - } -} - -function getSnapshotUpdatedAtMs( - snapshot: WeChatPollingOwnerRuntimeStateRecord["snapshot"], -): number { - return Math.max( - snapshot.statusChangedAtMs, - snapshot.lastHeartbeatAtMs ?? 0, - snapshot.lastInboundAtMs ?? 0, - snapshot.lastPollAtMs ?? 0, - ); -} - -export function createWeChatPollingOwnerDatabaseStore( - bindings: ApiBindings, -): WeChatPollingOwnerStore { - return { - async readCursor(input: WeChatPollingOwnerStorageKey): Promise { - const row = await requireWeChatChannelAccountRow(bindings.DB, input); - return row.cursor; - }, - async writeContextToken(input: WeChatPollingOwnerContextTokenRecord): Promise { - const row = await requireWeChatChannelAccountRow(bindings.DB, input); - const database = getAppDatabase(bindings.DB); - const existing = - (await database - .select({ - encryptedContextTokenSecretId: wechatContextTokensTable.encryptedContextTokenSecretId, - }) - .from(wechatContextTokensTable) - .where( - and( - eq(wechatContextTokensTable.accountId, row.id), - eq(wechatContextTokensTable.externalAccountId, input.accountId), - eq(wechatContextTokensTable.peerId, input.peerId), - ), - ) - .limit(1) - .get()) ?? null; - const encryptedContextTokenSecretId = await storeWeChatContextTokenSecret(bindings, { - accountId: row.id, - peerId: input.peerId, - value: input.contextTokenValue, - }); - - try { - await database - .insert(wechatContextTokensTable) - .values({ - accountId: row.id, - contextTokenKey: input.contextTokenKey, - createdAt: input.updatedAtMs, - encryptedContextTokenSecretId, - externalAccountId: input.accountId, - id: createPlatformId(), - peerId: input.peerId, - toUserId: input.toUserId, - updatedAt: input.updatedAtMs, - }) - .onConflictDoUpdate({ - set: { - contextTokenKey: input.contextTokenKey, - encryptedContextTokenSecretId, - toUserId: input.toUserId, - updatedAt: input.updatedAtMs, - }, - target: [ - wechatContextTokensTable.accountId, - wechatContextTokensTable.externalAccountId, - wechatContextTokensTable.peerId, - ], - }) - .run(); - } catch (error) { - await deleteSecret(bindings.DB, encryptedContextTokenSecretId); - throw error; - } - - if (existing) { - await deleteSecret(bindings.DB, existing.encryptedContextTokenSecretId); - } - }, - async writeCursor(input: WeChatPollingOwnerCursorRecord): Promise { - await updateWeChatAccountOrThrow(bindings.DB, { - bindingId: input.bindingId, - externalAccountId: input.accountId, - fields: { - cursor: input.cursor, - updatedAt: input.updatedAtMs, - }, - }); - }, - async writeRuntimeState(input: WeChatPollingOwnerRuntimeStateRecord): Promise { - await updateWeChatAccountOrThrow(bindings.DB, { - bindingId: input.bindingId, - externalAccountId: input.accountId, - fields: { - lastErrorCode: input.snapshot.lastErrorCode, - lastHeartbeatAt: input.snapshot.lastHeartbeatAtMs, - lastInboundAt: input.snapshot.lastInboundAtMs, - lastPollAt: input.snapshot.lastPollAtMs, - runtimeStateJson: input.runtimeStateJson, - status: input.snapshot.status, - statusChangedAt: input.snapshot.statusChangedAtMs, - updatedAt: getSnapshotUpdatedAtMs(input.snapshot), - }, - }); - }, - }; -} diff --git a/apps/api/src/modules/channels/wechat/wechat-polling-owner.ts b/apps/api/src/modules/channels/wechat/wechat-polling-owner.ts deleted file mode 100644 index 6363a1bd..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-polling-owner.ts +++ /dev/null @@ -1,421 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import type { - ChannelConnectionKey, - ChannelConnectionOwnerSnapshot, - ChannelConnectionOwnerStatus, -} from "../application/channel-connection-health"; -import { - normalizeWeChatIlinkWorkTrigger, - parseWeChatIlinkPollEnvelope, - summarizeWeChatPollRuntime, -} from "./wechat-events"; -import type { - WeChatIlinkRawMessage, - WeChatIlinkWorkTrigger, - WeChatPollRuntimeSummary, -} from "./wechat-events"; -import { WeChatIlinkApiError, WeChatIlinkHttpError } from "./wechat-ilink-client"; -import type { WeChatIlinkClient } from "./wechat-ilink-client"; -import type { WeChatReplyRoute } from "./wechat-runtime"; - -const DEFAULT_WECHAT_POLL_TIMEOUT_MS = 35_000; - -export interface WeChatPollingOwnerStore { - readCursor(input: WeChatPollingOwnerStorageKey): Promise; - writeContextToken(input: WeChatPollingOwnerContextTokenRecord): Promise; - writeCursor(input: WeChatPollingOwnerCursorRecord): Promise; - writeRuntimeState?(input: WeChatPollingOwnerRuntimeStateRecord): Promise; -} - -export interface WeChatPollingOwnerStorageKey { - accountId: string; - bindingId: ChannelBindingId; -} - -export interface WeChatPollingOwnerCursorRecord extends WeChatPollingOwnerStorageKey { - cursor: string; - updatedAtMs: number; -} - -export interface WeChatPollingOwnerContextTokenRecord extends WeChatPollingOwnerStorageKey { - contextTokenKey: string; - contextTokenValue: string; - peerId: string; - toUserId: string; - updatedAtMs: number; -} - -export interface WeChatPollingOwnerRuntimeState { - lastProcessedMessageId: string | null; - nextCursor: string | null; - pollTimeoutMs: number; -} - -export interface WeChatPollingOwnerRuntimeStateRecord extends WeChatPollingOwnerStorageKey { - runtimeState: WeChatPollingOwnerRuntimeState; - runtimeStateJson: string; - snapshot: ChannelConnectionOwnerSnapshot; -} - -export interface WeChatPollingRuntimeOwnerOptions { - accountId: string; - bindingId: ChannelBindingId; - botId: string; - client: Pick; - initialCursor?: string | null; - nowMs?: () => number; - onTrigger: (trigger: WeChatIlinkWorkTrigger) => Promise; - pollTimeoutMs?: number; - store: WeChatPollingOwnerStore; -} - -export interface WeChatPollingOwnerPollResult { - droppedMessageCount: number; - nextCursor: string | null; - processedMessageCount: number; - runtimeSummary: WeChatPollRuntimeSummary | null; - status: ChannelConnectionOwnerStatus; -} - -function assertNonEmpty(value: string, label: string): void { - if (!value.trim()) { - throw new Error(`${label} is required.`); - } -} - -function createRuntimeKey(input: WeChatPollingOwnerStorageKey): ChannelConnectionKey { - return { - accountId: input.accountId, - bindingId: input.bindingId, - provider: "wechat", - }; -} - -function createRuntimeStateJson(state: WeChatPollingOwnerRuntimeState): string { - return JSON.stringify(state); -} - -function errorCodeFromUnknown(error: unknown, fallback: string): string { - if (error instanceof WeChatIlinkApiError) { - return error.code; - } - - if (error instanceof WeChatIlinkHttpError) { - return `http_${error.status}`; - } - - if (error instanceof Error && error.name && error.name !== "Error") { - return error.name; - } - - return fallback; -} - -function isTransientHttpStatus(status: number): boolean { - return status === 408 || status === 425 || status === 429 || status >= 500; -} - -function classifyPollingError(error: unknown): { - code: string; - status: ChannelConnectionOwnerStatus; -} { - if (error instanceof WeChatIlinkApiError) { - if (error.code === "missing_bot_token" || error.code === "ilink_-14") { - return { code: error.code, status: "relogin_required" }; - } - - return { code: error.code, status: "failed" }; - } - - if (error instanceof WeChatIlinkHttpError) { - const code = `http_${error.status}`; - - if (error.status === 401 || error.status === 403) { - return { code, status: "relogin_required" }; - } - - if (isTransientHttpStatus(error.status)) { - return { code, status: "reconnecting" }; - } - - return { code, status: "failed" }; - } - - if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { - return { code: "timeout", status: "reconnecting" }; - } - - if (error instanceof TypeError) { - return { code: "network_error", status: "reconnecting" }; - } - - return { code: errorCodeFromUnknown(error, "poll_failed"), status: "failed" }; -} - -export class WeChatPollingRuntimeOwner { - readonly #accountId: string; - readonly #bindingId: ChannelBindingId; - readonly #botId: string; - readonly #client: Pick; - readonly #initialCursor: string | null; - readonly #nowMs: () => number; - readonly #onTrigger: (trigger: WeChatIlinkWorkTrigger) => Promise; - readonly #pollTimeoutMs: number; - readonly #store: WeChatPollingOwnerStore; - #lastProcessedMessageId: string | null = null; - #nextCursor: string | null = null; - #snapshot: ChannelConnectionOwnerSnapshot; - - constructor(options: WeChatPollingRuntimeOwnerOptions) { - assertNonEmpty(options.accountId, "WeChat account id"); - assertNonEmpty(options.bindingId, "WeChat binding id"); - assertNonEmpty(options.botId, "WeChat bot id"); - - const nowMs = options.nowMs ?? Date.now; - const timestampMs = nowMs(); - - this.#accountId = options.accountId; - this.#bindingId = options.bindingId; - this.#botId = options.botId; - this.#client = options.client; - this.#initialCursor = options.initialCursor?.trim() || null; - this.#nowMs = nowMs; - this.#onTrigger = options.onTrigger; - this.#pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_WECHAT_POLL_TIMEOUT_MS; - this.#store = options.store; - this.#snapshot = { - key: createRuntimeKey(options), - lastErrorCode: null, - lastHeartbeatAtMs: null, - lastInboundAtMs: null, - lastPollAtMs: null, - leaseExpiresAtMs: null, - leaseOwnerId: null, - status: "idle", - statusChangedAtMs: timestampMs, - }; - } - - getSnapshot(): ChannelConnectionOwnerSnapshot { - return this.#snapshot; - } - - getRuntimeState(): WeChatPollingOwnerRuntimeState { - return this.#runtimeState(); - } - - async pollOnce(): Promise { - const cursor = (await this.#store.readCursor(this.#storageKey())) ?? this.#initialCursor ?? ""; - this.#nextCursor = cursor || null; - await this.#persist("running", null); - - let body: string; - - try { - body = await this.#client.getUpdates({ - cursor, - timeoutMs: this.#pollTimeoutMs, - }); - } catch (error) { - const failure = classifyPollingError(error); - this.#nextCursor = failure.status === "relogin_required" ? null : cursor || null; - await this.#persist(failure.status, failure.code); - return { - droppedMessageCount: 0, - nextCursor: this.#nextCursor, - processedMessageCount: 0, - runtimeSummary: null, - status: failure.status, - }; - } - - const parsed = parseWeChatIlinkPollEnvelope(body); - const polledAtMs = this.#nowMs(); - - if (!parsed.ok) { - await this.#persist("failed", parsed.code, { lastPollAtMs: polledAtMs }); - return { - droppedMessageCount: 0, - nextCursor: cursor || null, - processedMessageCount: 0, - runtimeSummary: null, - status: "failed", - }; - } - - const runtimeSummary = summarizeWeChatPollRuntime(parsed.envelope); - - if (runtimeSummary.status === "relogin_required") { - this.#nextCursor = null; - await this.#persist("relogin_required", runtimeSummary.reason, { lastPollAtMs: polledAtMs }); - return { - droppedMessageCount: 0, - nextCursor: null, - processedMessageCount: 0, - runtimeSummary, - status: "relogin_required", - }; - } - - if (runtimeSummary.status === "provider_error") { - await this.#persist("reconnecting", runtimeSummary.reason, { lastPollAtMs: polledAtMs }); - return { - droppedMessageCount: 0, - nextCursor: cursor || null, - processedMessageCount: 0, - runtimeSummary, - status: "reconnecting", - }; - } - - return this.#processMessages(parsed.envelope.messages, { - nextCursor: parsed.envelope.nextCursor, - polledAtMs, - runtimeSummary, - }); - } - - async #processMessages( - messages: WeChatIlinkRawMessage[], - input: { - nextCursor: string; - polledAtMs: number; - runtimeSummary: WeChatPollRuntimeSummary; - }, - ): Promise { - let droppedMessageCount = 0; - let processedMessageCount = 0; - let lastInboundAtMs: number | null = null; - - try { - for (const message of messages) { - const trigger = normalizeWeChatIlinkWorkTrigger(message, { - accountId: this.#accountId, - bindingId: this.#bindingId, - botId: this.#botId, - }); - - if (!trigger) { - droppedMessageCount += 1; - continue; - } - - await this.#store.writeContextToken( - this.#contextTokenRecord(trigger.peerId, trigger.replyRoute), - ); - await this.#onTrigger(trigger); - this.#lastProcessedMessageId = trigger.messageId; - processedMessageCount += 1; - lastInboundAtMs = this.#nowMs(); - } - - await this.#store.writeCursor({ - ...this.#storageKey(), - cursor: input.nextCursor, - updatedAtMs: this.#nowMs(), - }); - this.#nextCursor = input.nextCursor; - await this.#persist("running", null, { - lastHeartbeatAtMs: input.polledAtMs, - lastInboundAtMs, - lastPollAtMs: input.polledAtMs, - }); - - return { - droppedMessageCount, - nextCursor: input.nextCursor, - processedMessageCount, - runtimeSummary: input.runtimeSummary, - status: "running", - }; - } catch (error) { - await this.#persist("failed", errorCodeFromUnknown(error, "trigger_dispatch_failed"), { - lastInboundAtMs, - lastPollAtMs: input.polledAtMs, - }); - throw error; - } - } - - async #persist( - status: ChannelConnectionOwnerStatus, - lastErrorCode: string | null, - input: { - lastHeartbeatAtMs?: number | null; - lastInboundAtMs?: number | null; - lastPollAtMs?: number | null; - } = {}, - ): Promise { - this.#transition(status, lastErrorCode, input); - - const runtimeState = this.#runtimeState(); - - await this.#store.writeRuntimeState?.({ - ...this.#storageKey(), - runtimeState, - runtimeStateJson: createRuntimeStateJson(runtimeState), - snapshot: this.#snapshot, - }); - } - - #transition( - status: ChannelConnectionOwnerStatus, - lastErrorCode: string | null, - input: { - lastHeartbeatAtMs?: number | null; - lastInboundAtMs?: number | null; - lastPollAtMs?: number | null; - } = {}, - ): void { - const nowMs = this.#nowMs(); - const statusChangedAtMs = - this.#snapshot.status === status ? this.#snapshot.statusChangedAtMs : nowMs; - - this.#snapshot = { - ...this.#snapshot, - lastErrorCode, - lastHeartbeatAtMs: - input.lastHeartbeatAtMs === undefined - ? this.#snapshot.lastHeartbeatAtMs - : input.lastHeartbeatAtMs, - lastInboundAtMs: - input.lastInboundAtMs === undefined - ? this.#snapshot.lastInboundAtMs - : input.lastInboundAtMs, - lastPollAtMs: - input.lastPollAtMs === undefined ? this.#snapshot.lastPollAtMs : input.lastPollAtMs, - status, - statusChangedAtMs, - }; - } - - #runtimeState(): WeChatPollingOwnerRuntimeState { - return { - lastProcessedMessageId: this.#lastProcessedMessageId, - nextCursor: this.#nextCursor, - pollTimeoutMs: this.#pollTimeoutMs, - }; - } - - #storageKey(): WeChatPollingOwnerStorageKey { - return { - accountId: this.#accountId, - bindingId: this.#bindingId, - }; - } - - #contextTokenRecord( - peerId: string, - route: WeChatReplyRoute, - ): WeChatPollingOwnerContextTokenRecord { - return { - ...this.#storageKey(), - contextTokenKey: route.contextTokenKey, - contextTokenValue: route.contextTokenValue, - peerId, - toUserId: route.toUserId, - updatedAtMs: this.#nowMs(), - }; - } -} diff --git a/apps/api/src/modules/channels/wechat/wechat-reply.service.ts b/apps/api/src/modules/channels/wechat/wechat-reply.service.ts deleted file mode 100644 index 8e9f3cf6..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-reply.service.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createPlatformId, parsePlatformId } from "@mosoo/id"; -import type { ChannelBindingId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { WeChatIlinkClient } from "./wechat-ilink-client"; -import { - readWeChatChannelAccountWithCredentials, - readWeChatContextTokenForPeer, -} from "./wechat-runtime-store"; -import type { WeChatChannelAccount } from "./wechat-runtime-store"; - -export interface SendWeChatStoredContextReplyInput { - accountId: string; - clientId?: string; - fetchImpl?: typeof fetch; - peerId: string; - text: string; -} - -export type WeChatReplyErrorCode = - | "account_not_found" - | "account_not_running" - | "context_token_not_found"; - -export class WeChatReplyError extends Error { - readonly code: WeChatReplyErrorCode; - - constructor(code: WeChatReplyErrorCode, message: string) { - super(message); - this.code = code; - this.name = "WeChatReplyError"; - } -} - -function createWeChatReplyClientId(): string { - return `mosoo-wechat-${createPlatformId()}`; -} - -// Replies are outbound (sendmessage) and only need valid credentials; they must NOT be gated -// on the polling (getUpdates, inbound) runtime status. Transient polling states like `starting` -// / `reconnecting` churn rapidly — especially under a fast scheduled-pump cadence — and a strict -// `status === "running"` check made them surface as spurious `account_not_running` retries that -// delayed reply delivery. Block only states where credentials are genuinely unusable -// (`relogin_required`) or the channel was stopped (`stopped`). -function ensureWeChatAccountCanSendReply(account: WeChatChannelAccount): void { - if (account.status === "relogin_required" || account.status === "stopped") { - throw new WeChatReplyError( - "account_not_running", - `WeChat channel account is "${account.status}"; cannot send reply until it recovers.`, - ); - } -} - -export async function sendWeChatStoredContextReply( - bindings: ApiBindings, - input: SendWeChatStoredContextReplyInput, -): Promise { - const accountId = parsePlatformId(input.accountId, "WeChat account ID"); - const account = await readWeChatChannelAccountWithCredentials(bindings, { - accountId, - }); - - if (!account) { - throw new WeChatReplyError("account_not_found", "WeChat channel account not found."); - } - - ensureWeChatAccountCanSendReply(account.account); - - const contextToken = await readWeChatContextTokenForPeer(bindings, { - accountId, - peerId: input.peerId, - }); - - if (!contextToken) { - throw new WeChatReplyError("context_token_not_found", "WeChat context token not found."); - } - - const client = new WeChatIlinkClient({ - baseUrl: account.credentials.baseUrl, - botToken: account.credentials.botToken, - ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), - }); - - await client.sendText({ - clientId: input.clientId?.trim() || createWeChatReplyClientId(), - contextToken: contextToken.contextToken, - text: input.text, - toUserId: contextToken.toUserId, - }); -} diff --git a/apps/api/src/modules/channels/wechat/wechat-runtime-store.ts b/apps/api/src/modules/channels/wechat/wechat-runtime-store.ts deleted file mode 100644 index 6baf6735..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-runtime-store.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { - agentChannelBindingsTable, - wechatChannelAccountsTable, - wechatContextTokensTable, -} from "@mosoo/db"; -import type { WeChatChannelAccountRow } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, ChannelBindingId, PlatformId, AppId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, runAppDatabaseBatch } from "../../../platform/db/drizzle"; -import { validationError } from "../../../platform/errors"; -import { currentTimestampMs, toIsoString } from "../../../time"; -import { ensureAppAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { deleteSecretsById } from "../../vault/application/vault-secret-store"; -import { - cleanupStoredAgentChannelBindingCredentialSecret, - readAgentChannelBindingCredentialSecret, - storeAgentChannelBindingCredentialSecret, -} from "../application/channel-credential-secret-resolution"; -import { readWeChatContextTokenSecret } from "./wechat-context-token-secret-store"; -import { - normalizeWeChatChannelCredentialsFromSnapshot, - parseWeChatChannelCredentials, - serializeWeChatChannelCredentials, -} from "./wechat-credentials"; -import type { WeChatChannelCredentials } from "./wechat-credentials"; -import type { WeChatQrPairingSnapshot } from "./wechat-runtime"; - -export { createWeChatPollingOwnerDatabaseStore } from "./wechat-polling-owner-store"; - -export interface WeChatChannelAccount { - agentId: AgentId; - baseUrl: string; - createdAt: string; - cursor: string | null; - externalAccountId: string; - externalBotId: string; - id: ChannelBindingId; - lastErrorCode: string | null; - ownerAccountId: AccountId; - appId: AppId; - runtimeStateJson: string; - status: WeChatChannelAccountRow["status"]; - updatedAt: string; -} - -export interface WeChatChannelAccountWithCredentials { - account: WeChatChannelAccount; - credentials: WeChatChannelCredentials; -} - -export interface WeChatContextTokenRecord { - accountId: ChannelBindingId; - contextToken: string; - contextTokenKey: string; - externalAccountId: string; - peerId: string; - toUserId: string; -} - -function toWeChatChannelAccount(row: WeChatChannelAccountRow): WeChatChannelAccount { - return { - agentId: row.agentId, - baseUrl: row.baseUrl, - createdAt: toIsoString(row.createdAt), - cursor: row.cursor, - externalAccountId: row.externalAccountId, - externalBotId: row.externalBotId, - id: row.id, - lastErrorCode: row.lastErrorCode, - ownerAccountId: row.ownerAccountId, - appId: row.appId, - runtimeStateJson: row.runtimeStateJson, - status: row.status, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function buildWeChatBindingDisplayMetadata(input: { - ilinkBotId: string; - ilinkUserId: string; -}): Record { - return { - ilink_bot_id: input.ilinkBotId, - ilink_user_id: input.ilinkUserId, - }; -} - -async function readWeChatChannelAccountRow( - database: D1Database, - input: { accountId: ChannelBindingId }, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(wechatChannelAccountsTable) - .where(eq(wechatChannelAccountsTable.id, input.accountId)) - .limit(1) - .get()) ?? null - ); -} - -async function readWeChatContextTokenSecretIdsForAccount( - database: D1Database, - input: { accountId: ChannelBindingId }, -): Promise { - const rows = await getAppDatabase(database) - .select({ - encryptedContextTokenSecretId: wechatContextTokensTable.encryptedContextTokenSecretId, - }) - .from(wechatContextTokensTable) - .where(eq(wechatContextTokensTable.accountId, input.accountId)) - .all(); - - return rows.map((row) => row.encryptedContextTokenSecretId); -} - -async function cleanupStoredWeChatCredentialSecrets(input: { - agentId: AgentId; - database: D1Database; - purpose: "channel_binding_replace_cleanup" | "channel_binding_write_rollback"; - appId: AppId; - secretIds: readonly PlatformId[]; -}): Promise { - let cleanupSucceeded = true; - - for (const secretId of new Set(input.secretIds)) { - cleanupSucceeded = - (await cleanupStoredAgentChannelBindingCredentialSecret({ - command: { - agentId: input.agentId, - provider: "wechat", - appId: input.appId, - purpose: input.purpose, - secretId, - }, - database: input.database, - })) && cleanupSucceeded; - } - - return cleanupSucceeded; -} - -export async function readWeChatChannelAccountWithCredentials( - bindings: ApiBindings, - input: { accountId: ChannelBindingId }, -): Promise { - const row = await readWeChatChannelAccountRow(bindings.DB, input); - - if (!row) { - return null; - } - - const credentials = parseWeChatChannelCredentials( - await readAgentChannelBindingCredentialSecret(bindings, { - bindingId: row.id, - expectedOwner: { - agentId: row.agentId, - appId: row.appId, - }, - provider: "wechat", - purpose: "channel_context", - secretId: row.encryptedCredsSecretId, - }), - ); - - return { - account: toWeChatChannelAccount(row), - credentials, - }; -} - -export async function persistConfirmedWeChatQrPairing( - bindings: ApiBindings, - viewer: AuthenticatedViewer, - input: { - agentId: AgentId; - appId: AppId; - snapshot: WeChatQrPairingSnapshot; - }, -): Promise { - const viewerId = viewer.id; - const access = await ensureAppAgentOwner(bindings.DB, viewer.id, { - agentId: input.agentId, - appId: input.appId, - }); - - if (access.agent.status !== "published") { - throw validationError("Publish the Agent before connecting WeChat.", "AGENT_NOT_PUBLISHED"); - } - - const credentials = normalizeWeChatChannelCredentialsFromSnapshot(input.snapshot); - const database = getAppDatabase(bindings.DB); - const existingForAgent = - (await database - .select() - .from(wechatChannelAccountsTable) - .where( - and( - eq(wechatChannelAccountsTable.agentId, input.agentId), - eq(wechatChannelAccountsTable.appId, input.appId), - ), - ) - .limit(1) - .get()) ?? null; - const existingBinding = - (await database - .select() - .from(agentChannelBindingsTable) - .where( - and( - eq(agentChannelBindingsTable.agentId, input.agentId), - eq(agentChannelBindingsTable.appId, input.appId), - eq(agentChannelBindingsTable.provider, "wechat"), - ), - ) - .limit(1) - .get()) ?? null; - const existingForRuntime = - (await database - .select({ - agentId: wechatChannelAccountsTable.agentId, - id: wechatChannelAccountsTable.id, - appId: wechatChannelAccountsTable.appId, - }) - .from(wechatChannelAccountsTable) - .where( - and( - eq(wechatChannelAccountsTable.externalAccountId, credentials.ilinkUserId), - eq(wechatChannelAccountsTable.externalBotId, credentials.ilinkBotId), - ), - ) - .limit(1) - .get()) ?? null; - - if ( - existingForRuntime && - (existingForRuntime.agentId !== input.agentId || existingForRuntime.appId !== input.appId) - ) { - throw validationError( - "This WeChat account is already connected to an Agent.", - "WECHAT_ACCOUNT_BOUND", - ); - } - - if (existingForAgent && existingBinding && existingForAgent.id !== existingBinding.id) { - throw validationError( - "WeChat channel binding and runtime account are inconsistent.", - "WECHAT_BINDING_INCONSISTENT", - ); - } - - const nowMs = currentTimestampMs(); - const encryptedCredsSecretId = await storeAgentChannelBindingCredentialSecret(bindings, { - agentId: input.agentId, - credentialsJson: serializeWeChatChannelCredentials(credentials), - provider: "wechat", - appId: input.appId, - purpose: - existingForAgent || existingBinding ? "channel_binding_update" : "channel_binding_create", - }); - const accountId: ChannelBindingId = - existingForAgent?.id ?? existingBinding?.id ?? createPlatformId(); - const runtimeChanged = - existingForAgent !== null && - (existingForAgent.externalAccountId !== credentials.ilinkUserId || - existingForAgent.externalBotId !== credentials.ilinkBotId); - const staleContextTokenSecretIds = runtimeChanged - ? await readWeChatContextTokenSecretIdsForAccount(bindings.DB, { accountId }) - : []; - const staleCredentialSecretIds = [ - existingForAgent?.encryptedCredsSecretId, - existingBinding?.encryptedCredsSecretId, - ].filter( - (secretId): secretId is PlatformId => - typeof secretId === "string" && secretId !== encryptedCredsSecretId, - ); - const displayMetadataJson = JSON.stringify( - buildWeChatBindingDisplayMetadata({ - ilinkBotId: credentials.ilinkBotId, - ilinkUserId: credentials.ilinkUserId, - }), - ); - - try { - await runAppDatabaseBatch(bindings.DB, (db) => { - const upsertBinding = existingBinding - ? db - .update(agentChannelBindingsTable) - .set({ - displayMetadataJson, - encryptedCredsSecretId, - externalBotId: credentials.ilinkBotId, - externalTenantId: credentials.ilinkUserId, - lastErrorCode: null, - appId: input.appId, - status: "active", - updatedAt: nowMs, - }) - .where(eq(agentChannelBindingsTable.id, existingBinding.id)) - : db.insert(agentChannelBindingsTable).values({ - agentId: input.agentId, - createdAt: nowMs, - displayMetadataJson, - encryptedCredsSecretId, - externalBotId: credentials.ilinkBotId, - externalTenantId: credentials.ilinkUserId, - id: accountId, - lastErrorCode: null, - provider: "wechat", - appId: input.appId, - status: "active", - updatedAt: nowMs, - }); - - if (existingForAgent) { - const updateAccount = db - .update(wechatChannelAccountsTable) - .set({ - baseUrl: credentials.baseUrl, - encryptedCredsSecretId, - externalAccountId: credentials.ilinkUserId, - externalBotId: credentials.ilinkBotId, - lastErrorCode: null, - ownerAccountId: viewerId, - appId: input.appId, - status: "idle", - statusChangedAt: nowMs, - updatedAt: nowMs, - }) - .where(eq(wechatChannelAccountsTable.id, existingForAgent.id)); - - if (!runtimeChanged) { - return [updateAccount, upsertBinding]; - } - - return [ - updateAccount, - db - .delete(wechatContextTokensTable) - .where(eq(wechatContextTokensTable.accountId, existingForAgent.id)), - upsertBinding, - ]; - } - - return [ - db.insert(wechatChannelAccountsTable).values({ - agentId: input.agentId, - baseUrl: credentials.baseUrl, - createdAt: nowMs, - cursor: null, - encryptedCredsSecretId, - externalAccountId: credentials.ilinkUserId, - externalBotId: credentials.ilinkBotId, - id: accountId, - lastErrorCode: null, - lastHeartbeatAt: null, - lastInboundAt: null, - lastPollAt: null, - ownerAccountId: viewerId, - appId: input.appId, - runtimeStateJson: "{}", - status: "idle", - statusChangedAt: nowMs, - updatedAt: nowMs, - }), - upsertBinding, - ]; - }); - } catch (error) { - await cleanupStoredWeChatCredentialSecrets({ - agentId: input.agentId, - database: bindings.DB, - purpose: "channel_binding_write_rollback", - appId: input.appId, - secretIds: [encryptedCredsSecretId], - }); - throw error; - } - - await cleanupStoredWeChatCredentialSecrets({ - agentId: input.agentId, - database: bindings.DB, - purpose: "channel_binding_replace_cleanup", - appId: input.appId, - secretIds: staleCredentialSecretIds, - }); - await deleteSecretsById(bindings.DB, staleContextTokenSecretIds); - - const row = await readWeChatChannelAccountRow(bindings.DB, { accountId }); - - if (!row) { - throw new Error("WeChat channel account could not be loaded."); - } - - return toWeChatChannelAccount(row); -} - -export async function deleteWeChatChannelAccountRuntime( - bindings: ApiBindings, - input: { accountId: ChannelBindingId }, -): Promise { - const row = await readWeChatChannelAccountRow(bindings.DB, input); - - if (!row) { - return; - } - - const contextTokenSecretIds = await readWeChatContextTokenSecretIdsForAccount(bindings.DB, { - accountId: row.id, - }); - - await runAppDatabaseBatch(bindings.DB, (db) => [ - db.delete(wechatContextTokensTable).where(eq(wechatContextTokensTable.accountId, row.id)), - db.delete(wechatChannelAccountsTable).where(eq(wechatChannelAccountsTable.id, row.id)), - ]); - await deleteSecretsById(bindings.DB, contextTokenSecretIds); -} - -export async function readWeChatContextTokenForPeer( - bindings: ApiBindings, - input: { - accountId: ChannelBindingId; - peerId: string; - }, -): Promise { - const account = await readWeChatChannelAccountRow(bindings.DB, { accountId: input.accountId }); - - if (!account) { - return null; - } - - const row = - (await getAppDatabase(bindings.DB) - .select() - .from(wechatContextTokensTable) - .where( - and( - eq(wechatContextTokensTable.accountId, account.id), - eq(wechatContextTokensTable.externalAccountId, account.externalAccountId), - eq(wechatContextTokensTable.peerId, input.peerId), - ), - ) - .limit(1) - .get()) ?? null; - - if (!row) { - return null; - } - - return { - accountId: row.accountId, - contextToken: await readWeChatContextTokenSecret(bindings, { - accountId: account.id, - externalAccountId: account.externalAccountId, - peerId: row.peerId, - purpose: "wechat_reply", - secretId: row.encryptedContextTokenSecretId, - }), - contextTokenKey: row.contextTokenKey, - externalAccountId: row.externalAccountId, - peerId: row.peerId, - toUserId: row.toUserId, - }; -} diff --git a/apps/api/src/modules/channels/wechat/wechat-runtime.ts b/apps/api/src/modules/channels/wechat/wechat-runtime.ts deleted file mode 100644 index 50ecfeee..00000000 --- a/apps/api/src/modules/channels/wechat/wechat-runtime.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { ChannelBindingId } from "@mosoo/id"; - -import { normalizeWeChatIlinkBaseUrl } from "./wechat-ilink-base-url"; - -export type WeChatQrPairingStatus = - | "confirmed" - | "expired" - | "failed" - | "idle" - | "qr_pending" - | "scanned"; - -export interface WeChatQrPairingSnapshot { - accountId: string | null; - baseUrl: string | null; - botToken: string | null; - expiresAtMs: number | null; - ilinkBotId: string | null; - ilinkUserId: string | null; - lastErrorCode: string | null; - qrCodeImageSrc: string | null; - qrToken: string | null; - status: WeChatQrPairingStatus; -} - -export interface WeChatIlinkQrStatusResponse { - baseurl?: string; - bot_token?: string; - ilink_bot_id?: string; - ilink_user_id?: string; - status: "confirmed" | "expired" | "scaned" | "wait"; -} - -export interface WeChatContextTokenStoreKeyInput { - accountId: string; - bindingId: ChannelBindingId; - peerId: string; -} - -export interface WeChatReplyRoute { - contextTokenKey: string; - contextTokenValue: string; - toUserId: string; -} - -function normalizeOptionalString(value: string | undefined): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function assertNonEmpty(value: string, label: string): void { - if (!value.trim()) { - throw new Error(`${label} is required.`); - } -} - -function encodeKeyPart(value: string): string { - return encodeURIComponent(value); -} - -export function createWeChatContextTokenStoreKey(input: WeChatContextTokenStoreKeyInput): string { - assertNonEmpty(input.bindingId, "WeChat binding id"); - assertNonEmpty(input.accountId, "WeChat account id"); - assertNonEmpty(input.peerId, "WeChat peer id"); - - return [ - "wechat", - encodeKeyPart(input.bindingId), - encodeKeyPart(input.accountId), - encodeKeyPart(input.peerId), - ].join(":"); -} - -export function applyWeChatQrStatusResponse( - current: WeChatQrPairingSnapshot, - response: WeChatIlinkQrStatusResponse, -): WeChatQrPairingSnapshot { - switch (response.status) { - case "wait": - return { - ...current, - lastErrorCode: null, - status: "qr_pending", - }; - case "scaned": - return { - ...current, - lastErrorCode: null, - status: "scanned", - }; - case "expired": - return { - ...current, - lastErrorCode: "qr_expired", - status: "expired", - }; - case "confirmed": { - const baseUrlValue = normalizeOptionalString(response.baseurl); - const botToken = normalizeOptionalString(response.bot_token); - const ilinkBotId = normalizeOptionalString(response.ilink_bot_id); - const ilinkUserId = normalizeOptionalString(response.ilink_user_id); - - if (!baseUrlValue || !botToken || !ilinkBotId || !ilinkUserId) { - return { - ...current, - lastErrorCode: "confirmed_missing_credentials", - status: "failed", - }; - } - - let baseUrl: string; - - try { - baseUrl = normalizeWeChatIlinkBaseUrl(baseUrlValue); - } catch { - return { - ...current, - lastErrorCode: "confirmed_untrusted_base_url", - status: "failed", - }; - } - - return { - ...current, - accountId: ilinkUserId, - baseUrl, - botToken, - ilinkBotId, - ilinkUserId, - lastErrorCode: null, - status: "confirmed", - }; - } - } -} - -export function createWeChatReplyRoute(input: { - accountId: string; - bindingId: ChannelBindingId; - contextToken: string; - peerId: string; -}): WeChatReplyRoute { - assertNonEmpty(input.contextToken, "WeChat context token"); - - return { - contextTokenKey: createWeChatContextTokenStoreKey({ - accountId: input.accountId, - bindingId: input.bindingId, - peerId: input.peerId, - }), - contextTokenValue: input.contextToken, - toUserId: input.peerId, - }; -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts b/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts deleted file mode 100644 index 218b769f..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * The blocking bound-agent ask endpoint (PM decision #2): a deployed App POSTs - * the injected self-authorizing capability URL with `{ message | input }` and - * gets the Agent's FINAL reply back in one call — no PAT. - * - * Flow: verify the capability token -> re-check the Agent is still published -> - * resolve the App owner account and run as that owner (the App owns the Agent, - * so we build the session from the owner viewer WITHOUT a PAT caller, reusing - * `createAgentSession` + `queueSessionRun`) -> wait (bounded) for the run to - * reach a terminal state -> return the final output text. - */ - -import type { SessionSummary } from "@mosoo/contracts/session"; -import { sessionEventsTable, sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, AppId, SessionId, SessionRunId } from "@mosoo/id"; -import { and, asc, eq } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../platform/db/drizzle"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; -import { - createAgentSession, - queueSessionRun, - SessionRunCreationGuardRejectedError, -} from "../runtime/application/session-run.service"; -import { getActiveSessionRunSummary } from "../runtime/infrastructure/session-runs/session-run-read.repository"; -import { getSessionRunSummary } from "../runtime/infrastructure/session-runs/session-run-store.repository"; -import { getSessionSummaryForCreator } from "../sessions/application/session-summary-query.service"; -import { selectBoundAgentReply, waitForTerminalRun } from "./app-agent-bound-call"; -import type { BoundAgentCallInput } from "./app-agent-bound-call"; -import { - beginBoundAgentCallIdempotency, - bindBoundAgentCallIdempotencyRun, - hashBoundAgentCallIdempotencyBody, - hashBoundAgentCallIdempotencySubject, -} from "./app-agent-bound-idempotency.service"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { - admitDeploymentCapability, - createDeploymentCapabilityRunAdmission, - deploymentCapabilityRateLimitKey, - ensureDeploymentCapabilityAuthorized, -} from "./deployment-capability-caller.service"; -import { enforcePublicApiRateLimit } from "./public-api-rate-limit.service"; -import { readPublicThreadRunFinalOutput } from "./public-thread-events"; -import { cleanupFailedThreadCreation } from "./public-thread-store"; - -const BOUND_AGENT_WAIT_TIMEOUT_MS = 25_000; -const BOUND_AGENT_WAIT_POLL_INTERVAL_MS = 1_000; - -export interface CreateBoundAgentThreadAndWaitRequest { - bindings: ApiBindings; - executionContext: Pick | null; - idempotencyKey?: string | null; - input: BoundAgentCallInput; - requestUrl: string; - token: string; -} - -export interface BoundAgentCallResponse { - reply: string; - runId: SessionRunId; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function loadRecoverableBoundSession(input: { - agentId: AgentId; - appId: AppId; - database: D1Database; - ownerViewer: AuthenticatedViewer; - sessionId: SessionId; -}): Promise { - const ownerId = parsePlatformId(input.ownerViewer.id, "bound Agent owner id"); - const existing = - (await getAppDatabase(input.database) - .select({ id: sessionsTable.id }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.id, input.sessionId), - eq(sessionsTable.agentId, input.agentId), - eq(sessionsTable.appId, input.appId), - eq(sessionsTable.creatorAccountId, ownerId), - ), - ) - .limit(1) - .get()) ?? null; - - if (existing === null) { - return null; - } - - return getSessionSummaryForCreator(input.database, ownerId, { - appId: input.appId, - sessionId: input.sessionId, - }); -} - -async function ensureRecoverableBoundSession(input: { - agentId: AgentId; - appId: AppId; - bindings: ApiBindings; - executionContext: Pick | null; - ownerViewer: AuthenticatedViewer; - sessionId: SessionId; -}): Promise { - const existing = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: input.sessionId, - }); - - if (existing !== null) { - return existing; - } - - try { - return await createAgentSession({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - agentId: input.agentId, - appId: input.appId, - type: "ui", - }, - options: { - accessViewer: input.ownerViewer, - metadata: null, - sessionId: input.sessionId, - }, - viewer: input.ownerViewer, - }); - } catch (error) { - const recovered = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: input.sessionId, - }); - - if (recovered !== null) { - return recovered; - } - - throw error; - } -} - -async function findRecoverableBoundRun( - database: D1Database, - input: { - clientRequestId: string | null; - recoverableRunId?: SessionRunId; - session: SessionSummary; - }, -): Promise<{ runId: SessionRunId; sessionId: SessionId } | null> { - if (input.recoverableRunId !== undefined) { - const runScope = - (await getAppDatabase(database) - .select({ sessionId: sessionRunsTable.sessionId }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.id, input.recoverableRunId)) - .limit(1) - .get()) ?? null; - - if (runScope?.sessionId !== input.session.id) { - throw new Error("Bound Agent idempotency Run is missing or belongs to another Session."); - } - - const run = await getSessionRunSummary(database, input.recoverableRunId); - - if (run === null) { - throw new Error("Bound Agent idempotency Run summary is missing."); - } - - return { runId: run.id, sessionId: input.session.id }; - } - - if (input.clientRequestId !== null) { - const event = - (await getAppDatabase(database) - .select({ runId: sessionEventsTable.runId }) - .from(sessionEventsTable) - .where( - and( - eq(sessionEventsTable.sessionId, input.session.id), - eq(sessionEventsTable.sourceEventId, input.clientRequestId), - ), - ) - .limit(1) - .get()) ?? null; - - if (event?.runId) { - const run = await getSessionRunSummary(database, event.runId); - - if (run !== null) { - return { runId: run.id, sessionId: input.session.id }; - } - } - - // A reserved idempotency key owns a dedicated Session. If the Worker is - // interrupted after the Run insert but before the event receipt or - // reservation binding is durable, the first Run in that Session remains - // the canonical execution even after it becomes terminal. - const firstRun = - (await getAppDatabase(database) - .select({ id: sessionRunsTable.id }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.sessionId, input.session.id)) - .orderBy(asc(sessionRunsTable.id)) - .limit(1) - .get()) ?? null; - - if (firstRun !== null) { - const run = await getSessionRunSummary(database, firstRun.id); - - if (run === null) { - throw new Error("Bound Agent idempotency Run summary is missing."); - } - - return { runId: run.id, sessionId: input.session.id }; - } - } - - const run = - input.session.lastRun ?? (await getActiveSessionRunSummary(database, input.session.id)); - - return run === null ? null : { runId: run.id, sessionId: input.session.id }; -} - -/** - * Create the session and queue the run as the App owner. On failure before the - * run is queued, the half-created session is cleaned up (mirrors the PAT thread - * path). Once queued, the run is left in place for the wait + extraction. - */ -async function startBoundAgentRun(input: { - agentId: AgentId; - appId: AppId; - bindings: ApiBindings; - capability: AppAgentCapabilityClaims; - clientRequestId: string | null; - executionContext: Pick | null; - ownerViewer: AuthenticatedViewer; - prompt: string; - recoverableSessionId?: SessionId; - recoverableRunId?: SessionRunId; - requestUrl: string; -}): Promise<{ runId: SessionRunId; sessionId: SessionId }> { - let createdSessionId: SessionId | null = null; - - try { - const session = - input.recoverableSessionId === undefined - ? await createAgentSession({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - agentId: input.agentId, - appId: input.appId, - type: "ui", - }, - options: { - accessViewer: input.ownerViewer, - metadata: null, - }, - viewer: input.ownerViewer, - }) - : await ensureRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - bindings: input.bindings, - executionContext: input.executionContext, - ownerViewer: input.ownerViewer, - sessionId: input.recoverableSessionId, - }); - createdSessionId = session.id; - - const existingRun = await findRecoverableBoundRun(input.bindings.DB, { - clientRequestId: input.clientRequestId, - ...(input.recoverableRunId === undefined ? {} : { recoverableRunId: input.recoverableRunId }), - session, - }); - - if (existingRun !== null) { - return existingRun; - } - - try { - const queued = await queueSessionRun({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - accessViewer: input.ownerViewer, - attachmentIds: [], - ...createDeploymentCapabilityRunAdmission(input.capability), - clientRequestId: input.clientRequestId, - prompt: input.prompt, - session: { - agent_id: session.agentId, - deployment_version_id: session.deploymentVersionId, - deployment_version_number: session.deploymentVersionNumber, - id: session.id, - model: session.model, - app_id: session.appId, - provider: session.provider, - runtime_id: session.runtimeId, - }, - }, - requestUrl: input.requestUrl, - viewer: input.ownerViewer, - }); - - return { runId: queued.run.id, sessionId: session.id }; - } catch (error) { - const recoveredSession = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: session.id, - }); - const recoveredRun = - recoveredSession === null - ? null - : await findRecoverableBoundRun(input.bindings.DB, { - clientRequestId: input.clientRequestId, - ...(input.recoverableRunId === undefined - ? {} - : { recoverableRunId: input.recoverableRunId }), - session: recoveredSession, - }); - - if (recoveredRun !== null) { - return recoveredRun; - } - - throw error; - } - } catch (error) { - if (createdSessionId !== null && input.recoverableSessionId === undefined) { - await cleanupFailedThreadCreation({ - bindings: input.bindings, - fileIds: [], - sessionId: createdSessionId, - }).catch((cleanupError: unknown) => { - logError("public-api.bound_agent_call.cleanup_failed", { - ...createErrorLogContext(cleanupError), - sessionId: createdSessionId, - }); - }); - } - - throw error; - } -} - -export async function createBoundAgentThreadAndWait( - request: CreateBoundAgentThreadAndWaitRequest, -): Promise { - const { agent, claims, ownerViewer } = await admitDeploymentCapability( - request.bindings, - request.token, - Date.now(), - ); - - await enforcePublicApiRateLimit(request.bindings.DB, deploymentCapabilityRateLimitKey(claims)); - - const idempotency = - request.idempotencyKey === null || request.idempotencyKey === undefined - ? null - : await beginBoundAgentCallIdempotency(request.bindings.DB, { - bodyHash: await hashBoundAgentCallIdempotencyBody(request.input.message), - idempotencyKey: request.idempotencyKey, - subjectHash: await hashBoundAgentCallIdempotencySubject(claims), - }); - - let startedRun: { runId: SessionRunId; sessionId: SessionId }; - - try { - startedRun = await startBoundAgentRun({ - agentId: agent.id, - appId: agent.appId, - bindings: request.bindings, - capability: claims, - clientRequestId: idempotency?.reservationId ?? null, - executionContext: request.executionContext, - ownerViewer, - prompt: request.input.message, - ...(idempotency === null ? {} : { recoverableSessionId: idempotency.sessionId }), - ...(idempotency?.runId === null || idempotency?.runId === undefined - ? {} - : { recoverableRunId: idempotency.runId }), - requestUrl: request.requestUrl, - }); - } catch (error) { - if (!(error instanceof SessionRunCreationGuardRejectedError)) { - throw error; - } - - // Surface the current revocation reason when the Run insert lost the race. - await ensureDeploymentCapabilityAuthorized(request.bindings.DB, claims); - throw error; - } - - const { runId, sessionId } = startedRun; - - if (idempotency !== null) { - await bindBoundAgentCallIdempotencyRun(request.bindings.DB, { - reservationId: idempotency.reservationId, - runId, - sessionId, - }); - } - - const terminalRun = await waitForTerminalRun( - { - delay, - now: () => Date.now(), - readRun: () => getSessionRunSummary(request.bindings.DB, runId), - }, - { - pollIntervalMs: BOUND_AGENT_WAIT_POLL_INTERVAL_MS, - timeoutMs: BOUND_AGENT_WAIT_TIMEOUT_MS, - }, - ); - - const finalOutput = - terminalRun.status === "completed" - ? await readPublicThreadRunFinalOutput({ - database: request.bindings.DB, - runId, - sessionId, - }) - : null; - - const { reply } = selectBoundAgentReply({ finalOutput, run: terminalRun }); - - return { reply, runId }; -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-call.ts b/apps/api/src/modules/public-api/app-agent-bound-call.ts deleted file mode 100644 index 5e38c7ad..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-call.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Pure / stack-free pieces of the bound-agent ask flow. Everything here is unit - * testable without the Worker runtime, the DB, or the session runtime: capability - * verification, the still-published guard, request-body parsing, the bounded - * server-side wait for a terminal run, and final-output extraction. The - * orchestration that wires these to the DB + session runtime lives in - * `app-agent-bound-ask.service.ts`. - */ - -import { PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH } from "@mosoo/contracts/public-api"; -import type { PublicThreadFinalOutput } from "@mosoo/contracts/public-api"; -import type { RunError, SessionRunStatus } from "@mosoo/contracts/session-run"; - -import type { AgentRow } from "../agents/application/agent-types"; -import { - boundAgentCallTimeout, - boundAgentFinalOutputMissing, - boundAgentNeedsInput, - boundAgentRunFailed, -} from "./app-agent-bound-errors"; -import { inspectAppAgentCapabilityToken } from "./app-agent-capability"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import type { AppAgentCapabilityTokenVerification } from "./app-agent-capability"; -import { - publicAgentNotExposed, - publicInvalidRequest, - publicUnauthenticated, -} from "./public-api-errors"; - -export interface BoundAgentCallInput { - message: string; -} - -export type BoundAgentServabilityFailure = "agent_mismatched" | "agent_unpublished"; - -function readBoundAgentMessage(value: unknown): string { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw publicInvalidRequest("Request body must be a JSON object."); - } - - const record = value as Record; - // Prefer a non-empty `message`; otherwise fall back to `input` (so an empty - // `message` alongside a real `input` is not rejected). - const messageField = record["message"]; - const raw = - typeof messageField === "string" && messageField.trim().length > 0 - ? messageField - : record["input"]; - - if (typeof raw !== "string") { - throw publicInvalidRequest("A non-empty `message` string is required."); - } - - const message = raw.trim(); - - if (message.length === 0) { - throw publicInvalidRequest("A non-empty `message` string is required."); - } - - if (message.length > PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH) { - throw publicInvalidRequest( - `\`message\` must be at most ${PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH} characters.`, - ); - } - - return message; -} - -export function parseBoundAgentCallBody(body: unknown): BoundAgentCallInput { - return { message: readBoundAgentMessage(body) }; -} - -/** - * Verify the capability token carried in the URL. Rejects (401) when the token - * is malformed, signed with a different secret, or expired. - */ -export async function verifyBoundAgentCapability( - secret: string, - token: string, - nowMs: number, -): Promise { - const verification = await inspectBoundAgentCapability(secret, token, nowMs); - - if (verification.status !== "valid") { - throw publicUnauthenticated("The capability URL is invalid or has expired."); - } - - return verification.claims; -} - -export async function inspectBoundAgentCapability( - secret: string, - token: string, - nowMs: number, -): Promise { - return inspectAppAgentCapabilityToken(secret, token, nowMs); -} - -/** - * Defense in depth against a revoked binding: re-check the Agent is still - * published (the same criterion the deploy-time resolver used) and that it - * belongs to the App the capability was minted for. - */ -export function getBoundAgentServabilityFailure( - agent: AgentRow, - claims: AppAgentCapabilityClaims, -): BoundAgentServabilityFailure | null { - if (agent.appId !== claims.appId || agent.name !== claims.binding.name) { - return "agent_mismatched"; - } - - if (agent.status !== "published" || agent.liveDeploymentVersionId === null) { - return "agent_unpublished"; - } - - return null; -} - -export function ensureBoundAgentServable(agent: AgentRow, claims: AppAgentCapabilityClaims): void { - if (getBoundAgentServabilityFailure(agent, claims) !== null) { - throw publicAgentNotExposed("This Agent is no longer published for bound calls."); - } -} - -export const BOUND_AGENT_TERMINAL_RUN_STATUSES = [ - "completed", - "failed", - "cancelled", - "expired", -] as const; - -export type BoundAgentTerminalRunStatus = (typeof BOUND_AGENT_TERMINAL_RUN_STATUSES)[number]; - -export function isTerminalRunStatus( - status: SessionRunStatus, -): status is BoundAgentTerminalRunStatus { - return (BOUND_AGENT_TERMINAL_RUN_STATUSES as readonly SessionRunStatus[]).includes(status); -} - -// A run parked waiting for interactive input never reaches a terminal state on -// its own; the single-call bound ask cannot answer it, so we stop waiting and -// surface a clear error instead of letting it run out the clock. -const BOUND_AGENT_BLOCKED_RUN_STATUSES = ["waiting_input"] as const; - -export function isBlockedRunStatus(status: SessionRunStatus): boolean { - return (BOUND_AGENT_BLOCKED_RUN_STATUSES as readonly SessionRunStatus[]).includes(status); -} - -export interface BoundAgentRunWaitDeps { - delay: (ms: number) => Promise; - now: () => number; - readRun: () => Promise; -} - -export interface BoundAgentRunWaitOptions { - pollIntervalMs: number; - timeoutMs: number; -} - -/** - * Poll `readRun` until the run reaches a terminal state, then return that run. - * Throws `boundAgentCallTimeout()` once `timeoutMs` elapses. Dependencies are - * injected so the loop is deterministic under test. - */ -export async function waitForTerminalRun( - deps: BoundAgentRunWaitDeps, - options: BoundAgentRunWaitOptions, -): Promise { - const startedAt = deps.now(); - - for (;;) { - const run = await deps.readRun(); - - if (run !== null && isTerminalRunStatus(run.status)) { - return run; - } - - if (run !== null && isBlockedRunStatus(run.status)) { - throw boundAgentNeedsInput(); - } - - if (deps.now() - startedAt >= options.timeoutMs) { - throw boundAgentCallTimeout(); - } - - await deps.delay(options.pollIntervalMs); - } -} - -/** - * Resolve the reply for a terminal run from its canonical final assistant - * message, otherwise surface a typed failure. - */ -export function selectBoundAgentReply(input: { - finalOutput: PublicThreadFinalOutput | null; - run: { error: RunError | null; status: SessionRunStatus }; -}): { reply: string } { - if (input.run.status !== "completed") { - throw boundAgentRunFailed(input.run.status, input.run.error); - } - - if (input.finalOutput === null) { - throw boundAgentFinalOutputMissing(); - } - - return { reply: input.finalOutput.text }; -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-errors.ts b/apps/api/src/modules/public-api/app-agent-bound-errors.ts deleted file mode 100644 index 6159b262..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-errors.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Failure model for the bound-agent ask endpoint (`POST /api/v1/bound/:token`). - * - * The bound endpoint authorizes via a self-authorizing capability URL (no PAT), - * so it does not flow through the PAT `runPublicApi*` route helpers. It renders - * its own error responses here. Cases that already have a public-API meaning - * (invalid capability -> 401, Agent un-published -> 409, bad request -> 400) are - * raised with the existing `PublicApiError` helpers and rendered via - * `toPublicApiError`. Failures unique to a one-call blocking ask — the - * run never reaching a terminal state in time, and a terminal-but-not-completed - * run, and a completed run whose canonical reply is missing — get the - * dedicated codes below. - */ - -import type { RunError, SessionRunStatus } from "@mosoo/contracts/session-run"; - -import { createErrorLogContext, logError } from "../../platform/cloudflare/logger"; -import { publicReadinessBlocked, toPublicApiError } from "./public-api-errors"; - -/** Returned when the bound Agent run does not reach a terminal state in time. */ -export const DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE = "deployment_agent_call_timeout"; -/** Returned when the bound Agent run finished without a successful reply. */ -export const DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE = "deployment_agent_run_failed"; -/** Returned when the bound Agent pauses for interactive input the single-call ask cannot provide. */ -export const DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE = "deployment_agent_needs_input"; -/** Returned when a completed bound Agent run has no canonical final assistant message. */ -export const DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE = - "deployment_agent_final_output_missing"; - -export type BoundAgentCallErrorCode = - | typeof DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE - | typeof DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE - | typeof DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE - | typeof DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE; - -export class BoundAgentCallError extends Error { - readonly code: BoundAgentCallErrorCode; - readonly status: number; - - constructor(input: { code: BoundAgentCallErrorCode; message: string; status: number }) { - super(input.message); - this.name = "BoundAgentCallError"; - this.code = input.code; - this.status = input.status; - } -} - -export function boundAgentCallTimeout(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE, - message: "The bound Agent did not return a final reply before the request timed out.", - status: 504, - }); -} - -export function boundAgentNeedsInput(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE, - message: - "The bound Agent paused for interactive input, which a single-call bound ask cannot provide.", - status: 422, - }); -} - -export function boundAgentFinalOutputMissing(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - message: "The bound Agent run completed without a canonical final reply.", - status: 503, - }); -} - -export function boundAgentRunFailed( - status: SessionRunStatus, - error: RunError | null, -): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE, - message: error?.message ?? `The bound Agent run ended without a reply (${status}).`, - status: 502, - }); -} - -interface BoundAgentCallErrorResponse { - body: { error: { code: string; message: string } }; - status: number; -} - -function errorResponse(code: string, message: string, status: number): BoundAgentCallErrorResponse { - return { body: { error: { code, message } }, status }; -} - -export function renderBoundAgentCallError(error: unknown): BoundAgentCallErrorResponse { - if (error instanceof BoundAgentCallError) { - return errorResponse(error.code, error.message, error.status); - } - - const publicError = toPublicApiError(error); - if (publicError) { - return errorResponse(publicError.code, publicError.message, publicError.status); - } - - if (error instanceof Error && error.message.startsWith("Agent is not ready to run:")) { - const readiness = publicReadinessBlocked(error.message); - return errorResponse(readiness.code, readiness.message, readiness.status); - } - - if (error instanceof SyntaxError) { - return errorResponse("invalid_json", "Request body must be valid JSON.", 400); - } - - logError("public-api.bound_agent_call.failed", createErrorLogContext(error)); - return errorResponse("internal_error", "Bound Agent call failed.", 500); -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts b/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts deleted file mode 100644 index 3d27ae7e..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { boundAgentCallIdempotencyKeysTable } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { PlatformId, SessionId, SessionRunId } from "@mosoo/id"; -import { and, eq, isNull, or } from "drizzle-orm"; - -import { getAppDatabase } from "../../platform/db/drizzle"; -import { currentTimestampMs } from "../../time"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { publicIdempotencyConflict } from "./public-api-errors"; -import { hashPublicApiIdempotencyBody } from "./public-api-idempotency.service"; - -const IDEMPOTENCY_RETRY_AFTER_SECONDS = 2; - -interface BoundAgentCallIdempotencyRow { - bodyHash: string; - id: PlatformId; - runId: SessionRunId | null; - sessionId: SessionId; -} - -export interface BoundAgentCallIdempotencyInput { - bodyHash: string; - idempotencyKey: string; - subjectHash: string; -} - -export interface BoundAgentCallIdempotencyReservation { - reservationId: PlatformId; - runId: SessionRunId | null; - sessionId: SessionId; - status: "existing" | "reserved"; -} - -export async function hashBoundAgentCallIdempotencyBody(message: string): Promise { - const bodyHash = await hashPublicApiIdempotencyBody({ message }); - - if (bodyHash === null) { - throw new Error("Bound Agent idempotency body cannot be empty."); - } - - return bodyHash; -} - -export async function hashBoundAgentCallIdempotencySubject( - claims: AppAgentCapabilityClaims, -): Promise { - const subjectHash = await hashPublicApiIdempotencyBody({ - agentId: claims.agentId, - appId: claims.appId, - binding: { - env: claims.binding.env, - expose: claims.binding.expose, - name: claims.binding.name, - }, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - }); - - if (subjectHash === null) { - throw new Error("Bound Agent idempotency subject cannot be empty."); - } - - return subjectHash; -} - -async function readReservation( - database: D1Database, - input: Pick, -): Promise { - return ( - (await getAppDatabase(database) - .select({ - bodyHash: boundAgentCallIdempotencyKeysTable.bodyHash, - id: boundAgentCallIdempotencyKeysTable.id, - runId: boundAgentCallIdempotencyKeysTable.runId, - sessionId: boundAgentCallIdempotencyKeysTable.sessionId, - }) - .from(boundAgentCallIdempotencyKeysTable) - .where( - and( - eq(boundAgentCallIdempotencyKeysTable.subjectHash, input.subjectHash), - eq(boundAgentCallIdempotencyKeysTable.idempotencyKey, input.idempotencyKey), - ), - ) - .limit(1) - .get()) ?? null - ); -} - -export async function beginBoundAgentCallIdempotency( - database: D1Database, - input: BoundAgentCallIdempotencyInput, -): Promise { - const reservationId = createPlatformId(); - const sessionId = createPlatformId(); - const timestampMs = currentTimestampMs(); - - await getAppDatabase(database) - .insert(boundAgentCallIdempotencyKeysTable) - .values({ - bodyHash: input.bodyHash, - createdAt: timestampMs, - id: reservationId, - idempotencyKey: input.idempotencyKey, - sessionId, - subjectHash: input.subjectHash, - updatedAt: timestampMs, - }) - .onConflictDoNothing() - .run(); - - const current = await readReservation(database, input); - - if (current === null) { - throw publicIdempotencyConflict( - "Idempotency-Key reservation could not be confirmed.", - IDEMPOTENCY_RETRY_AFTER_SECONDS, - ); - } - - if (current.bodyHash !== input.bodyHash) { - throw publicIdempotencyConflict( - "Idempotency-Key was already used for a different request.", - IDEMPOTENCY_RETRY_AFTER_SECONDS, - ); - } - - return { - reservationId: current.id, - runId: current.runId, - sessionId: current.sessionId, - status: current.id === reservationId ? "reserved" : "existing", - }; -} - -export async function bindBoundAgentCallIdempotencyRun( - database: D1Database, - input: { - reservationId: PlatformId; - runId: SessionRunId; - sessionId: SessionId; - }, -): Promise { - await getAppDatabase(database) - .update(boundAgentCallIdempotencyKeysTable) - .set({ - runId: input.runId, - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(boundAgentCallIdempotencyKeysTable.id, input.reservationId), - eq(boundAgentCallIdempotencyKeysTable.sessionId, input.sessionId), - or( - isNull(boundAgentCallIdempotencyKeysTable.runId), - eq(boundAgentCallIdempotencyKeysTable.runId, input.runId), - ), - ), - ) - .run(); - - const current = await getAppDatabase(database) - .select({ runId: boundAgentCallIdempotencyKeysTable.runId }) - .from(boundAgentCallIdempotencyKeysTable) - .where(eq(boundAgentCallIdempotencyKeysTable.id, input.reservationId)) - .limit(1) - .get(); - - if (current?.runId !== input.runId) { - throw new Error("Bound Agent idempotency Run binding could not be confirmed."); - } -} diff --git a/apps/api/src/modules/public-api/app-agent-capability.ts b/apps/api/src/modules/public-api/app-agent-capability.ts deleted file mode 100644 index 35d93d99..00000000 --- a/apps/api/src/modules/public-api/app-agent-capability.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Self-authorizing capability token for a deployed App's bound Agent. - * - * A deployed app reads one injected env var per binding whose value is a URL - * carrying this token. The token encodes the App, Agent, deployment revision, - * and binding that authorized it, and is signed with an HMAC secret, so the - * bound app needs no API key — the URL itself is the grant (PM decision #1, - * docs/prd/app-deployment.md "Agent Binding Wedge"). - * - * The token remains stateless, but the ask endpoint re-checks its deployment - * authority against D1 before starting a Run. Uses Web Crypto so it runs on - * Workers. - */ - -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { isPlatformId } from "@mosoo/id"; - -export type AppAgentCapabilityExpose = "public_thread"; - -export interface AppAgentCapabilityBinding { - env: string; - expose: AppAgentCapabilityExpose; - name: string; -} - -export interface AppAgentCapabilityClaims { - agentId: AgentId; - appId: AppId; - binding: AppAgentCapabilityBinding; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; - /** Absolute expiry, epoch milliseconds. */ - exp: number; -} - -export type AppAgentCapabilityTokenVerification = - | { claims: AppAgentCapabilityClaims; status: "expired" | "valid" } - | { status: "invalid" }; - -const HMAC_PARAMS: HmacKeyGenParams = { hash: "SHA-256", name: "HMAC" }; - -/** Path the deployed app's injected URL points at (the capability ask endpoint). */ -export const APP_AGENT_BOUND_PATH_PREFIX = "/api/v1/bound"; - -/** Strip trailing slashes without a backtracking regex (avoids ReDoS on library input). */ -function stripTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) { - end -= 1; - } - return value.slice(0, end); -} - -/** Build the self-authorizing URL injected as a bound agent's env var. */ -export function boundAgentUrl(apiOrigin: string, token: string): string { - return `${stripTrailingSlashes(apiOrigin)}${APP_AGENT_BOUND_PATH_PREFIX}/${token}`; -} - -function bytesToBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -function base64UrlToBytes(value: string): Uint8Array { - const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); - const binary = atob(normalized); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -async function importSigningKey(secret: string): Promise { - return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), HMAC_PARAMS, false, [ - "sign", - "verify", - ]); -} - -function isCapabilityClaims(value: unknown): value is AppAgentCapabilityClaims { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - const binding = record["binding"]; - - if (typeof binding !== "object" || binding === null || Array.isArray(binding)) { - return false; - } - - const bindingRecord = binding as Record; - return ( - isPlatformId(record["agentId"]) && - isPlatformId(record["appId"]) && - isPlatformId(record["deploymentId"]) && - isPlatformId(record["deploymentRunId"]) && - typeof record["exp"] === "number" && - typeof bindingRecord["env"] === "string" && - bindingRecord["env"].length > 0 && - bindingRecord["expose"] === "public_thread" && - typeof bindingRecord["name"] === "string" && - bindingRecord["name"].length > 0 - ); -} - -/** Mint a signed, URL-safe capability token for a bound agent. */ -export async function mintAppAgentCapabilityToken( - secret: string, - claims: AppAgentCapabilityClaims, -): Promise { - const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(claims))); - const key = await importSigningKey(secret); - const signature = await crypto.subtle.sign( - HMAC_PARAMS.name, - key, - new TextEncoder().encode(payload), - ); - return `${payload}.${bytesToBase64Url(new Uint8Array(signature))}`; -} - -/** - * Verify a capability token and return its claims, or null when the signature is - * invalid, the token is malformed, or it has expired at `nowMs`. - */ -export async function verifyAppAgentCapabilityToken( - secret: string, - token: string, - nowMs: number, -): Promise { - const verification = await inspectAppAgentCapabilityToken(secret, token, nowMs); - - return verification.status === "valid" ? verification.claims : null; -} - -/** - * Verify a token while preserving the single safe diagnostic state: a - * correctly signed capability whose authority has expired. Callers must keep - * invalid capabilities indistinguishable to external clients. - */ -export async function inspectAppAgentCapabilityToken( - secret: string, - token: string, - nowMs: number, -): Promise { - const separator = token.indexOf("."); - if (separator <= 0 || separator === token.length - 1) { - return { status: "invalid" }; - } - const payload = token.slice(0, separator); - const signaturePart = token.slice(separator + 1); - - let signatureValid: boolean; - try { - const key = await importSigningKey(secret); - signatureValid = await crypto.subtle.verify( - HMAC_PARAMS.name, - key, - base64UrlToBytes(signaturePart), - new TextEncoder().encode(payload), - ); - } catch { - return { status: "invalid" }; - } - if (!signatureValid) { - return { status: "invalid" }; - } - - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))); - } catch { - return { status: "invalid" }; - } - if (!isCapabilityClaims(parsed)) { - return { status: "invalid" }; - } - - return { claims: parsed, status: parsed.exp <= nowMs ? "expired" : "valid" }; -} diff --git a/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts b/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts deleted file mode 100644 index e3e6773b..00000000 --- a/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { sessionRunsTable } from "@mosoo/db"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId, SessionRunId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import { getAppDatabase } from "../../platform/db/drizzle"; -import { ensureAppOwnership } from "../apps/application/app.service"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; - -export interface BoundCapabilityRunProvenanceRecord { - agentId: AgentId; - appId: AppId; - bindingEnv: string; - bindingName: string; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; - runId: SessionRunId; -} - -/** - * Returns the immutable, non-secret delegation facts for a Run. App ownership - * is intentionally required because deployment identifiers and binding names - * are operational audit data, not part of the public Thread response. - */ -export async function getBoundCapabilityRunProvenance( - database: D1Database, - viewer: AuthenticatedViewer, - input: { appId: AppId; runId: SessionRunId }, -): Promise { - await ensureAppOwnership(database, viewer.id, input.appId); - - const row = - (await getAppDatabase(database) - .select({ - agentId: sessionRunsTable.boundCapabilityAgentId, - appId: sessionRunsTable.boundCapabilityAppId, - bindingEnv: sessionRunsTable.boundCapabilityBindingEnv, - bindingName: sessionRunsTable.boundCapabilityBindingName, - deploymentId: sessionRunsTable.boundCapabilityDeploymentId, - deploymentRunId: sessionRunsTable.boundCapabilityDeploymentRunId, - runId: sessionRunsTable.id, - }) - .from(sessionRunsTable) - .where( - and( - eq(sessionRunsTable.id, input.runId), - eq(sessionRunsTable.boundCapabilityAppId, input.appId), - ), - ) - .limit(1) - .get()) ?? null; - - if (row === null) { - return null; - } - - if ( - row.agentId === null || - row.appId === null || - row.bindingEnv === null || - row.bindingName === null || - row.deploymentId === null || - row.deploymentRunId === null - ) { - throw new Error("Bound capability Run provenance must be complete when present."); - } - - return { - agentId: row.agentId, - appId: row.appId, - bindingEnv: row.bindingEnv, - bindingName: row.bindingName, - deploymentId: row.deploymentId, - deploymentRunId: row.deploymentRunId, - runId: row.runId, - }; -} diff --git a/apps/api/src/modules/public-api/deployment-capability-caller.service.ts b/apps/api/src/modules/public-api/deployment-capability-caller.service.ts deleted file mode 100644 index 9d69c040..00000000 --- a/apps/api/src/modules/public-api/deployment-capability-caller.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Admission for the deployment-scoped runtime identity a deployed App receives - * through its injected bound Agent capability URL. - * - * Every bound request — the blocking ask and the Public Thread / file routes - * mounted under the same URL — passes through `admitDeploymentCapability` - * first: verify the signed token, re-check that the Agent is still published - * for the App, re-check that the Deployment revision still carries the binding, - * and resolve the App owner the capability acts on behalf of. The result is a - * `PublicApiCaller` whose thread and file admission is narrowed to the App, - * Agent binding, and Deployment named by the claims; the owner's account-wide - * Access Token never reaches deployed code. - */ - -import { logInfo } from "../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import { getAgentRow } from "../agents/application/agent-repository"; -import type { AgentRow } from "../agents/application/agent-types"; -import { - createDeploymentAgentCapabilityRunCreationGuard, - getDeploymentAgentCapabilityAuthority, -} from "../apps/application/app-deployment-capability-authority.service"; -import type { DeploymentAgentCapabilityAuthorityRejection } from "../apps/application/app-deployment-capability-authority.service"; -import { getAppRow } from "../apps/application/app.service"; -import { - getAccountViewer, - toDeploymentCapabilityCredentialSubjectId, -} from "../auth/application/public-api-caller.service"; -import type { DeploymentCapabilityPublicApiCaller } from "../auth/application/public-api-caller.service"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; -import type { BoundCapabilityRunAdmission } from "../runtime/domain/bound-capability-run-provenance"; -import { - getBoundAgentServabilityFailure, - inspectBoundAgentCapability, -} from "./app-agent-bound-call"; -import type { BoundAgentServabilityFailure } from "./app-agent-bound-call"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { publicAgentNotExposed, publicNotFound, publicUnauthenticated } from "./public-api-errors"; - -export interface DeploymentCapabilityAdmission { - agent: AgentRow; - claims: AppAgentCapabilityClaims; - ownerViewer: AuthenticatedViewer; -} - -export type DeploymentCapabilityRejectionReason = - | BoundAgentServabilityFailure - | DeploymentAgentCapabilityAuthorityRejection - | "expired"; - -export const DEPLOYMENT_CAPABILITY_INVALID_MESSAGE = - "The capability URL is invalid or has expired."; -export const DEPLOYMENT_CAPABILITY_AGENT_UNPUBLISHED_MESSAGE = - "This Agent is no longer published for bound calls."; -export const DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE = - "This capability is no longer authorized for the active deployment."; - -export function logDeploymentCapabilityRejection( - claims: AppAgentCapabilityClaims, - reason: DeploymentCapabilityRejectionReason, -): void { - logInfo("public-api.bound_agent_capability.rejected", { - agentId: claims.agentId, - appId: claims.appId, - bindingEnv: claims.binding.env, - bindingName: claims.binding.name, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - reason, - }); -} - -/** - * Re-run the revocable checks (Agent still published for the App, Deployment - * revision still carries the binding) without re-verifying the signature. - * Used both on entry and after a guarded Run insert is rejected, so the - * rejection reason reflects the current D1 state. - */ -export async function ensureDeploymentCapabilityAuthorized( - database: D1Database, - claims: AppAgentCapabilityClaims, -): Promise { - const agent = await getAgentRow(database, claims.agentId); - const agentFailure = getBoundAgentServabilityFailure(agent, claims); - - if (agentFailure !== null) { - logDeploymentCapabilityRejection(claims, agentFailure); - throw publicAgentNotExposed(DEPLOYMENT_CAPABILITY_AGENT_UNPUBLISHED_MESSAGE); - } - - const authority = await getDeploymentAgentCapabilityAuthority(database, claims); - - if (!authority.authorized) { - logDeploymentCapabilityRejection(claims, authority.reason); - throw publicAgentNotExposed(DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE); - } - - return agent; -} - -export async function admitDeploymentCapability( - bindings: ApiBindings, - token: string, - nowMs: number, -): Promise { - const verification = await inspectBoundAgentCapability( - bindings.RUNTIME_ACTION_TOKEN_SECRET, - token, - nowMs, - ); - - if (verification.status !== "valid") { - if (verification.status === "expired") { - logDeploymentCapabilityRejection(verification.claims, "expired"); - } - - throw publicUnauthenticated(DEPLOYMENT_CAPABILITY_INVALID_MESSAGE); - } - - const claims = verification.claims; - const agent = await ensureDeploymentCapabilityAuthorized(bindings.DB, claims); - const app = await getAppRow(bindings.DB, agent.appId); - const ownerViewer = await getAccountViewer(bindings.DB, app.ownerAccountId); - - if (ownerViewer === null) { - throw publicNotFound("App owner account was not found."); - } - - return { agent, claims, ownerViewer }; -} - -export function toDeploymentCapabilityCaller( - admission: DeploymentCapabilityAdmission, -): DeploymentCapabilityPublicApiCaller { - return { - capability: admission.claims, - credentialSubjectId: toDeploymentCapabilityCredentialSubjectId(admission.claims.deploymentId), - kind: "deployment_capability", - viewer: admission.ownerViewer, - }; -} - -/** - * The capability URL is keyless, long-lived, and internet-facing: without a - * limit a single leaked URL could launch unbounded owner-billed runs. Reuse the - * shared public-API limiter keyed on the capability identity, in a dedicated - * `bound:` bucket namespace so it never collides with Access Token ids. - */ -export function deploymentCapabilityRateLimitKey(claims: AppAgentCapabilityClaims): string { - return `bound:${claims.appId}:${claims.agentId}`; -} - -export function createDeploymentCapabilityRunAdmission( - claims: AppAgentCapabilityClaims, -): BoundCapabilityRunAdmission { - return { - boundCapabilityProvenance: { - agentId: claims.agentId, - appId: claims.appId, - bindingEnv: claims.binding.env, - bindingName: claims.binding.name, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - }, - runCreationGuard: createDeploymentAgentCapabilityRunCreationGuard(claims), - }; -} diff --git a/apps/api/src/modules/public-api/public-thread-admission.ts b/apps/api/src/modules/public-api/public-thread-admission.ts index ed2c23b7..793ba71b 100644 --- a/apps/api/src/modules/public-api/public-thread-admission.ts +++ b/apps/api/src/modules/public-api/public-thread-admission.ts @@ -1,32 +1,26 @@ -import type { AccountId, AgentId, AppId, PlatformId } from "@mosoo/id"; +import type { AccountId, AgentId, PersonalAccessTokenId, PlatformId, AppId } from "@mosoo/id"; import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; import { getAccountViewer } from "../auth/application/public-api-caller.service"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { admitAgentApiEndpointCaller } from "./agent-api-endpoint-admission.service"; import { publicNotFound } from "./public-api-errors"; -import { isDeploymentCapabilityCreatedBy } from "./public-thread-metadata"; -import type { - PublicApiThreadCreatedByMetadata, - PublicApiThreadMetadata, -} from "./public-thread-metadata"; export interface ThreadCreationAdmission { accessViewer: AuthenticatedViewer; creatorViewer: AuthenticatedViewer; fileViewer: AuthenticatedViewer; appId: AppId; - createdBy: PublicApiThreadCreatedByMetadata; + tokenId: PersonalAccessTokenId; + tokenLabel: string; } interface ThreadReadSnapshot { - metadata: PublicApiThreadMetadata; row: { creator_account_id: PlatformId; }; session: { agentId: AgentId; - appId: AppId; }; } @@ -43,57 +37,11 @@ async function getOwnerViewer( return viewer; } -/** The `created_by` facts a caller stamps on every Thread it creates. */ -export function toPublicApiThreadCreatedBy( - caller: PublicApiCaller, -): PublicApiThreadCreatedByMetadata { - if (caller.kind === "deployment_capability") { - return { - binding_env: caller.capability.binding.env, - binding_name: caller.capability.binding.name, - deployment_id: caller.capability.deploymentId, - deployment_run_id: caller.capability.deploymentRunId, - kind: "deployment_capability", - }; - } - - return { - token_id: caller.tokenId, - token_label: caller.tokenLabel, - }; -} - -/** - * A deployment capability may only address the Agent its binding declared. - * Any other Agent id — even one the App owner controls — is indistinguishable - * from a missing Agent to the deployed App. - */ -function ensureCapabilityAgent(caller: PublicApiCaller, agentId: AgentId): void { - if (caller.kind === "deployment_capability" && caller.capability.agentId !== agentId) { - throw publicNotFound("Agent not found."); - } -} - function canReadThreadFromOwnership( caller: PublicApiCaller, snapshot: ThreadReadSnapshot, ): boolean { - if (snapshot.row.creator_account_id !== caller.viewer.id) { - return false; - } - - if (caller.kind !== "deployment_capability") { - return true; - } - - const createdBy = snapshot.metadata.created_by; - - return ( - isDeploymentCapabilityCreatedBy(createdBy) && - createdBy.deployment_id === caller.capability.deploymentId && - snapshot.session.agentId === caller.capability.agentId && - snapshot.session.appId === caller.capability.appId - ); + return snapshot.row.creator_account_id === caller.viewer.id; } export async function admitPublicThreadReader( @@ -115,19 +63,13 @@ export async function admitPublicThreadCreator( agentId: AgentId; }, ): Promise { - ensureCapabilityAgent(caller, input.agentId); - const agent = await admitAgentApiEndpointCaller(database, caller.viewer, input.agentId); - - if (caller.kind === "deployment_capability" && agent.appId !== caller.capability.appId) { - throw publicNotFound("Agent not found."); - } - return { accessViewer: await getOwnerViewer(database, agent.ownerId), creatorViewer: caller.viewer, fileViewer: caller.viewer, appId: agent.appId, - createdBy: toPublicApiThreadCreatedBy(caller), + tokenId: caller.tokenId, + tokenLabel: caller.tokenLabel, }; } diff --git a/apps/api/src/modules/public-api/public-thread-api-command.service.ts b/apps/api/src/modules/public-api/public-thread-api-command.service.ts index 77d15fc7..9b60101c 100644 --- a/apps/api/src/modules/public-api/public-thread-api-command.service.ts +++ b/apps/api/src/modules/public-api/public-thread-api-command.service.ts @@ -11,7 +11,6 @@ import { eq } from "drizzle-orm"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../platform/db/drizzle"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { sendAgentSessionEvents } from "../runtime/application/session-run.service"; import { @@ -19,7 +18,6 @@ import { deleteAgentSession, unarchiveAgentSession, } from "../sessions/application/session-lifecycle-mutation.service"; -import { createDeploymentCapabilityRunAdmission } from "./deployment-capability-caller.service"; import { publicNotFound } from "./public-api-errors"; import { toPublicThreadEventBatch, @@ -63,7 +61,7 @@ async function getAccountViewer( export interface SendPublicThreadSessionEventsRequest { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; executionContext: Pick | null; input: PublicThreadApiSendEventsRequest; requestUrl: string; @@ -72,19 +70,19 @@ export interface SendPublicThreadSessionEventsRequest { export interface PublicThreadSessionMutationRequest { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; threadId: PublicThreadId; } export interface UnarchivePublicThreadSessionRequest { - caller: PublicApiCaller; + caller: AuthenticatedViewer; database: D1Database; threadId: PublicThreadId; } async function toAgentSessionEventInput(input: { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; event: PublicThreadEventInput; threadId: PublicThreadId; }): Promise { @@ -134,17 +132,9 @@ export async function sendPublicThreadSessionEvents( appId: admission.session.app_id, sessionId, }, - options: { - accessViewer, - actionAuthorization: "admitted", - // Follow-up Runs started through a deployment capability carry the same - // provenance and D1 revocation fence as the Thread's first Run. - ...(request.caller.kind === "deployment_capability" - ? { boundCapability: createDeploymentCapabilityRunAdmission(request.caller.capability) } - : {}), - }, + options: { accessViewer, actionAuthorization: "admitted" }, requestUrl: request.requestUrl, - viewer: request.caller.viewer, + viewer: request.caller, }); return toPublicThreadEventBatch({ batch, @@ -169,7 +159,7 @@ export async function archivePublicThreadSession( bindings: request.bindings, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } @@ -187,7 +177,7 @@ export async function unarchivePublicThreadSession( database: request.database, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } @@ -205,6 +195,6 @@ export async function deletePublicThreadSession( bindings: request.bindings, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } diff --git a/apps/api/src/modules/public-api/public-thread-create.ts b/apps/api/src/modules/public-api/public-thread-create.ts index c43024a1..db554a8e 100644 --- a/apps/api/src/modules/public-api/public-thread-create.ts +++ b/apps/api/src/modules/public-api/public-thread-create.ts @@ -6,7 +6,6 @@ import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { fileStore } from "../files/application/file-store"; import { createAgentSession, queueSessionRun } from "../runtime/application/session-run.service"; -import { createDeploymentCapabilityRunAdmission } from "./deployment-capability-caller.service"; import { admitPublicThreadCreator } from "./public-thread-admission"; import type { ThreadCreationAdmission } from "./public-thread-admission"; import { toPublicThreadSessionSummary } from "./public-thread-api-presenter"; @@ -62,7 +61,7 @@ export async function createPublicThread( }); let createdSessionId: SessionId | null = null; const metadata = createPublicApiThreadMetadata({ - createdBy: admission.createdBy, + admission, idempotencyKey: request.idempotencyKey, }); @@ -108,19 +107,12 @@ export async function createPublicThread( }); } - // A deployment capability stamps its delegation facts on the Run and - // repeats the Deployment authority condition inside the Run insert, so a - // deletion or revision replacement that commits mid-request cannot create - // an owner-billed Run (docs/prd/app-deployment.md). const queuedRun = await queueSessionRun({ bindings: request.bindings, executionContext: request.executionContext ?? null, input: { accessViewer: admission.accessViewer, attachmentIds: request.input.fileIds, - ...(request.caller.kind === "deployment_capability" - ? createDeploymentCapabilityRunAdmission(request.caller.capability) - : {}), clientRequestId: null, prompt: request.input.inputText, session: { @@ -188,8 +180,8 @@ export async function recoverPublicThreadCreation( }); const snapshot = await findPublicThreadSnapshotByIdempotencyKey(request.bindings.DB, { agentId: request.agentId, - createdBy: admission.createdBy, idempotencyKey: request.idempotencyKey, + tokenId: admission.tokenId, }); if (!snapshot) { diff --git a/apps/api/src/modules/public-api/public-thread-file-api.service.ts b/apps/api/src/modules/public-api/public-thread-file-api.service.ts index 6866b309..e37c215d 100644 --- a/apps/api/src/modules/public-api/public-thread-file-api.service.ts +++ b/apps/api/src/modules/public-api/public-thread-file-api.service.ts @@ -11,6 +11,7 @@ import type { AgentId, AppId, FileId, PublicThreadId, SessionId } from "@mosoo/i import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { FileControlError } from "../files/application/file-control-errors"; import { fileStore } from "../files/application/file-store"; import { publishSessionResourceDelete } from "../sessions/application/session-resource-events.service"; @@ -20,7 +21,7 @@ import { admitPublicSessionCaller } from "./public-thread-session-query.service" async function admitPublicThreadFileAccess( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise<{ appId: AppId; sessionId: SessionId }> { const admission = await admitPublicSessionCaller(bindings.DB, caller, threadId); @@ -76,10 +77,10 @@ function toPublicFile(file: FileEntry | FileRecord): PublicFile { async function admitPublicFileRecord( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { - const file = await fileStore.getRecord(bindings, caller.viewer, fileId); + const file = await fileStore.getRecord(bindings, caller, fileId); if (file.scope.kind === "session") { const threadId = requirePublicThreadFile(file); @@ -87,10 +88,7 @@ async function admitPublicFileRecord( return file; } - // App drafts carry no record of which integration uploaded them. An Access - // Token acts for the whole App; a deployment capability only sees a file once - // it is attached to one of its own Threads. - if (file.scope.kind === "app_draft" && caller.kind === "access_token") { + if (file.scope.kind === "app_draft") { return file; } @@ -99,13 +97,13 @@ async function admitPublicFileRecord( export async function listPublicThreadFiles( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise { const { appId, sessionId } = await admitPublicThreadFileAccess(bindings, caller, threadId); return { files: ( - await fileStore.list(bindings, caller.viewer, { + await fileStore.list(bindings, caller, { appId, sessionId, }) @@ -162,7 +160,7 @@ export async function createPublicAgentFile( export async function retrievePublicFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { const file = await admitPublicFileRecord(bindings, caller, fileId); @@ -173,7 +171,7 @@ export async function retrievePublicFile( export async function claimPublicThreadFiles( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { fileIds: FileId[]; threadId: PublicThreadId; @@ -184,24 +182,19 @@ export async function claimPublicThreadFiles( } const { sessionId } = await admitPublicThreadFileAccess(bindings, caller, input.threadId); - const claimedFiles = await fileStore.claimToSession( - bindings, - caller.viewer, - sessionId, - input.fileIds, - ); + const claimedFiles = await fileStore.claimToSession(bindings, caller, sessionId, input.fileIds); return claimedFiles.map((file) => parsePlatformId(file.id, "File ID")); } export async function deletePublicFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { const file = await admitPublicFileRecord(bindings, caller, fileId); - await fileStore.delete(bindings, caller.viewer, fileId); + await fileStore.delete(bindings, caller, fileId); if (file.scope.kind === "session" && file.scope.id !== null) { await publishSessionResourceDelete({ @@ -214,22 +207,17 @@ export async function deletePublicFile( export async function downloadPublicThreadFileContent( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { disposition: "attachment" | "inline"; fileId: FileId; }, ): Promise { - const file = await fileStore.getRecord(bindings, caller.viewer, input.fileId); + const file = await fileStore.getRecord(bindings, caller, input.fileId); const threadId = requirePublicThreadFile(file); await admitPublicSessionCaller(bindings.DB, caller, threadId); - const response = await fileStore.streamContent( - bindings, - caller.viewer, - input.fileId, - input.disposition, - ); + const response = await fileStore.streamContent(bindings, caller, input.fileId, input.disposition); const headers = new Headers(response.headers); headers.set("Cache-Control", "no-store"); return new Response(response.body, { @@ -241,18 +229,18 @@ export async function downloadPublicThreadFileContent( export async function deletePublicThreadFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { fileId: FileId; threadId: PublicThreadId; }, ): Promise { const { sessionId } = await admitPublicThreadFileAccess(bindings, caller, input.threadId); - const file = await fileStore.getRecord(bindings, caller.viewer, input.fileId); + const file = await fileStore.getRecord(bindings, caller, input.fileId); assertPublicThreadFile(file, sessionId); - await fileStore.delete(bindings, caller.viewer, input.fileId); + await fileStore.delete(bindings, caller, input.fileId); await publishSessionResourceDelete({ bindings, resourceId: input.fileId, diff --git a/apps/api/src/modules/public-api/public-thread-metadata.ts b/apps/api/src/modules/public-api/public-thread-metadata.ts index 39b8fdf5..176079c2 100644 --- a/apps/api/src/modules/public-api/public-thread-metadata.ts +++ b/apps/api/src/modules/public-api/public-thread-metadata.ts @@ -1,40 +1,14 @@ import { parsePlatformId } from "@mosoo/id"; -import type { AppDeploymentId, AppDeploymentRunId, PersonalAccessTokenId } from "@mosoo/id"; - -const ACCESS_TOKEN_CREATED_BY_FIELDS = new Set(["token_id", "token_label"]); -const DEPLOYMENT_CAPABILITY_CREATED_BY_FIELDS = new Set([ - "binding_env", - "binding_name", - "deployment_id", - "deployment_run_id", - "kind", -]); +import type { PersonalAccessTokenId } from "@mosoo/id"; + +const CREATED_BY_FIELDS = new Set(["token_id", "token_label"]); const PUBLIC_API_FIELDS = new Set(["created_by", "idempotency_key", "source"]); -/** Thread created by an owner Access Token (the original Public Thread API caller). */ -export interface PublicApiThreadAccessTokenCreatedByMetadata { +export interface PublicApiThreadCreatedByMetadata { token_id: PersonalAccessTokenId; token_label: string; } -/** - * Thread created by a deployed App through its bound Agent capability. The - * Deployment is the visibility boundary for that identity: a capability only - * reads Threads whose `deployment_id` matches its own claims, so one App's - * deployment can never observe another deployment's or the owner's Threads. - */ -export interface PublicApiThreadDeploymentCapabilityCreatedByMetadata { - binding_env: string; - binding_name: string; - deployment_id: AppDeploymentId; - deployment_run_id: AppDeploymentRunId; - kind: "deployment_capability"; -} - -export type PublicApiThreadCreatedByMetadata = - | PublicApiThreadAccessTokenCreatedByMetadata - | PublicApiThreadDeploymentCapabilityCreatedByMetadata; - export interface PublicApiThreadMetadata { created_by: PublicApiThreadCreatedByMetadata; idempotency_key: string | null; @@ -42,7 +16,10 @@ export interface PublicApiThreadMetadata { } interface PublicApiThreadMetadataInput { - createdBy: PublicApiThreadCreatedByMetadata; + admission: { + tokenId: PersonalAccessTokenId; + tokenLabel: string; + }; idempotencyKey: string | null; } @@ -54,16 +31,8 @@ function hasOnlyFields(value: Record, fields: ReadonlySet fields.has(field)); } -export function isDeploymentCapabilityCreatedBy( - createdBy: PublicApiThreadCreatedByMetadata, -): createdBy is PublicApiThreadDeploymentCapabilityCreatedByMetadata { - return "kind" in createdBy && createdBy.kind === "deployment_capability"; -} - -function readAccessTokenCreatedByMetadata( - value: Record, -): PublicApiThreadAccessTokenCreatedByMetadata | null { - if (!hasOnlyFields(value, ACCESS_TOKEN_CREATED_BY_FIELDS)) { +function readCreatedByMetadata(value: unknown): PublicApiThreadCreatedByMetadata | null { + if (!isRecord(value) || !hasOnlyFields(value, CREATED_BY_FIELDS)) { return null; } @@ -84,62 +53,17 @@ function readAccessTokenCreatedByMetadata( } } -function readDeploymentCapabilityCreatedByMetadata( - value: Record, -): PublicApiThreadDeploymentCapabilityCreatedByMetadata | null { - if (!hasOnlyFields(value, DEPLOYMENT_CAPABILITY_CREATED_BY_FIELDS)) { - return null; - } - - const bindingEnv = value["binding_env"]; - const bindingName = value["binding_name"]; - const deploymentId = value["deployment_id"]; - const deploymentRunId = value["deployment_run_id"]; - - if ( - typeof bindingEnv !== "string" || - bindingEnv.length === 0 || - typeof bindingName !== "string" || - bindingName.length === 0 || - typeof deploymentId !== "string" || - typeof deploymentRunId !== "string" - ) { - return null; - } - - try { - return { - binding_env: bindingEnv, - binding_name: bindingName, - deployment_id: parsePlatformId(deploymentId, "Public API deployment ID"), - deployment_run_id: parsePlatformId( - deploymentRunId, - "Public API deployment run ID", - ), - kind: "deployment_capability", - }; - } catch { - return null; - } -} - -function readCreatedByMetadata(value: unknown): PublicApiThreadCreatedByMetadata | null { - if (!isRecord(value)) { - return null; - } - - // Access Token threads predate the `kind` discriminator; their shape is - // exactly `{ token_id, token_label }` and must keep parsing unchanged. - return value["kind"] === "deployment_capability" - ? readDeploymentCapabilityCreatedByMetadata(value) - : readAccessTokenCreatedByMetadata(value); -} - export function createPublicApiThreadMetadata( input: PublicApiThreadMetadataInput, ): PublicApiThreadMetadata { return { - created_by: input.createdBy, + created_by: { + token_id: parsePlatformId( + input.admission.tokenId, + "Public API token ID", + ), + token_label: input.admission.tokenLabel, + }, idempotency_key: input.idempotencyKey, source: "public_api", }; diff --git a/apps/api/src/modules/public-api/public-thread-session-query.service.ts b/apps/api/src/modules/public-api/public-thread-session-query.service.ts index 610b4246..60de7f7b 100644 --- a/apps/api/src/modules/public-api/public-thread-session-query.service.ts +++ b/apps/api/src/modules/public-api/public-thread-session-query.service.ts @@ -1,13 +1,13 @@ import { PUBLIC_THREAD_API_THREADS_MAX_LIMIT } from "@mosoo/contracts/public-api"; import type { PublicThreadApiListThreadsResponse } from "@mosoo/contracts/public-api"; import { sessionRunsTable, sessionsTable } from "@mosoo/db"; -import type { AgentId, AppId, PublicThreadId, SessionId } from "@mosoo/id"; +import type { AccountId, AgentId, AppId, PublicThreadId, SessionId } from "@mosoo/id"; import type { SQL } from "drizzle-orm"; import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; import { getAppDatabase } from "../../platform/db/drizzle"; import type { AgentRow } from "../agents/application/agent-types"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { buildSessionSummaryFromJoinedRow, sessionSummaryWithLastRunColumns, @@ -36,43 +36,9 @@ interface PublicThreadSessionAdmission { session: PublicThreadSessionRow; } -/** - * Row conditions that bound which public Threads a caller can see. An Access - * Token sees every public-API Thread its account created; a deployment - * capability only sees Threads created through the same Deployment for the - * Agent and App its binding declared. - */ -export function publicThreadCallerScopeConditions(caller: PublicApiCaller): SQL[] { - const conditions: SQL[] = [ - eq(sessionsTable.creatorAccountId, caller.viewer.id), - sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, - ]; - - if (caller.kind === "deployment_capability") { - conditions.push( - eq(sessionsTable.appId, caller.capability.appId), - eq(sessionsTable.agentId, caller.capability.agentId), - sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.kind') = 'deployment_capability'`, - sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.deployment_id') = ${caller.capability.deploymentId}`, - ); - } - - return conditions; -} - -/** - * A deployment capability may only address the Agent its binding declared; - * any other Agent id reads as missing, exactly like an Agent outside the App. - */ -function ensurePublicThreadAgentInScope(caller: PublicApiCaller, agentId: AgentId): void { - if (caller.kind === "deployment_capability" && caller.capability.agentId !== agentId) { - throw publicNotFound("Agent not found."); - } -} - async function getPublicThreadSessionAccess( database: D1Database, - caller: PublicApiCaller, + callerId: AccountId, threadId: PublicThreadId, ): Promise { const sessionId = toBackingSessionId(threadId); @@ -87,7 +53,13 @@ async function getPublicThreadSessionAccess( title: sessionsTable.title, }) .from(sessionsTable) - .where(and(eq(sessionsTable.id, sessionId), ...publicThreadCallerScopeConditions(caller))) + .where( + and( + eq(sessionsTable.id, sessionId), + eq(sessionsTable.creatorAccountId, callerId), + sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, + ), + ) .limit(1) .get()) ?? null; @@ -114,11 +86,11 @@ async function getPublicThreadSessionAccess( export async function admitPublicSessionCaller( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise { - const access = await getPublicThreadSessionAccess(database, caller, threadId); - const agent = await admitAgentApiEndpointCaller(database, caller.viewer, access.row.agent_id); + const access = await getPublicThreadSessionAccess(database, caller.id, threadId); + const agent = await admitAgentApiEndpointCaller(database, caller, access.row.agent_id); if (agent.appId !== access.row.app_id) { throw publicNotFound("Thread not found."); @@ -132,18 +104,18 @@ export async function admitPublicSessionCaller( export async function listAgentApiEndpointThreads( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { agentId: AgentId; archived: boolean | null; }, ): Promise { - ensurePublicThreadAgentInScope(caller, input.agentId); - await admitAgentApiEndpointCaller(database, caller.viewer, input.agentId); + await admitAgentApiEndpointCaller(database, caller, input.agentId); const filters: SQL[] = [ eq(sessionsTable.agentId, input.agentId), - ...publicThreadCallerScopeConditions(caller), + eq(sessionsTable.creatorAccountId, caller.id), + sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, ]; if (input.archived !== null) { diff --git a/apps/api/src/modules/public-api/public-thread-store.ts b/apps/api/src/modules/public-api/public-thread-store.ts index 93549cf2..65d9e742 100644 --- a/apps/api/src/modules/public-api/public-thread-store.ts +++ b/apps/api/src/modules/public-api/public-thread-store.ts @@ -6,9 +6,15 @@ import { sessionsTable, } from "@mosoo/db"; import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, FileId, PublicThreadId, SessionId } from "@mosoo/id"; +import type { + AccountId, + AgentId, + FileId, + PersonalAccessTokenId, + PublicThreadId, + SessionId, +} from "@mosoo/id"; import { and, eq, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../platform/db/drizzle"; @@ -22,14 +28,8 @@ import type { SessionSummaryWithLastRunRow } from "../sessions/application/sessi import { deriveSessionTitleFromPrompt } from "../sessions/domain/session-title"; import { publicNotFound } from "./public-api-errors"; import { toBackingSessionId } from "./public-thread-ids"; -import { - isDeploymentCapabilityCreatedBy, - parsePublicApiThreadMetadata, -} from "./public-thread-metadata"; -import type { - PublicApiThreadCreatedByMetadata, - PublicApiThreadMetadata, -} from "./public-thread-metadata"; +import { parsePublicApiThreadMetadata } from "./public-thread-metadata"; +import type { PublicApiThreadMetadata } from "./public-thread-metadata"; export interface ThreadSnapshotRow extends SessionSummaryWithLastRunRow { creator_account_id: AccountId; @@ -118,27 +118,12 @@ export async function getThreadSnapshot( }; } -/** - * SQL condition selecting Threads stamped with the same creator identity. An - * Access Token owns its idempotency space; a deployment capability shares one - * space across the revisions of its Deployment so a retried create after a - * redeploy still replays instead of duplicating the Thread. - */ -export function publicThreadCreatedByCondition(createdBy: PublicApiThreadCreatedByMetadata): SQL { - if (isDeploymentCapabilityCreatedBy(createdBy)) { - return sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.kind') = 'deployment_capability' - AND json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.deployment_id') = ${createdBy.deployment_id}`; - } - - return sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.token_id') = ${createdBy.token_id}`; -} - export async function findPublicThreadSnapshotByIdempotencyKey( database: D1Database, input: { agentId: AgentId; - createdBy: PublicApiThreadCreatedByMetadata; idempotencyKey: string; + tokenId: PersonalAccessTokenId; }, ): Promise { const row = @@ -155,7 +140,7 @@ export async function findPublicThreadSnapshotByIdempotencyKey( and( eq(sessionsTable.agentId, input.agentId), sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, - publicThreadCreatedByCondition(input.createdBy), + sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.token_id') = ${input.tokenId}`, sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.idempotency_key') = ${input.idempotencyKey}`, ), ) diff --git a/apps/api/src/modules/runtime/application/owner-debug-terminal.service.ts b/apps/api/src/modules/runtime/application/owner-debug-terminal.service.ts deleted file mode 100644 index 05cc9160..00000000 --- a/apps/api/src/modules/runtime/application/owner-debug-terminal.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { PtyOptions } from "@cloudflare/sandbox"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId } from "@mosoo/id"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { API_ERROR_CODE, ApiError, createApiError } from "../../../platform/errors"; -import { ensureAgentOwner } from "../../agents/application/agent-access.service"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { getRuntimeKindPolicy } from "../domain/runtime-kind-policy"; -import { resolveStableAgentRuntimeSubject } from "../domain/runtime-sandbox-subject"; -import { connectPreparedSandboxTerminal } from "../infrastructure/execution-plane/sandbox-execution-plane-adapter"; -import { createRuntimeSubjectLifecycleService } from "../infrastructure/runtime-subject-lifecycle/runtime-subject-lifecycle.service"; -import { ensureRuntimeSubjectId } from "../infrastructure/runtime-subject-lifecycle/runtime-subject-store"; - -const DEFAULT_OWNER_DEBUG_TERMINAL_OPTIONS: PtyOptions = { cols: 120, rows: 32 }; - -function ensureOwnerDebugTerminalWebSocketRequest(request: Request): void { - if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { - throw new ApiError(426, "WEBSOCKET_REQUIRED", "Expected WebSocket upgrade."); - } -} - -function getOwnerDebugTerminalSessionId(input: { agentId: AgentId; viewerId: AccountId }): string { - return `owner-debug-${input.viewerId}-${input.agentId}`; -} - -async function resolveOwnerDebugTerminalTarget( - database: D1Database, - input: { - agentId: AgentId; - viewerId: AccountId; - }, -) { - const agent = await ensureAgentOwner(database, input.viewerId, input.agentId); - const policy = getRuntimeKindPolicy(agent.kind); - - if (policy.operations.terminalTarget !== "stable_subject") { - throw createApiError( - API_ERROR_CODE.ownerDebugTerminalUnavailable, - "Owner debug terminal is only available for Pet agents.", - ); - } - - const subject = resolveStableAgentRuntimeSubject({ - agentId: agent.id, - kind: agent.kind, - }); - - return { - agentId: agent.id, - appId: agent.appId, - executionOwnerUserId: agent.ownerId, - kind: agent.kind, - runtimeSubjectId: await ensureRuntimeSubjectId(database, { - ...subject, - agentId: agent.id, - appId: agent.appId, - executionOwnerUserId: agent.ownerId, - }), - subjectId: subject.subjectId, - subjectKind: subject.subjectKind, - terminalSessionId: getOwnerDebugTerminalSessionId({ - agentId: agent.id, - viewerId: input.viewerId, - }), - }; -} - -export async function connectOwnerDebugTerminalWebSocket( - bindings: ApiBindings, - input: { - agentId: string; - executionContext: Pick; - request: Request; - viewer: AuthenticatedViewer; - }, - options: PtyOptions = DEFAULT_OWNER_DEBUG_TERMINAL_OPTIONS, -): Promise { - ensureOwnerDebugTerminalWebSocketRequest(input.request); - const agentId = parsePlatformId(input.agentId, "Owner debug terminal agent ID"); - - const target = await resolveOwnerDebugTerminalTarget(bindings.DB, { - agentId, - viewerId: input.viewer.id, - }); - const activation = await createRuntimeSubjectLifecycleService(bindings).activate({ - ...target, - networkConstraints: { allowedHosts: [], networkPolicy: "full" }, - }); - - return connectPreparedSandboxTerminal(activation.subject, { - options, - request: input.request, - terminalSessionId: target.terminalSessionId, - }); -} diff --git a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts index af1fbdef..1bfe4391 100644 --- a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts @@ -55,18 +55,10 @@ export interface ChannelSessionTriggeredByMetadata { export interface AgentSessionMetadata { public_api?: { - created_by: - | { - token_id: string; - token_label: string; - } - | { - binding_env: string; - binding_name: string; - deployment_id: string; - deployment_run_id: string; - kind: "deployment_capability"; - }; + created_by: { + token_id: string; + token_label: string; + }; idempotency_key: string | null; source: "public_api"; }; diff --git a/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts b/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts index 2e45a8a7..97f4004c 100644 --- a/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts @@ -27,7 +27,6 @@ import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth. import { resolveReadyEnvironmentPackageArtifact } from "../../../environments/application/environment-package-artifact.service"; import { fileStore } from "../../../files/application/file-store"; import { publishPersistedSessionRuntimeEvents } from "../../../sessions/application/session-event-write.service"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { getSupportedRuntimeId } from "../../domain/runtime-config"; import { commitQueuedSessionRunAdmission, @@ -55,7 +54,6 @@ class SessionActiveRunExistsError extends Error { interface QueueSessionRunInput { accessViewer?: AuthenticatedViewer; attachmentIds: FileId[]; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; clientRequestId: string | null; prompt: string; runCreationGuard?: SQL; @@ -187,9 +185,6 @@ export async function queueSessionRun(request: QueueSessionRunRequest): Promise< }, run: { agentId: input.session.agent_id, - ...(input.boundCapabilityProvenance === undefined - ? {} - : { boundCapabilityProvenance: input.boundCapabilityProvenance }), createdBy: viewerId, deploymentVersionId: input.session.deployment_version_id, deploymentVersionNumber: input.session.deployment_version_number, diff --git a/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts b/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts index b01c269a..da92b0cc 100644 --- a/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts @@ -23,7 +23,6 @@ import type { SessionActionAuthorization } from "../../../sessions/domain/sessio import { resolveSessionActionCreatorFlag } from "../../../sessions/domain/session-access.policy"; import { toSessionLifecycleStatusForRunStatus } from "../../../sessions/domain/session-lifecycle"; import { deriveSessionTitleFromPrompt } from "../../../sessions/domain/session-title"; -import type { BoundCapabilityRunAdmission } from "../../domain/bound-capability-run-provenance"; import { getActiveSessionRunId } from "../../infrastructure/session-runs/session-run-store.repository"; import { cancelRun } from "./cancel-run.service"; import type { QueuedSessionRunState } from "./queue-run.service"; @@ -39,7 +38,6 @@ interface SendAgentSessionEventsInput { interface AgentSessionEventsOptions { accessViewer?: AuthenticatedViewer; actionAuthorization?: SessionActionAuthorization; - boundCapability?: BoundCapabilityRunAdmission; cachedState?: SessionLiveState | null; } diff --git a/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts b/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts index b3e45987..8e0e200e 100644 --- a/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts @@ -5,7 +5,6 @@ import type { AccountId, FileId, AppId, SessionId } from "@mosoo/id"; import type { ApiBindings } from "../../../../platform/cloudflare/worker-types"; import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth.service"; import { getActiveAppSessionQueueAccess } from "../../../sessions/domain/session-access.policy"; -import type { BoundCapabilityRunAdmission } from "../../domain/bound-capability-run-provenance"; import { queueSessionRun } from "./queue-run.service"; import type { QueuedSessionRunState } from "./queue-run.service"; @@ -32,7 +31,6 @@ export interface QueueSessionRunsOutput { export interface StartRunsOptions { accessViewer?: AuthenticatedViewer; - boundCapability?: BoundCapabilityRunAdmission; } export interface StartRunsRequest { @@ -88,7 +86,6 @@ async function queueRunRequest( prompt, session, ...(context.options.accessViewer ? { accessViewer: context.options.accessViewer } : {}), - ...context.options.boundCapability, }, requestUrl: context.requestUrl, viewer: context.viewer, diff --git a/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts b/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts deleted file mode 100644 index ee7d41a8..00000000 --- a/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import type { SQL } from "drizzle-orm"; - -/** - * Immutable authorization facts captured when a bound Agent capability accepts - * a Run. The raw URL and signed token are deliberately excluded. - */ -export interface BoundCapabilityRunProvenance { - agentId: AgentId; - appId: AppId; - bindingEnv: string; - bindingName: string; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; -} - -/** - * What a bound capability attaches to every Run it starts: the provenance - * recorded on the Run row plus the D1 authority condition repeated inside the - * Run insert, so a deletion or successful revision replacement that commits - * mid-request cannot create an owner-billed Run. - */ -export interface BoundCapabilityRunAdmission { - boundCapabilityProvenance: BoundCapabilityRunProvenance; - runCreationGuard: SQL; -} diff --git a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts index d00162aa..30a41ef6 100644 --- a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts +++ b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts @@ -30,13 +30,11 @@ import { import type { AppDatabase } from "../../../../platform/db/drizzle"; import type { PreparedApiCommand } from "../../../api-command/application/api-command-ledger"; import { createSessionRuntimeEventProjection } from "../../../sessions/domain/session-runtime-event-projection"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { ACTIVE_SESSION_RUN_STATUSES } from "../../domain/session-run-lifecycle.machine"; import { createSessionStatusTransitionPatch } from "./session-lifecycle-projection.repository"; interface QueuedRunAdmissionRecord { agentId: AgentId; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; createdBy: AccountId; deploymentVersionId: AgentDeploymentVersionId | null; deploymentVersionNumber: number | null; @@ -238,30 +236,6 @@ function createRunInsertQuery(db: AppDatabase, input: CommitQueuedSessionRunAdmi db .select({ agentId: selectedValue(input.run.agentId, "agent_id"), - boundCapabilityAgentId: selectedValue( - input.run.boundCapabilityProvenance?.agentId ?? null, - "bound_capability_agent_id", - ), - boundCapabilityAppId: selectedValue( - input.run.boundCapabilityProvenance?.appId ?? null, - "bound_capability_app_id", - ), - boundCapabilityBindingEnv: selectedValue( - input.run.boundCapabilityProvenance?.bindingEnv ?? null, - "bound_capability_binding_env", - ), - boundCapabilityBindingName: selectedValue( - input.run.boundCapabilityProvenance?.bindingName ?? null, - "bound_capability_binding_name", - ), - boundCapabilityDeploymentId: selectedValue( - input.run.boundCapabilityProvenance?.deploymentId ?? null, - "bound_capability_deployment_id", - ), - boundCapabilityDeploymentRunId: selectedValue( - input.run.boundCapabilityProvenance?.deploymentRunId ?? null, - "bound_capability_deployment_run_id", - ), completedAt: selectedValue(null, "completed_at"), createdAt: selectedValue(input.run.timestampMs, "created_at"), createdByAccountId: selectedValue(input.run.createdBy, "created_by_account_id"), diff --git a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts index ce1e4c3f..652d59a1 100644 --- a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts +++ b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts @@ -28,7 +28,6 @@ import { } from "../../../../platform/db/drizzle"; import { currentTimestampMs, toIsoString } from "../../../../time"; import { toSessionLifecycleStatusForRunStatus } from "../../../sessions/domain/session-lifecycle"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { ACTIVE_SESSION_RUN_STATUSES, decideSessionRunTransition, @@ -340,7 +339,6 @@ export async function createSessionRunRecordIfSessionIdle( database: D1Database, input: { agentId: AgentId; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; createdBy: AccountId; deploymentVersionId?: AgentDeploymentVersionId | null; deploymentVersionNumber?: number | null; @@ -378,12 +376,6 @@ export async function createSessionRunRecordIfSessionIdle( trigger, status, agent_id, - bound_capability_agent_id, - bound_capability_app_id, - bound_capability_binding_env, - bound_capability_binding_name, - bound_capability_deployment_id, - bound_capability_deployment_run_id, deployment_version_id, deployment_version_number, runtime_id, @@ -410,12 +402,6 @@ export async function createSessionRunRecordIfSessionIdle( ${input.trigger}, ${input.status}, ${input.agentId}, - ${input.boundCapabilityProvenance?.agentId ?? null}, - ${input.boundCapabilityProvenance?.appId ?? null}, - ${input.boundCapabilityProvenance?.bindingEnv ?? null}, - ${input.boundCapabilityProvenance?.bindingName ?? null}, - ${input.boundCapabilityProvenance?.deploymentId ?? null}, - ${input.boundCapabilityProvenance?.deploymentRunId ?? null}, ${input.deploymentVersionId ?? null}, ${input.deploymentVersionNumber ?? null}, ${input.runtimeId ?? null}, diff --git a/apps/api/src/modules/sessions/graphql/session-graphql.ts b/apps/api/src/modules/sessions/graphql/session-graphql.ts index a02e61d8..11843b57 100644 --- a/apps/api/src/modules/sessions/graphql/session-graphql.ts +++ b/apps/api/src/modules/sessions/graphql/session-graphql.ts @@ -1,9 +1,8 @@ import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppId, SessionId, SessionRunId } from "@mosoo/id"; +import type { AgentId, AppId, SessionId } from "@mosoo/id"; import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; import { sessionGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { getBoundCapabilityRunProvenance } from "../../public-api/bound-capability-run-provenance.service"; import { createAgentSession, sendAgentSessionEvents, @@ -39,11 +38,6 @@ interface SessionArgs { sessionId: string; } -interface BoundCapabilityRunProvenanceArgs { - appId: string; - runId: string; -} - interface SessionProcessEventsArgs extends SessionArgs { limit?: number | null; } @@ -109,10 +103,6 @@ function readSessionId(value: string): SessionId { return parsePlatformId(value, "Session ID"); } -function readSessionRunId(value: string): SessionRunId { - return parsePlatformId(value, "Session Run ID"); -} - export const sessionGraphQLModule = { ...sessionGraphQLSpec, authenticatedMutationResolvers: { @@ -206,15 +196,6 @@ export const sessionGraphQLModule = { appId: readAppId(args.appId), sessionId: readSessionId(args.sessionId), }), - boundCapabilityRunProvenance: async ( - _parent, - args: BoundCapabilityRunProvenanceArgs, - context, - ) => - getBoundCapabilityRunProvenance(context.bindings.DB, context.viewer, { - appId: readAppId(args.appId), - runId: readSessionRunId(args.runId), - }), agentSessionList: async (_parent, args: AgentSessionListArgs, context) => listAgentSessions(context.bindings.DB, context.viewer, { agentId: readAgentId(args.agentId), diff --git a/apps/api/src/platform/cloudflare/create-api-worker.ts b/apps/api/src/platform/cloudflare/create-api-worker.ts index 66caad6e..0d4ad3ed 100644 --- a/apps/api/src/platform/cloudflare/create-api-worker.ts +++ b/apps/api/src/platform/cloudflare/create-api-worker.ts @@ -7,7 +7,6 @@ import { processApiCommandDeadLetterMessage, processApiCommandMessage, } from "../../modules/api-command/application/api-command-processor"; -import type { ChannelFinalDeliveryMessage } from "../../modules/channels/application/channel-final-delivery-message"; import type { ApiBindings } from "./worker-types"; interface ApiHttpApp { @@ -75,16 +74,6 @@ export function createApiWorker(): ExportedHandler { return; } - - if (isQueue("channel-final-delivery")) { - const { processChannelFinalDeliveryMessage } = - await import("../../modules/channels/application/channel-final-delivery.service"); - const channelBatch = batch as MessageBatch; - - for (const message of channelBatch.messages) { - await processChannelFinalDeliveryMessage(env, message); - } - } }, } satisfies ExportedHandler; } diff --git a/apps/api/src/platform/cloudflare/worker-types.ts b/apps/api/src/platform/cloudflare/worker-types.ts index f7737d19..e429ce75 100644 --- a/apps/api/src/platform/cloudflare/worker-types.ts +++ b/apps/api/src/platform/cloudflare/worker-types.ts @@ -1,13 +1,7 @@ -import type { ChannelConnection } from "../../adapters/durable-objects/channel-connection.do"; import type { DriverConnection } from "../../adapters/durable-objects/driver-connection.do"; import type { Sandbox } from "../../adapters/durable-objects/sandbox.do"; import type { Session } from "../../adapters/durable-objects/session.do"; import type { ApiCommandMessage } from "../../modules/api-command/application/api-command-message"; -import type { ChannelFinalDeliveryMessage } from "../../modules/channels/application/channel-final-delivery-message"; - -interface OptionalChannelConnectionBinding { - ChannelConnection?: DurableObjectNamespace; -} interface OptionalSandboxBinding { Sandbox?: DurableObjectNamespace; @@ -21,10 +15,6 @@ interface OptionalSessionBinding { Session?: DurableObjectNamespace; } -interface ChannelFinalDeliveryQueueBinding { - CHANNEL_FINAL_DELIVERY_QUEUE: Queue; -} - interface ApiCommandQueueBinding { API_COMMAND_QUEUE: Queue; } @@ -63,38 +53,12 @@ interface OptionalSkillsShBindings { VERCEL_OIDC_TOKEN?: string; } -export interface OptionalSlackAdapterBindings { - MOSOO_AGENT_ID?: string; - MOSOO_API_BASE_URL?: string; - MOSOO_API_TOKEN?: string; - MOSOO_SESSION_LINK_BASE_URL?: string; - SLACK_BOT_TOKEN?: string; - SLACK_SIGNING_SECRET?: string; -} - -interface OptionalWeChatIlinkBindings { - WECHAT_ILINK_BASE_URL?: string; -} - -// Set when a local Node sidecar is running the official @larksuiteoapi/node-sdk -// WSClient on the worker's behalf (the SDK's pbbp2 protobuf + Node-native `ws` -// dependencies cannot run inside workerd). The sidecar polls the bindings -// internal endpoint and posts decoded events back to the events internal -// endpoint; both endpoints are gated by this shared secret. When unset the -// WebSocket connection-mode path is dormant and webhook mode is the only -// inbound channel. -interface OptionalLarkSidecarBindings { - MOSOO_LARK_SIDECAR_SECRET?: string; -} - interface OptionalRuntimeSubjectPlatformBindings { runtimeSubjectHandleFactory?: (runtimeSubjectId: string) => unknown; } export type ApiBindings = Env & ApiCommandQueueBinding & - ChannelFinalDeliveryQueueBinding & - OptionalChannelConnectionBinding & OptionalSandboxBinding & OptionalDriverConnectionBinding & OptionalSessionBinding & @@ -103,9 +67,6 @@ export type ApiBindings = Env & OptionalProductAnalyticsBindings & OptionalRuntimeBindings & OptionalSkillsShBindings & - OptionalSlackAdapterBindings & - OptionalWeChatIlinkBindings & - OptionalLarkSidecarBindings & OptionalRuntimeSubjectPlatformBindings; export interface ApiGatewayEnvironment { diff --git a/apps/api/src/platform/db/drizzle.ts b/apps/api/src/platform/db/drizzle.ts index 4b8590bc..e12c7d7f 100644 --- a/apps/api/src/platform/db/drizzle.ts +++ b/apps/api/src/platform/db/drizzle.ts @@ -4,18 +4,10 @@ import { agentMcpBindingsTable, agentsTable, agentSkillsTable, - appDeploymentRunsTable, - appDeploymentSecretsTable, - appDeploymentsTable, apiCommandsTable, authAccountsTable, authSessionsTable, authVerificationsTable, - agentChannelBindingsTable, - channelConnectionStatesTable, - channelEventReceiptsTable, - channelFinalDeliveryJobsTable, - channelThreadSessionsTable, driverCommandsTable, driverInstanceMcpGrantsTable, driverInstancesTable, @@ -54,9 +46,6 @@ import { usageEventsTable, vaultSecretsTable, vendorCredentialsTable, - wechatChannelAccountsTable, - wechatChannelPairingsTable, - wechatContextTokensTable, } from "@mosoo/db"; import type { SQL } from "drizzle-orm"; import { sql } from "drizzle-orm"; @@ -68,18 +57,10 @@ const schema = { agentMcpBindingsTable, agentsTable, agentSkillsTable, - appDeploymentRunsTable, - appDeploymentSecretsTable, - appDeploymentsTable, apiCommandsTable, authAccountsTable, authSessionsTable, authVerificationsTable, - agentChannelBindingsTable, - channelConnectionStatesTable, - channelEventReceiptsTable, - channelFinalDeliveryJobsTable, - channelThreadSessionsTable, driverCommandsTable, driverInstanceMcpGrantsTable, driverInstancesTable, @@ -118,9 +99,6 @@ const schema = { usageEventsTable, vaultSecretsTable, vendorCredentialsTable, - wechatChannelAccountsTable, - wechatChannelPairingsTable, - wechatContextTokensTable, }; type CompatD1RawOptions = { columnNames?: boolean } | undefined; diff --git a/apps/api/src/platform/errors.ts b/apps/api/src/platform/errors.ts index 6415caff..2e7dc3d6 100644 --- a/apps/api/src/platform/errors.ts +++ b/apps/api/src/platform/errors.ts @@ -33,100 +33,54 @@ export type ApiErrorStatus = (typeof API_ERROR_STATUS)[keyof typeof API_ERROR_ST export const API_ERROR_CODE = { activeRuntimeLeaseRequired: "ACTIVE_RUNTIME_LEASE_REQUIRED", - agentChannelBindingAlreadyExists: "AGENT_CHANNEL_BINDING_ALREADY_EXISTS", agentLiveVersionConflict: "AGENT_LIVE_VERSION_CONFLICT", agentLiveVersionRequired: "AGENT_LIVE_VERSION_REQUIRED", agentNotPublished: "AGENT_NOT_PUBLISHED", agentPublishNotReady: "AGENT_PUBLISH_NOT_READY", agentPublishPersonalMcp: "AGENT_PUBLISH_PERSONAL_MCP", agentSessionNotReady: "AGENT_SESSION_NOT_READY", - appDeploymentCleanupFailed: "APP_DEPLOYMENT_CLEANUP_FAILED", - channelAppBound: "CHANNEL_APP_BOUND", - discordAuthTestFailed: "DISCORD_AUTH_TEST_FAILED", - discordAuthTestNotBot: "DISCORD_AUTH_TEST_NOT_BOT", environmentArtifactFailed: "ENVIRONMENT_ARTIFACT_FAILED", environmentArtifactPreparing: "ENVIRONMENT_ARTIFACT_PREPARING", forbidden: "FORBIDDEN", internalError: "INTERNAL_ERROR", - larkAppRegistrationPollFailed: "LARK_APP_REGISTRATION_POLL_FAILED", - larkAppRegistrationStartFailed: "LARK_APP_REGISTRATION_START_FAILED", - larkAuthTestFailed: "LARK_AUTH_TEST_FAILED", - larkConnectionModeInvalid: "LARK_CONNECTION_MODE_INVALID", - larkDomainInvalid: "LARK_DOMAIN_INVALID", - larkWebsocketDisabled: "LARK_WEBSOCKET_DISABLED", notFound: "NOT_FOUND", personalOrganizationSlotOccupied: "PERSONAL_ORGANIZATION_SLOT_OCCUPIED", - ownerDebugTerminalUnavailable: "OWNER_DEBUG_TERMINAL_UNAVAILABLE", runtimeBackupConfigMissing: "RUNTIME_BACKUP_CONFIG_MISSING", runtimeEventCursorInvalid: "RUNTIME_EVENT_CURSOR_INVALID", runtimeEventLimitInvalid: "RUNTIME_EVENT_LIMIT_INVALID", runtimeReadyWaitUnsupported: "RUNTIME_READY_WAIT_UNSUPPORTED", sessionRunCheckpointPending: "SESSION_RUN_CHECKPOINT_PENDING", sessionRunClientRequestDuplicate: "SESSION_RUN_CLIENT_REQUEST_DUPLICATE", - slackAppBound: "SLACK_APP_BOUND", - slackAuthTestFailed: "SLACK_AUTH_TEST_FAILED", - slackAuthTestMissingBot: "SLACK_AUTH_TEST_MISSING_BOT", - slackAuthTestMissingTeam: "SLACK_AUTH_TEST_MISSING_TEAM", - telegramAuthTestFailed: "TELEGRAM_AUTH_TEST_FAILED", unauthorized: "UNAUTHORIZED", validationFailed: "VALIDATION_FAILED", websocketRequired: "WEBSOCKET_REQUIRED", - wechatAccountBound: "WECHAT_ACCOUNT_BOUND", - wechatBindingInconsistent: "WECHAT_BINDING_INCONSISTENT", - wechatQrPairingNotFound: "WECHAT_QR_PAIRING_NOT_FOUND", - wechatQrStartFailed: "WECHAT_QR_START_FAILED", - wechatQrStatusFailed: "WECHAT_QR_STATUS_FAILED", - wechatQrTokenRequired: "WECHAT_QR_TOKEN_REQUIRED", } as const; export type ApiErrorCode = (typeof API_ERROR_CODE)[keyof typeof API_ERROR_CODE]; const API_ERROR_STATUS_BY_CODE = { [API_ERROR_CODE.activeRuntimeLeaseRequired]: API_ERROR_STATUS.conflict, - [API_ERROR_CODE.agentChannelBindingAlreadyExists]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentLiveVersionConflict]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.agentLiveVersionRequired]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.agentNotPublished]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentPublishNotReady]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentPublishPersonalMcp]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentSessionNotReady]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.appDeploymentCleanupFailed]: API_ERROR_STATUS.badGateway, - [API_ERROR_CODE.channelAppBound]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.discordAuthTestFailed]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.discordAuthTestNotBot]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.environmentArtifactFailed]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.environmentArtifactPreparing]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.forbidden]: API_ERROR_STATUS.forbidden, [API_ERROR_CODE.internalError]: API_ERROR_STATUS.internalServerError, - [API_ERROR_CODE.larkAppRegistrationPollFailed]: API_ERROR_STATUS.badGateway, - [API_ERROR_CODE.larkAppRegistrationStartFailed]: API_ERROR_STATUS.badGateway, - [API_ERROR_CODE.larkAuthTestFailed]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.larkConnectionModeInvalid]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.larkDomainInvalid]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.larkWebsocketDisabled]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.notFound]: API_ERROR_STATUS.notFound, [API_ERROR_CODE.personalOrganizationSlotOccupied]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.ownerDebugTerminalUnavailable]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.runtimeBackupConfigMissing]: API_ERROR_STATUS.internalServerError, [API_ERROR_CODE.runtimeEventCursorInvalid]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.runtimeEventLimitInvalid]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.runtimeReadyWaitUnsupported]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.sessionRunCheckpointPending]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.sessionRunClientRequestDuplicate]: API_ERROR_STATUS.conflict, - [API_ERROR_CODE.slackAppBound]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.slackAuthTestFailed]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.slackAuthTestMissingBot]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.slackAuthTestMissingTeam]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.telegramAuthTestFailed]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.unauthorized]: API_ERROR_STATUS.unauthorized, [API_ERROR_CODE.validationFailed]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.websocketRequired]: API_ERROR_STATUS.upgradeRequired, - [API_ERROR_CODE.wechatAccountBound]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.wechatBindingInconsistent]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.wechatQrPairingNotFound]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.wechatQrStartFailed]: API_ERROR_STATUS.badGateway, - [API_ERROR_CODE.wechatQrStatusFailed]: API_ERROR_STATUS.badGateway, - [API_ERROR_CODE.wechatQrTokenRequired]: API_ERROR_STATUS.badRequest, } as const satisfies Record; const API_ERROR_CODES = Object.values(API_ERROR_CODE); diff --git a/apps/api/tests/agent-channel-binding-delete.test.ts b/apps/api/tests/agent-channel-binding-delete.test.ts deleted file mode 100644 index cf756123..00000000 --- a/apps/api/tests/agent-channel-binding-delete.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentChannelBindingsTable, appsTable } from "@mosoo/db"; -import type { AppId } from "@mosoo/id"; -import { count, eq } from "drizzle-orm"; - -import { - createDiscordAgentChannelBinding, - createSlackAgentChannelBinding, - deleteAgentChannelBinding, - listAgentChannelBindings, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - EXTERNAL_VIEWER, - OWNER_VIEWER, - createChannelConnectionNamespaceForDeleteTest, - withDiscordCurrentUserMock, - withSlackAuthTestMock, -} from "./agent-channel-binding-fixtures"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -describe("agent channel binding deletion", () => { - test("marks Discord bindings inactive before stopping the Gateway connection during delete", async () => { - await withDiscordCurrentUserMock(async () => { - const database = await createPublicHttpContractDatabase(); - const stoppedRows: Array<{ lastErrorCode: string | null; status: string }> = []; - const bindings = { - ...createPublicHttpTestBindings(database), - ChannelConnection: createChannelConnectionNamespaceForDeleteTest(async (bindingId) => { - const row = await database - .app() - .select({ - lastErrorCode: agentChannelBindingsTable.lastErrorCode, - status: agentChannelBindingsTable.status, - }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - - if (!row) { - throw new Error("Expected Discord binding row to exist before owner stop."); - } - - stoppedRows.push(row); - }), - } as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - await deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId: binding.id, - appId: PUBLIC_API_TEST_IDS.app, - }); - - expect(stoppedRows).toEqual([{ lastErrorCode: "binding_deleting", status: "error" }]); - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .get(); - expect(bindingCount?.count).toBe(0); - }); - }); - - test("deletes Discord bindings even when Gateway connection stop fails", async () => { - await withDiscordCurrentUserMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = { - ...createPublicHttpTestBindings(database), - ChannelConnection: createChannelConnectionNamespaceForDeleteTest(async () => { - throw new Error("Gateway connection unavailable"); - }), - } as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - await deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId: binding.id, - appId: PUBLIC_API_TEST_IDS.app, - }); - - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .get(); - - expect(bindingCount?.count).toBe(0); - }); - }); - - test("requires owner access to list or delete bindings", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - - await expect( - listAgentChannelBindings(database, EXTERNAL_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }), - ).rejects.toThrow(); - await expect( - deleteAgentChannelBinding(bindings, EXTERNAL_VIEWER, { - bindingId: binding.id, - appId: PUBLIC_API_TEST_IDS.app, - }), - ).rejects.toThrow(); - - await deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId: binding.id, - appId: PUBLIC_API_TEST_IDS.app, - }); - - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .get(); - - expect(bindingCount?.count).toBe(0); - }); - }); - - test("rejects owner operations when the Agent belongs to another App", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const nowMs = Date.now(); - const otherAppId = "01J000000000000000000000ZZ" as AppId; - - await database - .app() - .insert(appsTable) - .values({ - createdAt: nowMs, - defaultEnvironmentId: PUBLIC_API_TEST_IDS.environment, - id: otherAppId, - name: "Other App", - organizationId: PUBLIC_API_TEST_IDS.organization, - ownerAccountId: PUBLIC_API_TEST_IDS.ownerAccount, - updatedAt: nowMs, - }) - .run(); - - await expect( - createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: otherAppId, - signingSecret: "signing-secret", - }), - ).rejects.toThrow(); - - const binding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - - await expect( - listAgentChannelBindings(database, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: otherAppId, - }), - ).rejects.toThrow(); - await expect( - deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId: binding.id, - appId: otherAppId, - }), - ).rejects.toThrow(); - }); - }); -}); diff --git a/apps/api/tests/agent-channel-binding-fixtures.ts b/apps/api/tests/agent-channel-binding-fixtures.ts deleted file mode 100644 index 48626c8b..00000000 --- a/apps/api/tests/agent-channel-binding-fixtures.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { expect } from "bun:test"; - -import { agentDeploymentVersionsTable, agentsTable } from "@mosoo/db"; - -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import type { createPublicHttpContractDatabase } from "./helpers/public-api-http-test-fixture"; -import { PUBLIC_API_TEST_IDS } from "./helpers/public-api-http-test-fixture"; -import { nowMsForTest } from "./helpers/public-api-http-test-fixture"; - -export const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -export const EXTERNAL_VIEWER: AuthenticatedViewer = { - email: "external@example.com", - emailVerified: true, - id: "01J00000000000000000000003", - imageUrl: null, - name: "External Viewer", -}; - -export const SLACK_AUTH_TEST_OK_RESPONSE = { - ok: true, - team: "Growth HQ", - team_id: "T123", - user: "mosoobot", - user_id: "U-BOT", -} as const; - -const DISCORD_CURRENT_BOT_USER_OK_RESPONSE = { - bot: true, - id: "discord-bot-1", - username: "mosoobot", -} as const; - -const LARK_TENANT_ACCESS_TOKEN_OK_RESPONSE = { - code: 0, - data: { tenant_access_token: "tenant-token" }, - msg: "ok", -} as const; - -const LARK_BOT_INFO_OK_RESPONSE = { - code: 0, - data: { - bot: { - app_name: "mosoo Bot", - open_id: "lark-bot-open-id", - }, - }, - msg: "ok", -} as const; - -const LARK_APP_REGISTRATION_BEGIN_OK_RESPONSE = { - device_code: "lark-device-code", - expire_in: 600, - interval: 5, - user_code: "ABCD-EFGH", - verification_uri_complete: "https://accounts.feishu.cn/app-registration?device_code=1", -} as const; - -const LARK_APP_REGISTRATION_INIT_OK_RESPONSE = { - nonce: "nonce-1", - supported_auth_methods: ["client_secret"], -} as const; - -const LARK_APP_REGISTRATION_POLL_OK_RESPONSE = { - client_id: "cli_lark_scan", - client_secret: "lark-scan-secret", - user_info: { - open_id: "ou_lark_owner", - tenant_brand: "lark", - }, -} as const; - -const TELEGRAM_GET_ME_OK_RESPONSE = { - ok: true, - result: { - first_name: "mosoo", - id: 12345, - username: "mosoo_bot", - }, -} as const; - -const WECHAT_QR_OK_RESPONSE = { - qrcode: "wechat-qr-token", - qrcode_img_content: "data:image/png;base64,wechat-qr", -} as const; - -export const WECHAT_QR_WAIT_RESPONSE = { - status: "wait", -} as const; - -const WECHAT_QR_CONFIRMED_RESPONSE = { - baseurl: "https://ilinkai.weixin.qq.com/", - bot_token: "wechat-bot-secret", - ilink_bot_id: "wechat-bot-1", - ilink_user_id: "wechat-account-1", - status: "confirmed", -} as const; - -export async function withSlackAuthTestMock( - operation: () => Promise, - responseBody: unknown = SLACK_AUTH_TEST_OK_RESPONSE, - onAuthTest?: () => void, -): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://slack.com/api/auth.test") { - onAuthTest?.(); - return Response.json(responseBody); - } - - return originalFetch(url); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function withLarkIdentityMock(input: { - botInfoBody?: unknown; - operation: () => Promise; - tenantAccessTokenBody?: unknown; -}): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if ( - requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" || - requestUrl === "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal" - ) { - return Response.json(input.tenantAccessTokenBody ?? LARK_TENANT_ACCESS_TOKEN_OK_RESPONSE); - } - - if ( - requestUrl === "https://open.feishu.cn/open-apis/bot/v3/info" || - requestUrl === "https://open.larksuite.com/open-apis/bot/v3/info" - ) { - return Response.json(input.botInfoBody ?? LARK_BOT_INFO_OK_RESPONSE); - } - - return originalFetch(url); - }; - - try { - return await input.operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function withLarkRegistrationMock(input: { - beginBody?: unknown; - initBody?: unknown; - operation: () => Promise; - pollBody?: unknown; -}): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if ( - requestUrl === "https://accounts.feishu.cn/oauth/v1/app/registration" || - requestUrl === "https://accounts.larksuite.com/oauth/v1/app/registration" - ) { - const body = typeof init?.body === "string" ? init.body : ""; - const action = new URLSearchParams(body).get("action"); - - if (action === "init") { - return Response.json(input.initBody ?? LARK_APP_REGISTRATION_INIT_OK_RESPONSE); - } - - if (action === "begin") { - return Response.json(input.beginBody ?? LARK_APP_REGISTRATION_BEGIN_OK_RESPONSE); - } - - if (action === "poll") { - return Response.json(input.pollBody ?? LARK_APP_REGISTRATION_POLL_OK_RESPONSE); - } - } - - return originalFetch(url); - }; - - try { - return await input.operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function withTelegramGetMeMock( - operation: () => Promise, - responseBody: unknown = TELEGRAM_GET_ME_OK_RESPONSE, -): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://api.telegram.org/bottelegram-token/getMe") { - return Response.json(responseBody, { - status: isTelegramOkResponse(responseBody) ? 200 : 401, - statusText: isTelegramOkResponse(responseBody) ? "OK" : "Unauthorized", - }); - } - - return originalFetch(url); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function withDiscordCurrentUserMock( - operation: () => Promise, - responseBody: unknown = DISCORD_CURRENT_BOT_USER_OK_RESPONSE, -): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://discord.com/api/v10/users/@me") { - return Response.json(responseBody); - } - - return originalFetch(url); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function withWeChatQrMock(input: { - onQr?: () => void; - onQrStatus?: () => void; - operation: () => Promise; - qrBody?: unknown; - qrError?: unknown; - qrResponseInit?: ResponseInit; - qrStatusBody?: unknown; - qrStatusError?: unknown; - qrStatusResponseInit?: ResponseInit; -}): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3") { - input.onQr?.(); - - if (input.qrError) { - throw input.qrError; - } - - return Response.json(input.qrBody ?? WECHAT_QR_OK_RESPONSE, input.qrResponseInit); - } - - if ( - requestUrl === - "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode=wechat-qr-token" - ) { - input.onQrStatus?.(); - - if (input.qrStatusError) { - throw input.qrStatusError; - } - - return Response.json( - input.qrStatusBody ?? WECHAT_QR_CONFIRMED_RESPONSE, - input.qrStatusResponseInit, - ); - } - - return originalFetch(url); - }; - - try { - return await input.operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function insertSecondLiveAgent( - database: Awaited>, -): Promise { - const nowMs = nowMsForTest(); - const configJson = JSON.stringify({ - packageMcpServers: [], - packageResolution: null, - packageSkills: [], - }); - const appDatabase = database.app(); - - await appDatabase - .insert(agentsTable) - .values({ - configJson, - createdAt: nowMs, - description: null, - environmentId: "01J00000000000000000000007", - id: "01J00000000000000000000068", - kind: "pet", - liveDeploymentVersionId: "01J00000000000000000000069", - model: "gpt-5.4", - name: "Second Live Agent", - ownerId: "01J00000000000000000000001", - prompt: "Help again.", - provider: "openai", - appId: PUBLIC_API_TEST_IDS.app, - runtimeId: "openai-runtime", - status: "published", - updatedAt: nowMs, - visibility: "private", - }) - .run(); - - await appDatabase - .insert(agentDeploymentVersionsTable) - .values({ - agentId: "01J00000000000000000000068", - configJson, - createdAt: nowMs, - createdByAccountId: "01J00000000000000000000001", - environmentId: "01J00000000000000000000007", - id: "01J00000000000000000000069", - kind: "pet", - mcpBindingsJson: "[]", - model: "gpt-5.4", - prompt: "Help again.", - provider: "openai", - runtimeId: "openai-runtime", - skillsJson: "[]", - spaceBindingsJson: "[]", - summary: "Second published test version", - versionNumber: 1, - }) - .run(); -} - -export function createChannelConnectionNamespaceForDeleteTest( - onStop: (bindingId: string) => Promise, -) { - return { - get: () => ({ - stop: async (provider: string, bindingId: string) => { - expect(provider).toBe("discord"); - await onStop(bindingId); - return { bindingId, status: "stopped" as const }; - }, - }), - idFromName: (name: string) => name, - }; -} - -function isTelegramOkResponse(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - "ok" in value && - value.ok === true - ); -} diff --git a/apps/api/tests/agent-channel-binding-provider-validation.test.ts b/apps/api/tests/agent-channel-binding-provider-validation.test.ts deleted file mode 100644 index b3a93733..00000000 --- a/apps/api/tests/agent-channel-binding-provider-validation.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentChannelBindingsTable } from "@mosoo/db"; -import { count, eq } from "drizzle-orm"; - -import { recordAgentChannelBindingError } from "../src/modules/channels/application/agent-channel-binding-error"; -import { - createDiscordAgentChannelBinding, - createLarkAgentChannelBinding, - createSlackAgentChannelBinding, - createTelegramAgentChannelBinding, - listAgentChannelBindings, - pollLarkAgentChannelRegistration, - startLarkAgentChannelRegistration, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { isApiError } from "../src/platform/errors"; -import { - OWNER_VIEWER, - SLACK_AUTH_TEST_OK_RESPONSE, - withDiscordCurrentUserMock, - withLarkIdentityMock, - withLarkRegistrationMock, - withSlackAuthTestMock, - withTelegramGetMeMock, -} from "./agent-channel-binding-fixtures"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -describe("agent channel provider validation", () => { - test("starts and polls Lark / Feishu app registration for scan-to-create prefill", async () => { - await withLarkRegistrationMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - - const started = await startLarkAgentChannelRegistration(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - domain: "feishu", - appId: PUBLIC_API_TEST_IDS.app, - }); - - expect(started).toMatchObject({ - appId: null, - appSecret: null, - deviceCode: "lark-device-code", - domain: "feishu", - expireIn: 600, - interval: 5, - qrUrl: - "https://accounts.feishu.cn/app-registration?device_code=1&from=mosoo_channel_setup&tp=ob_cli_app", - status: "qr_pending", - userCode: "ABCD-EFGH", - }); - - const polled = await pollLarkAgentChannelRegistration(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - deviceCode: started.deviceCode ?? "", - domain: "feishu", - appId: PUBLIC_API_TEST_IDS.app, - }); - - expect(polled).toMatchObject({ - appId: "cli_lark_scan", - appSecret: "lark-scan-secret", - domain: "lark", - lastErrorCode: null, - openId: "ou_lark_owner", - status: "confirmed", - }); - }, - }); - }); - test("returns validation error when Slack auth.test rejects credentials", async () => { - await withSlackAuthTestMock( - async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-invalid-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected Slack auth.test failure to surface as ApiError."); - } - - expect(caughtError.code).toBe("SLACK_AUTH_TEST_FAILED"); - expect(caughtError.status).toBe(400); - }, - { error: "invalid_auth", ok: false }, - ); - }); - - test("returns validation error before Slack auth.test when required credentials are blank", async () => { - let authTestCallCount = 0; - await withSlackAuthTestMock( - async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const scenarios = [ - { - botToken: " ", - signingSecret: "signing-secret", - }, - { - botToken: "xoxb-secret-token", - signingSecret: " ", - }, - ] as const; - - for (const scenario of scenarios) { - let caughtError: unknown = null; - - try { - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: scenario.botToken, - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: scenario.signingSecret, - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected blank Slack credential to surface as ApiError."); - } - - expect(caughtError.code).toBe("VALIDATION_FAILED"); - expect(caughtError.status).toBe(400); - } - }, - SLACK_AUTH_TEST_OK_RESPONSE, - () => { - authTestCallCount += 1; - }, - ); - - expect(authTestCallCount).toBe(0); - }); - - test("returns validation error when Slack is already connected to the Agent", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - - let caughtError: unknown = null; - - try { - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected duplicate Slack binding to surface as ApiError."); - } - - expect(caughtError.code).toBe("AGENT_CHANNEL_BINDING_ALREADY_EXISTS"); - expect(caughtError.status).toBe(400); - }); - }); - - test("returns validation error when the Slack app is already connected elsewhere", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const nowMs = 1_778_000_000_000; - - await database - .app() - .insert(agentChannelBindingsTable) - .values({ - agentId: "other-agent", - createdAt: nowMs, - displayMetadataJson: "{}", - encryptedCredsSecretId: "existing-secret", - externalBotId: "U-BOT", - externalTenantId: "T123", - id: "existing-binding", - lastErrorCode: null, - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - status: "active", - updatedAt: nowMs, - }) - .run(); - - let caughtError: unknown = null; - - try { - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected duplicate Slack app binding to surface as ApiError."); - } - - expect(caughtError.code).toBe("SLACK_APP_BOUND"); - expect(caughtError.status).toBe(400); - }); - }); - - test("moves Slack bindings to error status", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - - await recordAgentChannelBindingError(database, { - agentId: PUBLIC_API_TEST_IDS.agent, - bindingId: binding.id, - errorCode: "invalid_auth", - appId: PUBLIC_API_TEST_IDS.app, - }); - - await expect( - listAgentChannelBindings(database, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }), - ).resolves.toEqual([ - expect.objectContaining({ - id: binding.id, - lastErrorCode: "invalid_auth", - status: "error", - }), - ]); - }); - }); - - test("returns validation error when Lark identity validation rejects credentials", async () => { - await withLarkIdentityMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli-invalid", - appSecret: "invalid-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "lark-encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "lark-verification-token", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected Lark auth failure to surface as ApiError."); - } - - expect(caughtError.code).toBe("LARK_AUTH_TEST_FAILED"); - expect(caughtError.status).toBe(400); - }, - tenantAccessTokenBody: { - code: 99991663, - msg: "invalid app_secret", - }, - }); - }); - - test("rejects new Lark WebSocket bindings while the sidecar path is disabled", async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "websocket", - domain: "feishu", - encryptKey: null, - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: null, - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected Lark WebSocket mode to surface as ApiError."); - } - - expect(caughtError.code).toBe("LARK_CONNECTION_MODE_INVALID"); - expect(caughtError.status).toBe(400); - }); - - test("returns validation error when Telegram getMe rejects credentials", async () => { - await withTelegramGetMeMock( - async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await createTelegramAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected Telegram auth failure to surface as ApiError."); - } - - expect(caughtError.code).toBe("TELEGRAM_AUTH_TEST_FAILED"); - expect(caughtError.status).toBe(400); - }, - { - description: "Unauthorized", - ok: false, - }, - ); - }); - - test("returns validation error when Discord credentials do not resolve to a bot user", async () => { - await withDiscordCurrentUserMock( - async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-user-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "relay-secret", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected Discord non-bot credentials to surface as ApiError."); - } - - expect(caughtError.code).toBe("DISCORD_AUTH_TEST_NOT_BOT"); - expect(caughtError.status).toBe(400); - }, - { - bot: false, - id: "discord-user-1", - username: "human", - }, - ); - }); -}); diff --git a/apps/api/tests/agent-channel-binding-wechat.test.ts b/apps/api/tests/agent-channel-binding-wechat.test.ts deleted file mode 100644 index 8c88ac62..00000000 --- a/apps/api/tests/agent-channel-binding-wechat.test.ts +++ /dev/null @@ -1,653 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - agentChannelBindingsTable, - vaultSecretsTable, - wechatChannelAccountsTable, - wechatChannelPairingsTable, - wechatContextTokensTable, -} from "@mosoo/db"; -import { count, eq } from "drizzle-orm"; - -import { - deleteAgentChannelBinding, - listAgentChannelBindings, - pollWeChatAgentChannelPairing, - startWeChatAgentChannelPairing, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import { createWeChatContextTokenStoreKey } from "../src/modules/channels/wechat/wechat-runtime"; -import { - createWeChatPollingOwnerDatabaseStore, - persistConfirmedWeChatQrPairing, - readWeChatChannelAccountWithCredentials, - readWeChatContextTokenForPeer, -} from "../src/modules/channels/wechat/wechat-runtime-store"; -import { - readSecretOutcome, - storeSecret, -} from "../src/modules/vault/application/vault-secret-store"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { isApiError } from "../src/platform/errors"; -import { - OWNER_VIEWER, - WECHAT_QR_WAIT_RESPONSE, - insertSecondLiveAgent, - withWeChatQrMock, -} from "./agent-channel-binding-fixtures"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -describe("agent channel WeChat bindings", () => { - test("starts and confirms Personal WeChat QR pairing into a channel binding", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - - expect(started).toEqual({ - binding: null, - lastErrorCode: null, - qrCodeImageSrc: "data:image/png;base64,wechat-qr", - qrToken: "wechat-qr-token", - status: "qr_pending", - }); - - const confirmed = await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: "wechat-qr-token", - }); - - expect(JSON.stringify(confirmed)).not.toContain("wechat-bot-secret"); - expect(confirmed).toMatchObject({ - lastErrorCode: null, - qrCodeImageSrc: null, - qrToken: "wechat-qr-token", - status: "confirmed", - }); - expect(confirmed.binding).toMatchObject({ - agentId: "01J00000000000000000000009", - displayMetadata: { - ilink_bot_id: "wechat-bot-1", - ilink_user_id: "wechat-account-1", - }, - externalBotId: "wechat-bot-1", - externalTenantId: "wechat-account-1", - provider: "wechat", - status: "active", - }); - expect(JSON.stringify(confirmed.binding?.displayMetadata)).not.toContain( - "01J00000000000000000000001", - ); - - const bindingId = confirmed.binding?.id ?? ""; - const account = await database - .app() - .select() - .from(wechatChannelAccountsTable) - .where(eq(wechatChannelAccountsTable.id, bindingId)) - .get(); - expect(account).toMatchObject({ - agentId: "01J00000000000000000000009", - externalAccountId: "wechat-account-1", - externalBotId: "wechat-bot-1", - ownerAccountId: "01J00000000000000000000001", - status: "idle", - }); - - const bindingRow = await database - .app() - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - expect(JSON.stringify(bindingRow)).not.toContain("wechat-bot-secret"); - expect(JSON.stringify(account)).not.toContain("wechat-bot-secret"); - - const pairingRow = await database.app().select().from(wechatChannelPairingsTable).get(); - expect(pairingRow).toMatchObject({ - agentId: "01J00000000000000000000009", - consumedAt: expect.any(Number), - createdByAccountId: "01J00000000000000000000001", - }); - expect(pairingRow?.qrTokenHash).not.toBe("wechat-qr-token"); - - if (!bindingId) { - throw new Error("Expected confirmed WeChat QR pairing to create a binding."); - } - - const accountWithCredentials = await readWeChatChannelAccountWithCredentials(bindings, { - accountId: bindingId, - }); - expect(accountWithCredentials?.credentials).toEqual({ - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "wechat-bot-secret", - ilinkBotId: "wechat-bot-1", - ilinkUserId: "wechat-account-1", - }); - - await expect( - listAgentChannelBindings(database, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }), - ).resolves.toMatchObject([ - { - externalBotId: "wechat-bot-1", - externalTenantId: "wechat-account-1", - provider: "wechat", - status: "active", - }, - ]); - }, - }); - }); - - test("reads WeChat context tokens only through the owning account and peer", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - const confirmed = await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - const bindingId = confirmed.binding?.id; - - if (!bindingId) { - throw new Error("Expected confirmed WeChat QR pairing to create a binding."); - } - - const store = createWeChatPollingOwnerDatabaseStore(bindings); - const contextTokenKey = createWeChatContextTokenStoreKey({ - accountId: "wechat-account-1", - bindingId, - peerId: "peer-1", - }); - await store.writeContextToken({ - accountId: "wechat-account-1", - bindingId, - contextTokenKey, - contextTokenValue: "wechat-context-secret", - peerId: "peer-1", - toUserId: "peer-to-user-1", - updatedAtMs: 1779646500000, - }); - - await expect( - readWeChatContextTokenForPeer(bindings, { - accountId: bindingId, - peerId: "peer-1", - }), - ).resolves.toMatchObject({ - contextToken: "wechat-context-secret", - }); - const contextTokenRow = await database - .app() - .select({ - secretId: wechatContextTokensTable.encryptedContextTokenSecretId, - }) - .from(wechatContextTokensTable) - .where(eq(wechatContextTokensTable.accountId, bindingId)) - .get(); - - if (!contextTokenRow) { - throw new Error("Expected WeChat context token row."); - } - - const unrelatedSecretId = await storeSecret(database, bindings, { - kind: "test_unrelated_context_token", - value: "wrong-peer-secret", - }); - await database - .app() - .update(wechatContextTokensTable) - .set({ encryptedContextTokenSecretId: unrelatedSecretId }) - .where(eq(wechatContextTokensTable.accountId, bindingId)) - .run(); - - await expect( - readWeChatContextTokenForPeer(bindings, { - accountId: bindingId, - peerId: "peer-1", - }), - ).rejects.toThrow(); - - await database - .app() - .update(wechatContextTokensTable) - .set({ encryptedContextTokenSecretId: contextTokenRow.secretId }) - .where(eq(wechatContextTokensTable.accountId, bindingId)) - .run(); - await database - .app() - .delete(vaultSecretsTable) - .where(eq(vaultSecretsTable.id, contextTokenRow.secretId)) - .run(); - - await expect( - readWeChatContextTokenForPeer(bindings, { - accountId: bindingId, - peerId: "peer-1", - }), - ).rejects.toThrow(); - }, - }); - }); - - test("cleans up replaced Personal WeChat credential secrets", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - const confirmed = await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - const bindingId = confirmed.binding?.id; - - if (!bindingId) { - throw new Error("Expected confirmed WeChat QR pairing to create a binding."); - } - - const oldBindingRow = await database - .app() - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - - if (!oldBindingRow) { - throw new Error("Expected WeChat binding row."); - } - - await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: { - accountId: null, - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "wechat-bot-secret-2", - expiresAtMs: null, - ilinkBotId: "wechat-bot-1", - ilinkUserId: "wechat-account-1", - lastErrorCode: null, - qrCodeImageSrc: null, - qrToken: "wechat-qr-token-2", - status: "confirmed", - }, - }); - - const newBindingRow = await database - .app() - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - - if (!newBindingRow) { - throw new Error("Expected updated WeChat binding row."); - } - - expect(newBindingRow.encryptedCredsSecretId).not.toBe(oldBindingRow.encryptedCredsSecretId); - await expect( - readWeChatChannelAccountWithCredentials(bindings, { accountId: bindingId }), - ).resolves.toMatchObject({ - credentials: { - botToken: "wechat-bot-secret-2", - }, - }); - - await expect( - readSecretOutcome(database, bindings, oldBindingRow.encryptedCredsSecretId), - ).resolves.toMatchObject({ status: "missing" }); - await expect( - readSecretOutcome(database, bindings, newBindingRow.encryptedCredsSecretId), - ).resolves.toMatchObject({ status: "found" }); - }, - }); - }); - - test("rejects a new Personal WeChat QR setup when the Agent already has a binding", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - - await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - - let caughtError: unknown = null; - - try { - await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected duplicate WeChat binding to surface as ApiError."); - } - - expect(caughtError.code).toBe("AGENT_CHANNEL_BINDING_ALREADY_EXISTS"); - expect(caughtError.status).toBe(400); - }, - }); - }); - - test("rejects Personal WeChat QR polling when the token was started for another Agent", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await insertSecondLiveAgent(database); - - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - let caughtError: unknown = null; - - try { - await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: "01J00000000000000000000068", - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected cross-Agent WeChat QR poll to surface as ApiError."); - } - - expect(caughtError.code).toBe("WECHAT_QR_PAIRING_NOT_FOUND"); - expect(caughtError.status).toBe(400); - - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.provider, "wechat")) - .get(); - expect(bindingCount?.count).toBe(0); - }, - }); - }); - - test("maps Personal WeChat iLink HTTP failures as upstream setup failures", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected WeChat iLink HTTP failure to surface as ApiError."); - } - - expect(caughtError.code).toBe("WECHAT_QR_START_FAILED"); - expect(caughtError.status).toBe(502); - }, - qrBody: { error: "temporarily unavailable" }, - qrResponseInit: { status: 503, statusText: "Service Unavailable" }, - }); - }); - - test("redacts Personal WeChat QR token from upstream status failures", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - let caughtError: unknown = null; - - try { - await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected WeChat iLink status failure to surface as ApiError."); - } - - expect(caughtError.code).toBe("WECHAT_QR_STATUS_FAILED"); - expect(caughtError.status).toBe(502); - expect(caughtError.message).not.toContain(started.qrToken ?? ""); - }, - qrStatusBody: { error: "temporarily unavailable" }, - qrStatusResponseInit: { status: 503, statusText: "Service Unavailable" }, - }); - }); - - test("maps Personal WeChat iLink timeouts as upstream setup failures", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let caughtError: unknown = null; - - try { - await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - } catch (error) { - caughtError = error; - } - - expect(isApiError(caughtError)).toBe(true); - if (!isApiError(caughtError)) { - throw new Error("Expected WeChat iLink timeout to surface as ApiError."); - } - - expect(caughtError.code).toBe("WECHAT_QR_START_FAILED"); - expect(caughtError.status).toBe(502); - }, - qrError: new DOMException("The operation timed out.", "TimeoutError"), - }); - }); - - test("does not persist Personal WeChat credentials while QR pairing is still pending", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - - const pending = await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - - expect(pending).toEqual({ - binding: null, - lastErrorCode: null, - qrCodeImageSrc: null, - qrToken: "wechat-qr-token", - status: "qr_pending", - }); - - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.provider, "wechat")) - .get(); - - expect(bindingCount?.count).toBe(0); - - const pairingRow = await database.app().select().from(wechatChannelPairingsTable).get(); - expect(pairingRow).toMatchObject({ - agentId: "01J00000000000000000000009", - consumedAt: null, - createdByAccountId: "01J00000000000000000000001", - }); - expect(pairingRow?.qrTokenHash).not.toBe(started.qrToken); - }, - qrStatusBody: WECHAT_QR_WAIT_RESPONSE, - }); - }); - - test("consumes Personal WeChat QR pairing when the QR code expires", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - - await expect( - pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }), - ).resolves.toMatchObject({ - binding: null, - lastErrorCode: "qr_expired", - qrToken: "wechat-qr-token", - status: "expired", - }); - - const pairingRow = await database.app().select().from(wechatChannelPairingsTable).get(); - expect(pairingRow).toMatchObject({ - agentId: "01J00000000000000000000009", - consumedAt: expect.any(Number), - createdByAccountId: "01J00000000000000000000001", - }); - }, - qrStatusBody: { status: "expired" }, - }); - }); - - test("deletes Personal WeChat runtime rows with the binding", async () => { - await withWeChatQrMock({ - operation: async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const started = await startWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - const confirmed = await pollWeChatAgentChannelPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - qrToken: started.qrToken ?? "", - }); - const bindingId = confirmed.binding?.id; - - if (!bindingId) { - throw new Error("Expected confirmed WeChat QR pairing to create a binding."); - } - - const store = createWeChatPollingOwnerDatabaseStore(bindings); - await store.writeContextToken({ - accountId: "wechat-account-1", - bindingId, - contextTokenKey: createWeChatContextTokenStoreKey({ - accountId: "wechat-account-1", - bindingId, - peerId: "peer-1", - }), - contextTokenValue: "wechat-context-secret", - peerId: "peer-1", - toUserId: "peer-to-user-1", - updatedAtMs: 1779646500000, - }); - - const contextRowsBefore = await database - .app() - .select({ count: count() }) - .from(wechatContextTokensTable) - .where(eq(wechatContextTokensTable.accountId, bindingId)) - .get(); - expect(contextRowsBefore?.count).toBe(1); - - await deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId, - appId: PUBLIC_API_TEST_IDS.app, - }); - - const bindingCount = await database - .app() - .select({ count: count() }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - const accountCount = await database - .app() - .select({ count: count() }) - .from(wechatChannelAccountsTable) - .where(eq(wechatChannelAccountsTable.id, bindingId)) - .get(); - const contextTokenCount = await database - .app() - .select({ count: count() }) - .from(wechatContextTokensTable) - .where(eq(wechatContextTokensTable.accountId, bindingId)) - .get(); - - expect(bindingCount?.count).toBe(0); - expect(accountCount?.count).toBe(0); - expect(contextTokenCount?.count).toBe(0); - }, - }); - }); -}); diff --git a/apps/api/tests/agent-channel-binding.test.ts b/apps/api/tests/agent-channel-binding.test.ts deleted file mode 100644 index 0dcf0041..00000000 --- a/apps/api/tests/agent-channel-binding.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentChannelBindingsTable, sessionsTable, vaultSecretsTable } from "@mosoo/db"; -import { count, eq } from "drizzle-orm"; - -import { cleanupOrphanChannelBindingCredentialSecrets } from "../src/modules/channels/application/agent-channel-binding-maintenance.service"; -import { - createSlackAgentChannelBinding, - listAgentChannelBindings, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import { - deleteAgentChannelBindingCredentialSecret, - readAgentChannelBindingCredentialSecret, - storeAgentChannelBindingCredentialSecret, -} from "../src/modules/channels/application/channel-credential-secret-resolution"; -import { - readSecretOutcome, - storeSecret, -} from "../src/modules/vault/application/vault-secret-store"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { OWNER_VIEWER, withSlackAuthTestMock } from "./agent-channel-binding-fixtures"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -describe("agent channel bindings", () => { - test("loads channel activity for all bindings", async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const secretId = await storeSecret(database, bindings, { - kind: "test_channel_binding_credentials", - value: "{}", - }); - const nowMs = Date.now(); - - await database - .app() - .insert(agentChannelBindingsTable) - .values([ - { - agentId: "01J00000000000000000000009", - createdAt: nowMs, - displayMetadataJson: "{}", - encryptedCredsSecretId: secretId, - externalBotId: "slack-bot", - externalTenantId: "slack-tenant", - id: "binding-slack", - lastErrorCode: null, - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - status: "active", - updatedAt: nowMs, - }, - { - agentId: "01J00000000000000000000009", - createdAt: nowMs, - displayMetadataJson: "{}", - encryptedCredsSecretId: secretId, - externalBotId: "telegram-bot", - externalTenantId: "telegram-tenant", - id: "binding-telegram", - lastErrorCode: null, - appId: PUBLIC_API_TEST_IDS.app, - provider: "telegram", - status: "active", - updatedAt: nowMs, - }, - { - agentId: "01J00000000000000000000009", - createdAt: nowMs, - displayMetadataJson: "{}", - encryptedCredsSecretId: secretId, - externalBotId: "discord-bot", - externalTenantId: "discord-tenant", - id: "binding-discord", - lastErrorCode: null, - appId: PUBLIC_API_TEST_IDS.app, - provider: "discord", - status: "active", - updatedAt: nowMs, - }, - ]) - .run(); - - await database - .app() - .insert(sessionsTable) - .values([ - { - agentId: "01J00000000000000000000009", - archivedAt: null, - participantAccountId: null, - createdAt: nowMs - 10, - creatorAccountId: "01J00000000000000000000001", - deploymentVersionId: "01J0000000000000000000000A", - deploymentVersionNumber: 1, - id: "session-slack-old", - kind: "pet", - lastMessageAt: null, - lastRunId: null, - metadataJson: JSON.stringify({ triggered_by: { binding_id: "binding-slack" } }), - model: "gpt-5.4", - organizationId: "01J00000000000000000000006", - appId: PUBLIC_API_TEST_IDS.app, - provider: "openai", - renamed: false, - runtimeId: "openai-runtime", - status: "IDLE", - title: null, - type: "api_channel", - updatedAt: nowMs - 10, - }, - { - agentId: "01J00000000000000000000009", - archivedAt: null, - participantAccountId: null, - createdAt: nowMs, - creatorAccountId: "01J00000000000000000000001", - deploymentVersionId: "01J0000000000000000000000A", - deploymentVersionNumber: 1, - id: "session-slack-new", - kind: "pet", - lastMessageAt: null, - lastRunId: null, - metadataJson: JSON.stringify({ triggered_by: { binding_id: "binding-slack" } }), - model: "gpt-5.4", - organizationId: "01J00000000000000000000006", - appId: PUBLIC_API_TEST_IDS.app, - provider: "openai", - renamed: false, - runtimeId: "openai-runtime", - status: "IDLE", - title: null, - type: "api_channel", - updatedAt: nowMs, - }, - { - agentId: "01J00000000000000000000009", - archivedAt: null, - participantAccountId: null, - createdAt: nowMs, - creatorAccountId: "01J00000000000000000000001", - deploymentVersionId: "01J0000000000000000000000A", - deploymentVersionNumber: 1, - id: "session-telegram", - kind: "pet", - lastMessageAt: null, - lastRunId: null, - metadataJson: JSON.stringify({ triggered_by: { binding_id: "binding-telegram" } }), - model: "gpt-5.4", - organizationId: "01J00000000000000000000006", - appId: PUBLIC_API_TEST_IDS.app, - provider: "openai", - renamed: false, - runtimeId: "openai-runtime", - status: "IDLE", - title: null, - type: "api_channel", - updatedAt: nowMs, - }, - ]) - .run(); - const results = await listAgentChannelBindings(database, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }); - expect(results).toContainEqual( - expect.objectContaining({ - activityLastTriggeredAt: new Date(nowMs).toISOString(), - activitySessionCount7d: 2, - id: "binding-slack", - }), - ); - expect(results).toContainEqual( - expect.objectContaining({ - activityLastTriggeredAt: new Date(nowMs).toISOString(), - activitySessionCount7d: 1, - id: "binding-telegram", - }), - ); - expect(results).toContainEqual( - expect.objectContaining({ - activityLastTriggeredAt: null, - activitySessionCount7d: 0, - id: "binding-discord", - }), - ); - }); - - test("creates Slack binding credentials through vault secret storage", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - - const binding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - - expect(binding).toMatchObject({ - agentId: "01J00000000000000000000009", - displayMetadata: { - bot_handle: "mosoobot", - workspace_name: "Growth HQ", - }, - externalBotId: "U-BOT", - externalTenantId: "T123", - provider: "slack", - status: "active", - }); - - const row = await database - .app() - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, binding.id)) - .get(); - - expect(row?.encryptedCredsSecretId).toBeString(); - expect(JSON.stringify(row)).not.toContain("xoxb-secret-token"); - - if (!row) { - throw new Error("Expected Slack binding row."); - } - - const decrypted = await readAgentChannelBindingCredentialSecret(bindings, { - bindingId: binding.id, - expectedOwner: { - agentId: binding.agentId, - appId: PUBLIC_API_TEST_IDS.app, - }, - provider: "slack", - purpose: "channel_callback", - secretId: row.encryptedCredsSecretId, - }); - expect(JSON.parse(decrypted)).toEqual({ - appLevelToken: null, - botToken: "xoxb-secret-token", - signingSecret: "signing-secret", - threadRepliesRequireMention: false, - }); - - const unrelatedSecretId = await storeSecret(database, bindings, { - kind: "test_unrelated_channel_binding_credentials", - value: "{}", - }); - await expect( - readAgentChannelBindingCredentialSecret(bindings, { - bindingId: binding.id, - expectedOwner: { - agentId: binding.agentId, - appId: PUBLIC_API_TEST_IDS.app, - }, - provider: "slack", - purpose: "channel_callback", - secretId: unrelatedSecretId, - }), - ).rejects.toThrow(); - await expect( - readAgentChannelBindingCredentialSecret(bindings, { - bindingId: binding.id, - expectedOwner: { - agentId: binding.agentId, - appId: "01J00000000000000000000099", - }, - provider: "slack", - purpose: "channel_callback", - secretId: row.encryptedCredsSecretId, - }), - ).rejects.toThrow(); - - await database - .app() - .delete(vaultSecretsTable) - .where(eq(vaultSecretsTable.id, row.encryptedCredsSecretId)) - .run(); - await expect( - readAgentChannelBindingCredentialSecret(bindings, { - bindingId: binding.id, - expectedOwner: { - agentId: binding.agentId, - appId: PUBLIC_API_TEST_IDS.app, - }, - provider: "slack", - purpose: "channel_callback", - secretId: row.encryptedCredsSecretId, - }), - ).rejects.toThrow(); - }); - }); - - test("deletes channel credentials only through the expected owner context", async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const slackSecretId = await storeAgentChannelBindingCredentialSecret(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - credentialsJson: "{}", - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - purpose: "channel_binding_create", - }); - const telegramSecretId = await storeAgentChannelBindingCredentialSecret(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - credentialsJson: "{}", - appId: PUBLIC_API_TEST_IDS.app, - provider: "telegram", - purpose: "channel_binding_create", - }); - - await expect( - deleteAgentChannelBindingCredentialSecret(database, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - purpose: "channel_binding_delete", - secretId: telegramSecretId, - }), - ).resolves.toMatchObject({ - status: "denied", - }); - - const wrongOwnerSecretCount = await database - .app() - .select({ count: count() }) - .from(vaultSecretsTable) - .where(eq(vaultSecretsTable.id, telegramSecretId)) - .get(); - expect(wrongOwnerSecretCount?.count).toBe(1); - - await expect( - deleteAgentChannelBindingCredentialSecret(database, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - purpose: "channel_binding_delete", - secretId: slackSecretId, - }), - ).resolves.toEqual({ status: "deleted" }); - - const deletedSecretCount = await database - .app() - .select({ count: count() }) - .from(vaultSecretsTable) - .where(eq(vaultSecretsTable.id, slackSecretId)) - .get(); - expect(deletedSecretCount?.count).toBe(0); - }); - - test("repairs orphaned channel credential secrets after replacement", async () => { - await withSlackAuthTestMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const oldBindingRow = await database - .app() - .select() - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, binding.id)) - .get(); - - if (!oldBindingRow) { - throw new Error("Expected Slack binding row."); - } - - const replacementSecretId = await storeAgentChannelBindingCredentialSecret(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - credentialsJson: "{}", - appId: PUBLIC_API_TEST_IDS.app, - provider: "slack", - purpose: "channel_binding_update", - }); - await database - .app() - .update(agentChannelBindingsTable) - .set({ encryptedCredsSecretId: replacementSecretId }) - .where(eq(agentChannelBindingsTable.id, binding.id)) - .run(); - - await expect( - cleanupOrphanChannelBindingCredentialSecrets(bindings, new Date(), { limit: 10 }), - ).resolves.toEqual({ - deleted: 1, - failed: 0, - skipped: 0, - total: 1, - }); - - await expect( - readSecretOutcome(database, bindings, oldBindingRow.encryptedCredsSecretId), - ).resolves.toMatchObject({ status: "missing" }); - await expect( - readSecretOutcome(database, bindings, replacementSecretId), - ).resolves.toMatchObject({ - status: "found", - }); - }); - }); -}); diff --git a/apps/api/tests/api-web-boundary.test.ts b/apps/api/tests/api-web-boundary.test.ts index 5b646649..d408dafd 100644 --- a/apps/api/tests/api-web-boundary.test.ts +++ b/apps/api/tests/api-web-boundary.test.ts @@ -151,56 +151,32 @@ describe("API to web boundary", () => { expect(fields.ownerCostCard).toBeUndefined(); }); - test("keeps bound capability Run provenance behind an App-scoped audit query", () => { + test("keeps retired Deployment and Channel surfaces out of GraphQL", () => { const schema = createGraphQLSchema(); const query = schema.getQueryType(); + const mutation = schema.getMutationType(); - if (!query) { - throw new Error("Expected Query in the GraphQL schema."); + if (!query || !mutation) { + throw new Error("Expected Query and Mutation in the GraphQL schema."); } - const provenance = query.getFields().boundCapabilityRunProvenance; - - expect(provenance).toBeDefined(); - expect(String(provenance?.type)).toBe("BoundCapabilityRunProvenance"); - expect(String(provenance?.args.find((arg) => arg.name === "appId")?.type)).toBe("ULID!"); - expect(String(provenance?.args.find((arg) => arg.name === "runId")?.type)).toBe("ULID!"); - }); - - test("keeps Channel GraphQL setup App-scoped with Agent-owned delivery", () => { - const schema = createGraphQLSchema(); - for (const typeName of [ + "AppDeployment", + "AppDeploymentRun", + "BoundCapabilityRunProvenance", + "AgentChannelBinding", "CreateSlackAgentChannelBindingInput", "CreateLarkAgentChannelBindingInput", - "StartLarkAgentChannelRegistrationInput", - "PollLarkAgentChannelRegistrationInput", "CreateTelegramAgentChannelBindingInput", "CreateDiscordAgentChannelBindingInput", - "StartWeChatAgentChannelPairingInput", - "PollWeChatAgentChannelPairingInput", ] as const) { - const input = schema.getType(typeName); - - if (!isInputObjectType(input)) { - throw new Error(`Expected ${typeName} to be a GraphQL input object.`); - } - - expect(String(input.getFields().appId?.type)).toBe("ULID!"); - expect(String(input.getFields().agentId?.type)).toBe("ULID!"); - expect(input.getFields().organizationId).toBeUndefined(); - } - - const deleteInput = schema.getType("DeleteAgentChannelBindingInput"); - - if (!isInputObjectType(deleteInput)) { - throw new Error("Expected DeleteAgentChannelBindingInput to be a GraphQL input object."); + expect(schema.getType(typeName)).toBeUndefined(); } - expect(String(deleteInput.getFields().appId?.type)).toBe("ULID!"); - expect(String(deleteInput.getFields().bindingId?.type)).toBe("ULID!"); - expect(deleteInput.getFields().agentId).toBeUndefined(); - expect(deleteInput.getFields().organizationId).toBeUndefined(); + expect(query.getFields().boundCapabilityRunProvenance).toBeUndefined(); + expect(query.getFields().appDeployment).toBeUndefined(); + expect(mutation.getFields().deployApp).toBeUndefined(); + expect(mutation.getFields().createSlackAgentChannelBinding).toBeUndefined(); }); test("keeps file GraphQL scope details compatible", () => { @@ -293,16 +269,32 @@ describe("API to web boundary", () => { expect(document.openapi).toBe("3.1.0"); expect(document.servers).toEqual([{ url: "https://api.example.com/api/v1" }]); - expect(Object.keys(document.components.securitySchemes)).toEqual(["accessToken"]); + expect(Object.keys(document.components.securitySchemes)).toEqual([ + "accessToken", + "workspaceApiKey", + ]); expect(document.security).toEqual([{ accessToken: [] }]); for (const pathItem of Object.values(document.paths)) { for (const operation of Object.values(pathItem)) { if (operation?.security !== undefined) { - expect(operation.security).toEqual([{ accessToken: [] }]); + expect([ + JSON.stringify([]), + JSON.stringify([{ accessToken: [] }]), + JSON.stringify([{ workspaceApiKey: [] }]), + ]).toContain(JSON.stringify(operation.security)); } } } + expect(document.paths["/harnesses"]?.get?.security).toEqual([]); + expect(document.paths["/runs"]?.post?.security).toEqual([{ workspaceApiKey: [] }]); expectProperties(document.paths, [ + "/harnesses", + "/harnesses/{slug}", + "/runs", + "/runs/{runId}", + "/runs/{runId}/events", + "/runs/{runId}/events/stream", + "/runs/{runId}/result", "/agents/{agentId}/files", "/files/{fileId}/content", "/files/{fileId}", diff --git a/apps/api/tests/app-agent-binding-resolution.test.ts b/apps/api/tests/app-agent-binding-resolution.test.ts deleted file mode 100644 index 018d83bf..00000000 --- a/apps/api/tests/app-agent-binding-resolution.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AppAgentBindingResolutionError, - resolveAppAgentBindings, -} from "../src/modules/apps/application/app-agent-binding-resolution"; -import type { AppDeploymentAgentBinding } from "../src/modules/apps/application/app-deployment-detector"; - -const BINDINGS: AppDeploymentAgentBinding[] = [ - { env: "ROADMAP_THREAD_URL", expose: "public_thread", name: "roadmap" }, - { env: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, -]; - -describe("resolveAppAgentBindings", () => { - test("resolves every binding to its published agent", () => { - expect( - resolveAppAgentBindings(BINDINGS, [ - { id: "agt_3kf", name: "roadmap", published: true }, - { id: "agt_9wz", name: "triage", published: true }, - ]), - ).toEqual([ - { - agentId: "agt_3kf", - envVar: "ROADMAP_THREAD_URL", - expose: "public_thread", - name: "roadmap", - }, - { agentId: "agt_9wz", envVar: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, - ]); - }); - - test("fails fast when a bound agent is missing", () => { - expect(() => - resolveAppAgentBindings(BINDINGS, [{ id: "agt_3kf", name: "roadmap", published: true }]), - ).toThrow(AppAgentBindingResolutionError); - }); - - test("fails fast with the published code when a bound agent is not live", () => { - try { - resolveAppAgentBindings(BINDINGS, [ - { id: "agt_3kf", name: "roadmap", published: true }, - { id: "agt_9wz", name: "triage", published: false }, - ]); - throw new Error("expected resolution to throw"); - } catch (error) { - expect(error).toBeInstanceOf(AppAgentBindingResolutionError); - expect((error as AppAgentBindingResolutionError).code).toBe("deployment_agent_not_published"); - } - }); - - test("resolves to an empty list when there are no bindings", () => { - expect(resolveAppAgentBindings([], [])).toEqual([]); - }); -}); diff --git a/apps/api/tests/app-agent-bound-call.test.ts b/apps/api/tests/app-agent-bound-call.test.ts deleted file mode 100644 index 41ee70be..00000000 --- a/apps/api/tests/app-agent-bound-call.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { SessionRunStatus } from "@mosoo/contracts/session-run"; -import { parsePlatformId } from "@mosoo/id"; -import type { - AccountId, - AgentDeploymentVersionId, - AgentId, - AppDeploymentId, - AppDeploymentRunId, - AppId, -} from "@mosoo/id"; - -import type { AgentRow } from "../src/modules/agents/application/agent-types"; -import { - getBoundAgentServabilityFailure, - isTerminalRunStatus, - parseBoundAgentCallBody, - selectBoundAgentReply, - verifyBoundAgentCapability, - waitForTerminalRun, -} from "../src/modules/public-api/app-agent-bound-call"; -import { - BoundAgentCallError, - DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE, - DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE, - DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE, -} from "../src/modules/public-api/app-agent-bound-errors"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { PublicApiError } from "../src/modules/public-api/public-api-errors"; - -const SECRET = "bound-test-secret"; -const NOW = 5_000_000; -const AGENT_ID = parsePlatformId("01J00000000000000000000009"); -const APP_ID = parsePlatformId("01J0000000000000000000000Q"); -const DEPLOYMENT_ID = parsePlatformId("01J0000000000000000000000D"); -const DEPLOYMENT_RUN_ID = parsePlatformId("01J0000000000000000000000R"); -const OWNER_ID = parsePlatformId("01J00000000000000000000001"); -const AGENT_VERSION_ID = parsePlatformId("01J0000000000000000000000A"); - -function claims(overrides: Partial = {}): AppAgentCapabilityClaims { - return { - agentId: AGENT_ID, - appId: APP_ID, - binding: { env: "MOSOO_AGENT", expose: "public_thread", name: "Bound Agent" }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: NOW + 60_000, - ...overrides, - }; -} - -function agent(overrides: Partial = {}): AgentRow { - return { - appId: APP_ID, - configJson: "{}", - createdAt: 0, - description: null, - environmentId: null, - id: AGENT_ID, - kind: "pet", - liveDeploymentVersionId: AGENT_VERSION_ID, - model: "gpt-5.4", - name: "Bound Agent", - ownerId: OWNER_ID, - prompt: "Help.", - provider: "openai", - runtimeId: "openai-runtime", - status: "published", - updatedAt: 0, - visibility: "private", - ...overrides, - }; -} - -describe("verifyBoundAgentCapability (capability verify-on-route)", () => { - test("rejects a malformed token", async () => { - const rejection = await verifyBoundAgentCapability(SECRET, "not-a-token", NOW).catch( - (error: unknown) => error, - ); - expect(rejection).toBeInstanceOf(PublicApiError); - expect((rejection as PublicApiError).status).toBe(401); - }); - - test("rejects a token signed with a different secret", async () => { - const token = await mintAppAgentCapabilityToken("other-secret", claims()); - await expect(verifyBoundAgentCapability(SECRET, token, NOW)).rejects.toBeInstanceOf( - PublicApiError, - ); - }); - - test("rejects an expired token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims({ exp: NOW })); - await expect(verifyBoundAgentCapability(SECRET, token, NOW)).rejects.toBeInstanceOf( - PublicApiError, - ); - }); - - test("returns claims for a valid token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyBoundAgentCapability(SECRET, token, NOW)).toEqual(claims()); - }); -}); - -describe("bound Agent servability", () => { - test("classifies unpublished Agents for capability audit", () => { - expect(getBoundAgentServabilityFailure(agent({ status: "draft" }), claims())).toBe( - "agent_unpublished", - ); - }); - - test("classifies a renamed binding target without treating it as unpublished", () => { - expect(getBoundAgentServabilityFailure(agent({ name: "Renamed" }), claims())).toBe( - "agent_mismatched", - ); - }); -}); - -describe("parseBoundAgentCallBody", () => { - test("accepts a `message` field", () => { - expect(parseBoundAgentCallBody({ message: "hello" })).toEqual({ message: "hello" }); - }); - - test("accepts an `input` alias", () => { - expect(parseBoundAgentCallBody({ input: "yo" })).toEqual({ message: "yo" }); - }); - - test("trims surrounding whitespace", () => { - expect(parseBoundAgentCallBody({ message: " spaced " })).toEqual({ message: "spaced" }); - }); - - test("falls back to input when message is an empty string", () => { - expect(parseBoundAgentCallBody({ input: "hi", message: "" })).toEqual({ message: "hi" }); - }); - - test("rejects a missing message", () => { - expect(() => parseBoundAgentCallBody({})).toThrow(PublicApiError); - }); - - test("rejects a blank message", () => { - expect(() => parseBoundAgentCallBody({ message: " " })).toThrow(PublicApiError); - }); - - test("rejects a non-object body", () => { - expect(() => parseBoundAgentCallBody("nope")).toThrow(PublicApiError); - expect(() => parseBoundAgentCallBody(null)).toThrow(PublicApiError); - expect(() => parseBoundAgentCallBody(["a"])).toThrow(PublicApiError); - }); -}); - -describe("isTerminalRunStatus", () => { - test("classifies terminal vs active statuses", () => { - const terminal: SessionRunStatus[] = ["completed", "failed", "cancelled", "expired"]; - const active: SessionRunStatus[] = ["queued", "booting", "running", "waiting_input"]; - for (const status of terminal) { - expect(isTerminalRunStatus(status)).toBe(true); - } - for (const status of active) { - expect(isTerminalRunStatus(status)).toBe(false); - } - }); -}); - -describe("waitForTerminalRun", () => { - test("returns the terminal run without delaying when already terminal", async () => { - let delays = 0; - const run = await waitForTerminalRun( - { - delay: async () => { - delays += 1; - }, - now: () => 0, - readRun: async () => ({ status: "completed" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ); - expect(run.status).toBe("completed"); - expect(delays).toBe(0); - }); - - test("rejects with needs-input when the run parks on waiting_input", async () => { - const error = await waitForTerminalRun( - { - delay: async () => undefined, - now: () => 0, - readRun: async () => ({ status: "waiting_input" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(BoundAgentCallError); - expect((error as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE); - }); - - test("polls until the run reaches a terminal state", async () => { - const statuses: SessionRunStatus[] = ["running", "running", "completed"]; - let index = 0; - let clock = 0; - let delays = 0; - const run = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - delays += 1; - }, - now: () => clock, - readRun: async () => { - const status = statuses[index] ?? "completed"; - index += 1; - return { status }; - }, - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ); - expect(run.status).toBe("completed"); - expect(delays).toBe(2); - }); - - test("throws a timeout error once the budget elapses", async () => { - let clock = 0; - const rejection = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - }, - now: () => clock, - readRun: async () => ({ status: "running" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 5_000 }, - ).catch((error: unknown) => error); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - expect((rejection as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE); - expect((rejection as BoundAgentCallError).status).toBe(504); - }); - - test("treats a missing run row as non-terminal until the timeout", async () => { - let clock = 0; - const rejection = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - }, - now: () => clock, - readRun: async () => null, - }, - { pollIntervalMs: 1_000, timeoutMs: 2_000 }, - ).catch((error: unknown) => error); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - }); -}); - -describe("selectBoundAgentReply (final-output extraction)", () => { - test("returns the joined final output text on a completed run", () => { - expect( - selectBoundAgentReply({ - finalOutput: { text: "the answer" }, - run: { error: null, status: "completed" }, - }), - ).toEqual({ reply: "the answer" }); - }); - - test("rejects a completed run with no canonical final output", () => { - expect(() => - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "completed" }, - }), - ).toThrow(BoundAgentCallError); - - try { - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "completed" }, - }); - } catch (error) { - expect(error).toMatchObject({ - code: DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - status: 503, - }); - } - }); - - test("surfaces the run error on a failed run", () => { - const rejection = (() => { - try { - selectBoundAgentReply({ - finalOutput: null, - run: { - error: { code: "boom", details: {}, message: "it broke", retryable: false }, - status: "failed", - }, - }); - return null; - } catch (error: unknown) { - return error; - } - })(); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - expect((rejection as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE); - expect((rejection as BoundAgentCallError).message).toBe("it broke"); - }); - - test("fails a cancelled run even without an error payload", () => { - expect(() => - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "cancelled" }, - }), - ).toThrow(BoundAgentCallError); - }); -}); diff --git a/apps/api/tests/app-agent-bound-run-revocation.test.ts b/apps/api/tests/app-agent-bound-run-revocation.test.ts deleted file mode 100644 index ee480edb..00000000 --- a/apps/api/tests/app-agent-bound-run-revocation.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parsePlatformId } from "@mosoo/id"; -import type { AgentDeploymentVersionId, SessionId } from "@mosoo/id"; -import { graphql } from "graphql"; - -import { createGraphQLSchema } from "../src/adapters/graphql/create-graphql-schema"; -import type { GraphQLContext } from "../src/adapters/graphql/graphql-context"; -import { createDeploymentAgentCapabilityRunCreationGuard } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import { getAccountViewer } from "../src/modules/auth/application/public-api-caller.service"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { - queueSessionRun, - SessionRunCreationGuardRejectedError, -} from "../src/modules/runtime/application/session-run.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - insertOwnerSession, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -const CLAIMS: AppAgentCapabilityClaims = { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, -}; - -async function insertDeploymentAuthority(database: SqliteD1Database): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, NULL, ?)") - .bind(PUBLIC_API_TEST_IDS.app, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, 'success')", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: [CLAIMS.binding] }), - ) - .run(); -} - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -function queueBoundRun(input: { bindings: ApiBindings; viewer: AuthenticatedViewer }) { - return queueSessionRun({ - bindings: input.bindings, - executionContext: null, - input: { - accessViewer: input.viewer, - attachmentIds: [], - boundCapabilityProvenance: { - agentId: CLAIMS.agentId, - appId: CLAIMS.appId, - bindingEnv: CLAIMS.binding.env, - bindingName: CLAIMS.binding.name, - deploymentId: CLAIMS.deploymentId, - deploymentRunId: CLAIMS.deploymentRunId, - }, - clientRequestId: null, - prompt: "Race the deployment deletion.", - runCreationGuard: createDeploymentAgentCapabilityRunCreationGuard(CLAIMS), - session: { - agent_id: CLAIMS.agentId, - app_id: CLAIMS.appId, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(PUBLIC_API_TEST_IDS.ownerSession, "fixture session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/v1/bound/test", - viewer: input.viewer, - }); -} - -function createBoundCapabilityAuditContext( - bindings: ApiBindings, - viewer: AuthenticatedViewer, -): GraphQLContext { - const executionCtx = createTestExecutionContext(); - - return { - bindings, - executionContext: executionCtx, - request: new Request("https://api.example.com/api/graphql"), - serverContext: { - ...bindings, - executionCtx, - }, - viewer, - }; -} - -describe("bound Agent Run revocation boundary", () => { - test("creates a Run while the claimed deployment authority remains current", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const result = await queueBoundRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - viewer, - }); - - expect(result.run.status).toBe("queued"); - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - viewer, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - agentId - appId - bindingEnv - bindingName - deploymentId - deploymentRunId - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response).toEqual({ - data: { - boundCapabilityRunProvenance: { - agentId: CLAIMS.agentId, - appId: CLAIMS.appId, - bindingEnv: CLAIMS.binding.env, - bindingName: CLAIMS.binding.name, - deploymentId: CLAIMS.deploymentId, - deploymentRunId: CLAIMS.deploymentRunId, - runId: result.run.id, - }, - }, - }); - await expect( - database - .prepare( - `SELECT - bound_capability_agent_id, - bound_capability_app_id, - bound_capability_binding_env, - bound_capability_binding_name, - bound_capability_deployment_id, - bound_capability_deployment_run_id - FROM session_run`, - ) - .first(), - ).resolves.toEqual({ - bound_capability_agent_id: CLAIMS.agentId, - bound_capability_app_id: CLAIMS.appId, - bound_capability_binding_env: CLAIMS.binding.env, - bound_capability_binding_name: CLAIMS.binding.name, - bound_capability_deployment_id: CLAIMS.deploymentId, - bound_capability_deployment_run_id: CLAIMS.deploymentRunId, - }); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 1 }); - }); - - test("does not expose accepted Run provenance to a non-owner", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const owner = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - const nonOwner = await getAccountViewer(database, PUBLIC_API_TEST_IDS.nonOwnerAccount); - - if (owner === null || nonOwner === null) { - throw new Error("Test viewers are missing."); - } - - const result = await queueBoundRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - viewer: owner, - }); - - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - nonOwner, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response.data).toEqual({ boundCapabilityRunProvenance: null }); - expect(response.errors?.[0]?.extensions.code).toBe("FORBIDDEN"); - }); - - test("returns no provenance for an existing non-bound Run", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const result = await queueSessionRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - executionContext: null, - input: { - accessViewer: viewer, - attachmentIds: [], - clientRequestId: null, - prompt: "Read a Run created before capability provenance.", - session: { - agent_id: CLAIMS.agentId, - app_id: CLAIMS.appId, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(PUBLIC_API_TEST_IDS.ownerSession, "fixture session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/graphql", - viewer, - }); - - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - viewer, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response).toEqual({ - data: { - boundCapabilityRunProvenance: null, - }, - }); - }); - - test("does not insert a Run when deletion commits after preflight authorization", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const bindings = createPublicHttpTestBindings( - revokeDeploymentWhenRunInsertStarts(database), - ) as ApiBindings; - - await expect(queueBoundRun({ bindings, viewer })).rejects.toBeInstanceOf( - SessionRunCreationGuardRejectedError, - ); - - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/apps/api/tests/app-agent-capability-revocation-http.test.ts b/apps/api/tests/app-agent-capability-revocation-http.test.ts deleted file mode 100644 index 5e969a43..00000000 --- a/apps/api/tests/app-agent-capability-revocation-http.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { Hono } from "hono"; - -import { registerPublicApiRoute } from "../src/adapters/http/routes/public-api-route"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -function createBoundAgentRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - - registerPublicApiRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function capabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -async function insertDeploymentAuthority( - database: SqliteD1Database, - input: { agentBindings: unknown[]; deletedAt: number | null }, -): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(PUBLIC_API_TEST_IDS.app, input.deletedAt, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: input.agentBindings }), - "success", - ) - .run(); -} - -async function requestBoundAgent( - database: D1Database, - claims: AppAgentCapabilityClaims, -): Promise { - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); - - return createBoundAgentRouteTestApp().request( - new Request(`https://api.example.com/api/v1/bound/${token}`, { - body: JSON.stringify({ message: "Hello" }), - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); -} - -async function withProviderProbeMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - Response.json({ - data: [{ id: "gpt-5.4" }], - }); - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -async function expectNoSessions(database: SqliteD1Database): Promise { - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); -} - -describe("bound Agent capability revocation HTTP boundary", () => { - test("rejects a deleted deployment capability before it can create a Session", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [capabilityClaims().binding], - deletedAt: Date.now(), - }); - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects an expired capability before reading deployment state", async () => { - const database = await createPublicHttpContractDatabase(); - const response = await requestBoundAgent(database, capabilityClaims({ exp: Date.now() })); - - expect(response.status).toBe(401); - expect(await response.json()).toEqual({ - error: { - code: "unauthenticated", - message: "The capability URL is invalid or has expired.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects an unpublished Agent before it can create a Session", async () => { - const database = await createPublicHttpContractDatabase(); - await database - .prepare("UPDATE agent SET status = 'draft' WHERE id = ?") - .bind(PUBLIC_API_TEST_IDS.agent) - .run(); - - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This Agent is no longer published for bound calls.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects a capability whose current successful revision removed its binding", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [], - deletedAt: null, - }); - - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - }); - - test("cleans up the new Session when deletion wins the final Run creation race", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [capabilityClaims().binding], - deletedAt: null, - }); - - const response = await withProviderProbeMock(() => - requestBoundAgent(revokeDeploymentWhenRunInsertStarts(database), capabilityClaims()), - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/apps/api/tests/app-agent-capability.test.ts b/apps/api/tests/app-agent-capability.test.ts deleted file mode 100644 index 86d51276..00000000 --- a/apps/api/tests/app-agent-capability.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; - -import { - boundAgentUrl, - inspectAppAgentCapabilityToken, - mintAppAgentCapabilityToken, - verifyAppAgentCapabilityToken, -} from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; - -const SECRET = "test-capability-secret"; -const NOW = 1_000_000; -const AGENT_ID = parsePlatformId("01J00000000000000000000009"); -const APP_ID = parsePlatformId("01J0000000000000000000000Q"); -const DEPLOYMENT_ID = parsePlatformId("01J0000000000000000000000D"); -const DEPLOYMENT_RUN_ID = parsePlatformId("01J0000000000000000000000R"); - -function claims(overrides: Partial = {}): AppAgentCapabilityClaims { - return { - agentId: AGENT_ID, - appId: APP_ID, - binding: { env: "MOSOO_AGENT", expose: "public_thread", name: "Roadmap" }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: NOW + 60_000, - ...overrides, - }; -} - -describe("app agent capability token", () => { - test("round-trips mint and verify", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyAppAgentCapabilityToken(SECRET, token, NOW)).toEqual(claims()); - }); - - test("rejects a token signed with a different secret", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyAppAgentCapabilityToken("other-secret", token, NOW)).toBeNull(); - }); - - test("rejects a tampered payload", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - const tampered = `${token.split(".")[0]}x.${token.split(".")[1]}`; - expect(await verifyAppAgentCapabilityToken(SECRET, tampered, NOW)).toBeNull(); - }); - - test("rejects an expired token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims({ exp: NOW })); - expect(await verifyAppAgentCapabilityToken(SECRET, token, NOW)).toBeNull(); - await expect(inspectAppAgentCapabilityToken(SECRET, token, NOW)).resolves.toEqual({ - claims: claims({ exp: NOW }), - status: "expired", - }); - }); - - test("rejects a malformed token", async () => { - expect(await verifyAppAgentCapabilityToken(SECRET, "not-a-token", NOW)).toBeNull(); - expect(await verifyAppAgentCapabilityToken(SECRET, "", NOW)).toBeNull(); - }); - - test("rejects a legacy token without deployment authority claims", async () => { - const legacy = await mintLegacyToken(SECRET, { - agentId: AGENT_ID, - appId: APP_ID, - exp: NOW + 60_000, - expose: "public_thread", - }); - - expect(await verifyAppAgentCapabilityToken(SECRET, legacy, NOW)).toBeNull(); - }); - - test("builds a bound-agent url whose embedded token verifies", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - const url = boundAgentUrl("https://api.mosoo.ai/", token); - expect(url).toBe(`https://api.mosoo.ai/api/v1/bound/${token}`); - const embedded = url.slice(url.lastIndexOf("/") + 1); - expect(await verifyAppAgentCapabilityToken(SECRET, embedded, NOW)).toEqual(claims()); - }); -}); - -async function mintLegacyToken(secret: string, payload: Record): Promise { - const encoded = btoa(JSON.stringify(payload)) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(encoded)); - const signatureEncoded = btoa(String.fromCharCode(...new Uint8Array(signature))) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); - - return `${encoded}.${signatureEncoded}`; -} diff --git a/apps/api/tests/app-deployment-capability-authority.test.ts b/apps/api/tests/app-deployment-capability-authority.test.ts deleted file mode 100644 index 56425ee1..00000000 --- a/apps/api/tests/app-deployment-capability-authority.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createPlatformId } from "@mosoo/id"; -import type { AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; - -import { getDeploymentAgentCapabilityAuthority } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import { SqliteD1Database } from "./helpers/sqlite-d1"; - -const APP_ID = createPlatformId(1); -const DEPLOYMENT_ID = createPlatformId(1); -const SUCCESSFUL_RUN_ID = createPlatformId(1); -const FAILED_RUN_ID = createPlatformId(1); -const REPLACEMENT_RUN_ID = createPlatformId(1); - -const BINDING = { env: "MOSOO_AGENT", expose: "public_thread" as const, name: "Support" }; - -function createDatabase(): SqliteD1Database { - const database = new SqliteD1Database({ foreignKeys: false }); - - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - return database; -} - -function plan(bindings: readonly (typeof BINDING)[]): string { - return JSON.stringify({ agentBindings: bindings }); -} - -async function insertDeployment(database: SqliteD1Database, deletedAt: number | null = null) { - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(APP_ID, deletedAt, DEPLOYMENT_ID) - .run(); -} - -async function insertRun(input: { - database: SqliteD1Database; - id: AppDeploymentRunId; - planJson: string | null; - status: "failed" | "success"; -}) { - await input.database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind(APP_ID, DEPLOYMENT_ID, input.id, input.planJson, input.status) - .run(); -} - -function authority() { - return { - appId: APP_ID, - binding: BINDING, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: SUCCESSFUL_RUN_ID, - }; -} - -describe("deployment bound-agent capability authority", () => { - test("accepts the current successful deployment binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: true, - }); - }); - - test("rejects a capability after its deployment is deleted", async () => { - const database = createDatabase(); - await insertDeployment(database, 1); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "deployment_deleted", - }); - }); - - test("keeps the prior capability valid when a newer deployment run fails", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ database, id: FAILED_RUN_ID, planJson: plan([]), status: "failed" }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: true, - }); - }); - - test("rejects a capability after a successful revision removes its binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ database, id: REPLACEMENT_RUN_ID, planJson: plan([]), status: "success" }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "binding_removed", - }); - }); - - test("reports a superseded successful revision even when it retains the binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ - database, - id: REPLACEMENT_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "deployment_revision_replaced", - }); - }); -}); diff --git a/apps/api/tests/app-deployment-cloudflare-client.test.ts b/apps/api/tests/app-deployment-cloudflare-client.test.ts deleted file mode 100644 index 9721ee3b..00000000 --- a/apps/api/tests/app-deployment-cloudflare-client.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createWorkerModuleUpload } from "../src/modules/apps/application/app-deployment-cloudflare-client"; - -describe("app deployment Cloudflare client", () => { - test("uses the module name and metadata as multipart part names", async () => { - const scriptContent = "export default { fetch() {} };"; - const upload = createWorkerModuleUpload({ - compatibilityDate: "2026-07-14", - mainModuleName: "worker.js", - scriptContent, - scriptName: "example", - vars: { MOSOO_AGENT_URL: "https://example.com/bound/token" }, - }); - - const modulePart = upload.get("worker.js"); - - expect(upload).toBeInstanceOf(FormData); - expect(modulePart).toBeInstanceOf(File); - expect((modulePart as File).name).toBe("worker.js"); - expect((modulePart as File).type).toBe("application/javascript+module"); - expect(await (modulePart as File).text()).toBe(scriptContent); - expect(upload.get("files")).toBeNull(); - expect(upload.get("metadata")).toBe( - JSON.stringify({ - bindings: [ - { - name: "MOSOO_AGENT_URL", - text: "https://example.com/bound/token", - type: "plain_text", - }, - ], - compatibility_date: "2026-07-14", - main_module: "worker.js", - }), - ); - }); -}); diff --git a/apps/api/tests/app-deployment-detector.test.ts b/apps/api/tests/app-deployment-detector.test.ts deleted file mode 100644 index e5b4c784..00000000 --- a/apps/api/tests/app-deployment-detector.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AppDeploymentDetectionError, - detectAppDeploymentPlan, -} from "../src/modules/apps/application/app-deployment-detector"; - -const RESOURCE_NAME = "app-01j00000000000000000000054"; - -function detect(files: Record) { - return detectAppDeploymentPlan({ files }, { resourceName: RESOURCE_NAME }); -} - -describe("app deployment detector", () => { - test("detects a root static page without install or build", () => { - expect(detect({ "index.html": "
Hello
" })).toMatchObject({ - buildCommand: null, - installCommand: null, - outputDir: ".", - packageManager: "none", - rootDir: ".", - targetKind: "cloudflare_pages", - targetMode: "static_assets", - }); - }); - - test("uses the caller-provided Cloudflare resource name", () => { - expect(detect({ "index.html": "
Hello
" }).generatedWranglerConfig).toContain( - `name = "${RESOURCE_NAME}"`, - ); - }); - - test("detects Vite static output", () => { - expect( - detect({ - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - scripts: { build: "vite build" }, - }), - "pnpm-lock.yaml": "", - }), - ).toMatchObject({ - buildCommand: "pnpm run build", - installCommand: "pnpm install --frozen-lockfile", - outputDir: "dist", - packageManager: "pnpm", - targetKind: "cloudflare_pages", - }); - }); - - test("requires a build script for Vite static output", () => { - expect(() => - detect({ - "index.html": "
", - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - }), - "pnpm-lock.yaml": "", - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("does not freeze install when packageManager has no lockfile", () => { - expect( - detect({ - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - packageManager: "pnpm@10.0.0", - scripts: { build: "vite build" }, - }), - }), - ).toMatchObject({ - installCommand: "pnpm install", - packageManager: "pnpm", - }); - }); - - test("requires explicit static export for Next.js", () => { - expect(() => - detect({ - "package.json": JSON.stringify({ - dependencies: { next: "^16.0.0" }, - scripts: { build: "next build" }, - }), - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("detects Next.js static export", () => { - expect( - detect({ - "next.config.mjs": "export default { output: 'export' };", - "package-lock.json": "{}", - "package.json": JSON.stringify({ - dependencies: { next: "^16.0.0" }, - scripts: { build: "next build" }, - }), - }), - ).toMatchObject({ - buildCommand: "npm run build", - installCommand: "npm ci", - outputDir: "out", - packageManager: "npm", - targetKind: "cloudflare_pages", - }); - }); - - test("uses .mosoo.toml static override", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "static" -root = "site" - -[build] -install = "bun install --frozen-lockfile" -command = "bun run build" -output = "public" - -[routes] -fallback = "index.html" -`, - "site/package.json": JSON.stringify({ scripts: { build: "vite build" } }), - }), - ).toMatchObject({ - buildCommand: "bun run build", - installCommand: "bun install --frozen-lockfile", - mosooConfigPath: ".mosoo.toml", - outputDir: "public", - routesFallback: "index.html", - rootDir: "site", - targetKind: "cloudflare_pages", - }); - }); - - test("uses .mosoo.toml worker override", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" -`, - }), - ).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - outputDir: null, - rootDir: ".", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - }); - }); - - test("keeps the legacy flat worker override taking precedence over wrangler main", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" -`, - "wrangler.toml": 'main = "src/other.js"\n', - }), - ).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - workerEntry: "src/index.js", - }); - }); - - test("parses .mosoo.toml [[agents]] bindings", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" - -[[agents]] -name = "triage" -expose = "public_thread" -env = "TRIAGE_THREAD_URL" -`, - }).agentBindings, - ).toEqual([ - { env: "ROADMAP_THREAD_URL", expose: "public_thread", name: "roadmap" }, - { env: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, - ]); - }); - - test("parses the schema-v1 product manifest into a worker target", () => { - const plan = detect({ - ".mosoo.toml": ` -schema = 1 -name = "roadmap-board" - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "MOSOO_AGENT_ROADMAP_URL" -`, - "wrangler.toml": 'name = "roadmap-board"\nmain = "src/index.js"\n', - }); - - expect(plan).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - outputDir: null, - rootDir: ".", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - workerEntry: "src/index.js", - }); - expect(plan.agentBindings).toEqual([ - { env: "MOSOO_AGENT_ROADMAP_URL", expose: "public_thread", name: "roadmap" }, - ]); - }); - - test("rejects duplicate agent names", () => { - expect(() => - detect({ - ".mosoo.toml": ` -schema = 1 - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "TRIAGE_THREAD_URL" -`, - "wrangler.toml": 'main = "src/index.js"\n', - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects duplicate agent env vars", () => { - expect(() => - detect({ - ".mosoo.toml": ` -schema = 1 - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "SHARED_THREAD_URL" - -[[agents]] -name = "triage" -expose = "public_thread" -env = "SHARED_THREAD_URL" -`, - "wrangler.toml": 'main = "src/index.js"\n', - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects an agent binding that is not public_thread", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" - -[[agents]] -name = "roadmap" -expose = "private" -env = "ROADMAP_THREAD_URL" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects [[agents]] on a static deployment", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "static" - -[build] -output = "dist" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("repository-shape detection yields no agent bindings", () => { - expect(detect({ "index.html": "
Hello
" }).agentBindings).toEqual([]); - }); - - test("rejects TypeScript worker entry in the first cut", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.ts" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects routes fallback for worker override", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.ts" - -[routes] -fallback = "index.html" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("detects wrangler main as a Worker hint", () => { - expect( - detect({ - "package.json": JSON.stringify({ - dependencies: { hono: "^4.0.0" }, - scripts: { build: "tsc" }, - }), - "wrangler.jsonc": '{ "main": "src/index.js" }', - }), - ).toMatchObject({ - buildCommand: "npm run build", - installCommand: "npm install", - packageManager: "npm", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - }); - }); - - test("continues reading Wrangler hints until it finds main", () => { - expect( - detect({ - "package.json": JSON.stringify({ scripts: { build: "tsc" } }), - "wrangler.jsonc": '{ "main": "src/index.js" }', - "wrangler.toml": "name = ", - }), - ).toMatchObject({ - targetKind: "cloudflare_worker", - }); - }); - - test("rejects unsupported .mosoo.toml fields", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "static" -account_id = "do-not-pass-through" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects .mosoo.toml paths outside the repository", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" -root = "apps/../secret" - -[worker] -entry = "src/index.ts" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); -}); diff --git a/apps/api/tests/app-deployment-service.test.ts b/apps/api/tests/app-deployment-service.test.ts deleted file mode 100644 index a4f215b3..00000000 --- a/apps/api/tests/app-deployment-service.test.ts +++ /dev/null @@ -1,1663 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { apiCommandsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { createAppDeploymentRunDispatchDedupeKey } from "../src/modules/api-command/application/api-command-enqueue"; -import { API_COMMAND_QUEUE_SEND_FAILED_CODE } from "../src/modules/api-command/application/api-command-ledger"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, -} from "../src/modules/api-command/application/api-command-policy"; -import { processApiCommandDeadLetterMessage } from "../src/modules/api-command/application/api-command-processor"; -import { getDeploymentAgentCapabilityAuthority } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import type { CloudflareDeploymentClient } from "../src/modules/apps/application/app-deployment-cloudflare-client"; -import type { AppDeploymentBuildRunner } from "../src/modules/apps/application/app-deployment-executor.service"; -import { dispatchAppDeploymentRun } from "../src/modules/apps/application/app-deployment-executor.service"; -import { - deleteAppDeployment, - deployApp, - getAppDeployment, - getAppDeploymentStatus, - listAppDeploymentRuns, -} from "../src/modules/apps/application/app-deployment.service"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { SandboxHandle } from "../src/modules/runtime/infrastructure/sandbox-handles"; -import { createApiWorker } from "../src/platform/cloudflare/create-api-worker"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { API_ERROR_CODE } from "../src/platform/errors"; -import type { ApiError } from "../src/platform/errors"; -import { currentTimestampMs } from "../src/time"; -import { - createApiCommandQueueStub, - createRecordedQueueMessage, -} from "./helpers/channel-final-delivery-queue-fixture"; -import { SqliteD1Database } from "./helpers/sqlite-d1"; - -const OWNER_ID = "01J00000000000000000000001"; -const APP_ID = "01J0000000000000000000000Q"; -const OTHER_APP_ID = "01J0000000000000000000000R"; -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const OTHER_DEPLOYMENT_ID = "01J0000000000000000000000E"; -const NOW_MS = Date.parse("2026-06-26T00:00:00.000Z"); - -const VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: OWNER_ID, - imageUrl: null, - name: "Owner", -}; - -function createDatabase(): SqliteD1Database { - const database = new SqliteD1Database({ foreignKeys: false }); - - database.execute(` - CREATE TABLE app ( - id text PRIMARY KEY NOT NULL, - organization_id text NOT NULL, - owner_account_id text NOT NULL, - name text NOT NULL, - default_environment_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL - ); - - CREATE TABLE app_deployment ( - app_id text NOT NULL, - created_at integer NOT NULL, - default_branch text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL, - last_successful_url text, - latest_run_id text, - mosoo_subdomain text NOT NULL, - owner_account_id text NOT NULL, - repo_name text NOT NULL, - repo_owner text NOT NULL, - repo_url text NOT NULL, - source_kind text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE UNIQUE INDEX app_deployment_active_app_idx - ON app_deployment (app_id) - WHERE deleted_at IS NULL; - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - created_at integer NOT NULL, - deployment_id text NOT NULL, - error_code text, - error_message text, - external_deployment_id text, - external_project_id text, - external_version_id text, - generated_wrangler_config_json text, - id text PRIMARY KEY NOT NULL, - mosoo_config_json text, - plan_json text, - source_branch text NOT NULL, - source_commit_sha text NOT NULL, - status text NOT NULL, - target_kind text, - target_project_name text, - target_script_name text, - updated_at integer NOT NULL, - url text - ); - - CREATE UNIQUE INDEX app_deployment_run_active_app_idx - ON app_deployment_run (app_id) - WHERE status IN ('queued', 'preparing', 'building', 'submitting', 'submitted', 'activating'); - - CREATE TABLE api_command ( - attempt_count integer DEFAULT 0 NOT NULL, - claim_expires_at integer, - claim_owner text, - completed_at integer, - created_at integer NOT NULL, - dedupe_key text NOT NULL, - id text PRIMARY KEY NOT NULL, - kind text NOT NULL, - last_error_code text, - last_error_message text, - payload_json text NOT NULL, - status text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE UNIQUE INDEX api_command_dedupe_idx ON api_command (dedupe_key); - - INSERT INTO app ( - id, - organization_id, - owner_account_id, - name, - created_at, - updated_at - ) - VALUES ('${APP_ID}', '01J00000000000000000000006', '${OWNER_ID}', 'App', 1, 1); - `); - - return database; -} - -function createBindings(database: SqliteD1Database) { - const queue = createApiCommandQueueStub(); - - return { - bindings: { - API_COMMAND_QUEUE: queue, - CLOUDFLARE_ACCOUNT_ID: "test-account", - CLOUDFLARE_API_TOKEN: "test-token", - CLOUDFLARE_ZONE_ID: "test-zone", - DB: database, - MOSOO_APP_DEPLOYMENT_DOMAIN: "apps.localhost", - } as Pick< - ApiBindings, - | "API_COMMAND_QUEUE" - | "CLOUDFLARE_ACCOUNT_ID" - | "CLOUDFLARE_API_TOKEN" - | "CLOUDFLARE_ZONE_ID" - | "DB" - | "MOSOO_APP_DEPLOYMENT_DOMAIN" - >, - queue, - }; -} - -const githubFetch: typeof fetch = async (input) => { - const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; - - if (url === "https://api.github.com/repos/samzong/awire") { - return Response.json({ - clone_url: "https://github.com/samzong/awire.git", - default_branch: "main", - name: "awire", - owner: { login: "samzong" }, - private: false, - }); - } - - if (url === "https://api.github.com/repos/samzong/awire/branches/main") { - return Response.json({ - commit: { sha: "abc123" }, - }); - } - - return new Response("not found", { status: 404 }); -}; - -async function setDeploymentRunUpdatedAt( - database: SqliteD1Database, - runId: string, - updatedAt: number, -): Promise { - await database - .prepare("UPDATE app_deployment_run SET updated_at = ? WHERE id = ?") - .bind(updatedAt, runId) - .run(); -} - -async function seedExpiredRunningDispatch( - database: SqliteD1Database, - runId: string, -): Promise { - await database - .prepare( - "UPDATE api_command SET status = 'running', claim_owner = 'stale-owner', claim_expires_at = 1 WHERE dedupe_key = ?", - ) - .bind(createAppDeploymentRunDispatchDedupeKey(runId)) - .run(); - await setDeploymentRunUpdatedAt(database, runId, 1); -} - -async function seedExhaustedRunningDispatch( - database: SqliteD1Database, - runId: string, -): Promise { - await database - .prepare( - `UPDATE api_command - SET status = 'running', - claim_owner = 'worker-owner', - claim_expires_at = ?, - attempt_count = ?, - last_error_code = 'SandboxError', - last_error_message = 'Container is starting. Please retry in a moment.' - WHERE dedupe_key = ?`, - ) - .bind( - NOW_MS + 60_000, - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - createAppDeploymentRunDispatchDedupeKey(runId), - ) - .run(); -} - -async function seedQueuedDispatch(database: SqliteD1Database, runId: string): Promise { - await database - .prepare( - `INSERT INTO api_command ( - attempt_count, - claim_expires_at, - claim_owner, - completed_at, - created_at, - dedupe_key, - id, - kind, - last_error_code, - last_error_message, - payload_json, - status, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - 0, - null, - null, - null, - NOW_MS, - createAppDeploymentRunDispatchDedupeKey(runId), - `cmd-${runId}`, - "app_deployment_run_dispatch", - null, - null, - JSON.stringify({ appDeploymentRunId: runId }), - "queued", - NOW_MS, - ) - .run(); -} - -async function seedDeployment( - database: SqliteD1Database, - input: { appId: string; deploymentId: string }, -): Promise { - await database - .prepare( - `INSERT INTO app_deployment ( - app_id, created_at, default_branch, deleted_at, id, last_successful_url, - latest_run_id, mosoo_subdomain, owner_account_id, repo_name, repo_owner, - repo_url, source_kind, updated_at - ) - VALUES (?, ?, 'main', NULL, ?, NULL, NULL, ?, ?, 'awire', 'samzong', - 'https://github.com/samzong/awire.git', 'github_public', ?)`, - ) - .bind( - input.appId, - NOW_MS, - input.deploymentId, - `app-${input.appId.toLowerCase()}`, - OWNER_ID, - NOW_MS, - ) - .run(); -} - -async function seedDeploymentRun( - database: SqliteD1Database, - input: { - appId: string; - deploymentId: string; - runId: string; - status?: string; - url?: string | null; - }, -): Promise { - await database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, created_at, deployment_id, id, source_branch, source_commit_sha, - status, updated_at, url - ) - VALUES (?, ?, ?, ?, 'main', 'abc123', ?, ?, ?)`, - ) - .bind( - input.appId, - NOW_MS, - input.deploymentId, - input.runId, - input.status ?? "failed", - NOW_MS, - input.url ?? null, - ) - .run(); -} - -function runListRunId(index: number): string { - return `01J00000000000000000${String(index).padStart(6, "0")}`; -} - -const unexpectedSandboxCall = async (): Promise => { - throw new Error("Unexpected sandbox method call."); -}; - -const successfulCommandResult = (stdout = "") => ({ - exitCode: 0, - stderr: "", - stdout, - success: true as const, -}); - -function createCloudflareDeleteRecorder( - deleted: string[], - overrides: Partial = {}, -): CloudflareDeploymentClient { - return { - async deletePagesDomain(input) { - deleted.push(`pages-domain:${input.hostname}`); - }, - async deletePagesProject(input) { - deleted.push(`pages:${input.projectName}`); - }, - async deleteWorkerDomain(input) { - deleted.push(`worker-domain:${input.hostname}`); - }, - async deleteWorkerRoute(input) { - deleted.push(`worker-route:${input.hostname}`); - }, - async deleteWorkerScript(input) { - deleted.push(`worker:${input.scriptName}`); - }, - async deployWorkerModule() { - throw new Error("Unexpected Worker deploy."); - }, - async ensurePagesProject() { - throw new Error("Unexpected Pages project creation."); - }, - async ensurePagesDomain() { - throw new Error("Unexpected Pages domain creation."); - }, - async ensureWorkerDomain() { - throw new Error("Unexpected Worker domain creation."); - }, - async ensureWorkerRoute() { - throw new Error("Unexpected Worker route creation."); - }, - async getLatestPagesDeployment() { - throw new Error("Unexpected Pages deployment read."); - }, - ...overrides, - }; -} - -function createTestSandboxHandle( - id: string, - events: string[], - mode: "destroy-only" | "deployment", -): SandboxHandle { - const base = { - configureNetworkConstraints: async () => {}, - createBackup: unexpectedSandboxCall, - deleteSession: unexpectedSandboxCall, - getSession: unexpectedSandboxCall, - mkdir: unexpectedSandboxCall, - mountBucket: unexpectedSandboxCall, - restoreBackup: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - terminal: unexpectedSandboxCall, - unmountBucket: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - wsConnect: unexpectedSandboxCall, - }; - - if (mode === "destroy-only") { - return { - ...base, - createSession: unexpectedSandboxCall, - destroy: async () => { - events.push(id); - }, - exec: unexpectedSandboxCall, - readFile: unexpectedSandboxCall, - setKeepAlive: async () => {}, - writeFile: unexpectedSandboxCall, - } as SandboxHandle; - } - - const session = { - exec: async (command: string) => { - events.push(`${id}:session:${command}`); - return successfulCommandResult(); - }, - mkdir: unexpectedSandboxCall, - readFile: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - writeFile: unexpectedSandboxCall, - }; - - return { - ...base, - createSession: async () => session, - destroy: async () => { - events.push(`${id}:destroy`); - }, - exec: async (command) => { - events.push(`${id}:${command}`); - return successfulCommandResult(command.includes("find . -type f") ? "./index.html\n" : ""); - }, - readFile: async (_path, options) => ({ - content: options?.encoding === "base64" ? "YXJjaGl2ZQ==" : "
Hello
", - encoding: options?.encoding ?? "utf8", - }), - setKeepAlive: async (keepAlive) => { - events.push(`${id}:keep-alive:${String(keepAlive)}`); - }, - writeFile: async (path) => { - events.push(`${id}:write:${path}`); - }, - } as SandboxHandle; -} - -function createWorkerDeploymentSandboxHandle(id: string, events: string[]): SandboxHandle { - const base = { - configureNetworkConstraints: async () => {}, - createBackup: unexpectedSandboxCall, - deleteSession: unexpectedSandboxCall, - getSession: unexpectedSandboxCall, - mkdir: unexpectedSandboxCall, - mountBucket: unexpectedSandboxCall, - restoreBackup: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - terminal: unexpectedSandboxCall, - unmountBucket: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - wsConnect: unexpectedSandboxCall, - }; - - return { - ...base, - createSession: unexpectedSandboxCall, - destroy: async () => { - events.push(`${id}:destroy`); - }, - exec: async (command) => { - events.push(`${id}:${command}`); - return successfulCommandResult( - command.includes("find . -type f -print") - ? "./.mosoo.toml\n./wrangler.toml\n./src/index.js\n" - : "", - ); - }, - readFile: async (path, options) => { - let content = "export default { fetch() { return new Response('ok'); } };\n"; - - if (path.endsWith(".mosoo.toml")) { - content = [ - "schema = 1", - 'name = "worker-app"', - "", - "[deploy]", - 'adapter = "cloudflare-workers"', - 'wrangler = "wrangler.toml"', - "", - ].join("\n"); - } else if (path.endsWith("wrangler.toml")) { - content = 'name = "worker-app"\nmain = "src/index.js"\n'; - } - - return { content, encoding: options?.encoding ?? "utf8" }; - }, - setKeepAlive: async (keepAlive) => { - events.push(`${id}:keep-alive:${String(keepAlive)}`); - }, - writeFile: unexpectedSandboxCall, - } as SandboxHandle; -} - -describe("app deployment service", () => { - test("creates a deployment run and queues dispatch", async () => { - const database = createDatabase(); - const { bindings, queue } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { - appId: APP_ID, - configPath: ".mosoo.toml", - repoUrl: "https://github.com/samzong/awire.git", - }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - expect(run).toMatchObject({ - liveUrl: null, - plannedUrl: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - sourceBranch: "main", - sourceCommitSha: "abc123", - status: "queued", - }); - expect(queue.sent).toHaveLength(1); - - const command = await database.app().select().from(apiCommandsTable).limit(1).get(); - - expect(command).toMatchObject({ - kind: "app_deployment_run_dispatch", - status: "queued", - }); - expect(JSON.parse(command?.payloadJson ?? "{}")).toEqual({ - appDeploymentRunId: run.id, - }); - - const deployment = await getAppDeployment(bindings, VIEWER, APP_ID); - expect(deployment?.latestRun?.id).toBe(run.id); - - await database.prepare("UPDATE app_deployment SET latest_run_id = NULL").run(); - - const deploymentAfterPointerDrift = await getAppDeployment(bindings, VIEWER, APP_ID); - expect(deploymentAfterPointerDrift?.latestRun?.id).toBe(run.id); - }); - - test("redrives a dropped deployment dispatch without leaving its run queued forever", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deliveredCommandIds: string[] = []; - let queueUnavailable = true; - const deferredBindings = { - ...bindings, - API_COMMAND_QUEUE: { - async send(input: { commandId: string }): Promise { - if (queueUnavailable) { - throw new Error("Queue response timed out."); - } - - deliveredCommandIds.push(input.commandId); - }, - }, - }; - - const run = await deployApp( - deferredBindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - const command = await database - .app() - .select({ - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .get(); - const runRow = await database - .app() - .select({ - errorCode: appDeploymentRunsTable.errorCode, - status: appDeploymentRunsTable.status, - }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(command).toEqual({ - lastErrorCode: API_COMMAND_QUEUE_SEND_FAILED_CODE, - status: "queued", - }); - expect(runRow).toEqual({ errorCode: null, status: "queued" }); - - queueUnavailable = false; - await createApiWorker().scheduled( - { scheduledTime: NOW_MS } as ScheduledController, - deferredBindings as ApiBindings, - ); - - const redrivenCommand = await database - .app() - .select({ - id: apiCommandsTable.id, - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(run.id))) - .get(); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-after-redrive", - externalProjectId: "pages-project-after-redrive", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Recovered
" } }, - }; - }, - }; - - expect(deliveredCommandIds).toContain(redrivenCommand?.id); - expect(redrivenCommand).toMatchObject({ lastErrorCode: null, status: "queued" }); - - await dispatchAppDeploymentRun( - deferredBindings as ApiBindings, - { appDeploymentRunId: run.id }, - { runner }, - ); - - const status = await getAppDeploymentStatus(deferredBindings, VIEWER, APP_ID); - expect(status).toMatchObject({ status: "success" }); - }); - - test("rejects a second deploy while a run is active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await expect( - deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ), - ).rejects.toThrow("An App deployment run is already active."); - }); - - test("recovers an active deployment run without a dispatch command", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await database.prepare("DELETE FROM api_command").run(); - await setDeploymentRunUpdatedAt(database, firstRun.id, 1); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: "deployment_dispatch_missing", - status: "failed", - }); - }); - - test("recovers an active deployment run with an expired running dispatch command", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExpiredRunningDispatch(database, firstRun.id); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: "deployment_dispatch_expired", - status: "failed", - }); - }); - - test("recovers an active deployment run with an expired running dispatch from reads", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExpiredRunningDispatch(database, run.id); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }); - - await expect(getAppDeployment(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - latestRun: { - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }, - }); - - await expect(listAppDeploymentRuns(bindings, VIEWER, APP_ID, 10)).resolves.toEqual([ - expect.objectContaining({ - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }), - ]); - - const runRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .limit(1) - .get(); - - expect(runRow).toMatchObject({ - errorCode: "deployment_dispatch_expired", - status: "failed", - }); - }); - - test("fails an active deployment run when dispatch retries are exhausted", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExhaustedRunningDispatch(database, firstRun.id); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - errorMessage: expect.stringContaining("Container is starting"), - id: firstRun.id, - status: "failed", - }); - - const dispatchCommand = await database - .app() - .select({ - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(firstRun.id))) - .limit(1) - .get(); - - expect(dispatchCommand).toMatchObject({ - lastErrorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - status: "failed", - }); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - }); - - test("keeps a fresh active deployment run without a dispatch command active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: currentTimestampMs }, - ); - - await database.prepare("DELETE FROM api_command").run(); - await setDeploymentRunUpdatedAt(database, firstRun.id, currentTimestampMs()); - - await expect( - deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: currentTimestampMs }, - ), - ).rejects.toThrow("An App deployment run is already active."); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: null, - status: "queued", - }); - }); - - test("dispatches a queued deployment run to success", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - let buildPlanName: string | null = null; - const runner: AppDeploymentBuildRunner = { - async build({ plan }) { - buildPlanName = plan.generatedWranglerConfig; - }, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const deployment = await getAppDeployment(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(buildPlanName).toContain(`name = "app-${APP_ID.toLowerCase()}"`); - expect(status).toMatchObject({ - liveUrl: targetUrl, - status: "success", - }); - expect(deployment?.liveUrl).toBe(targetUrl); - expect(runRow).toMatchObject({ - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - status: "success", - targetKind: "cloudflare_pages", - targetProjectName: `app-${APP_ID.toLowerCase()}`, - url: targetUrl, - }); - }); - - test("does not delete stable Cloudflare resources when an inactive run finishes", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await database - .prepare("UPDATE app_deployment_run SET status = 'failed' WHERE id = ?") - .bind(run.id) - .run(); - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - cloudflareClient: createCloudflareDeleteRecorder(deleted), - runner, - }, - ); - - const runRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .limit(1) - .get(); - - expect(deleted).toEqual([]); - expect(runRow?.status).toBe("failed"); - }); - - test("compensates resources created after deployment deletion", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const externallyCreated: string[] = []; - const cloudflareClient = createCloudflareDeleteRecorder(deleted); - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient }); - externallyCreated.push(`pages:${APP_ID}`); - - return { - externalDeploymentId: "pages-deployment-after-delete", - externalProjectId: "pages-project-after-delete", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient, runner }, - ); - - const deployment = await database - .app() - .select({ deletedAt: appDeploymentsTable.deletedAt }) - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - const runRow = await database - .app() - .select({ - errorCode: appDeploymentRunsTable.errorCode, - status: appDeploymentRunsTable.status, - }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(externallyCreated).toEqual([`pages:${APP_ID}`]); - expect(deployment?.deletedAt).toBeNumber(); - expect(runRow).toEqual({ errorCode: "deployment_deleted", status: "failed" }); - expect(deleted).toHaveLength(10); - expect(deleted.filter((entry) => entry.startsWith("pages:"))).toHaveLength(2); - expect(deleted.filter((entry) => entry.startsWith("worker:"))).toHaveLength(2); - }); - - test("does not compensate a deleted deployment after a replacement is active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const cloudflareClient = createCloudflareDeleteRecorder(deleted); - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient }); - await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - return { - externalDeploymentId: "pages-deployment-after-replacement", - externalProjectId: "pages-project-after-replacement", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient, runner }, - ); - - const activeDeployment = await getAppDeployment(bindings, VIEWER, APP_ID); - - expect(activeDeployment).not.toBeNull(); - expect(deleted).toHaveLength(5); - }); - - test("deletes the active deployment and fails the active run", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const destroyed: string[] = []; - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createTestSandboxHandle(runtimeSubjectId, destroyed, "destroy-only"); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await expect( - deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder(deleted), - }, - ), - ).resolves.toEqual({ ok: true }); - - await expect(getAppDeployment(bindings, VIEWER, APP_ID)).resolves.toBeNull(); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const deploymentRow = await database.app().select().from(appDeploymentsTable).limit(1).get(); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - errorCode: "deployment_deleted", - id: run.id, - status: "failed", - }); - expect(deploymentRow?.deletedAt).toBeNumber(); - expect(runRow?.status).toBe("failed"); - expect(deleted).toContain(`pages-domain:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`pages:app-${APP_ID.toLowerCase()}`); - expect(deleted).toContain(`worker-domain:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`worker-route:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`worker:app-${APP_ID.toLowerCase()}`); - expect(destroyed).toContain(`${run.id}-build`); - expect(destroyed).toContain(`${run.id}-deploy`); - }); - - test("keeps a deployment retryable when Cloudflare cleanup fails", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const failedDeletes: string[] = []; - const successfulDeletes: string[] = []; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const failingClient = createCloudflareDeleteRecorder(failedDeletes, { - async deletePagesProject(input) { - failedDeletes.push(`pages:${input.projectName}`); - throw new Error("Cloudflare API unavailable."); - }, - }); - - await expect( - deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient: failingClient }), - ).rejects.toMatchObject({ - code: API_ERROR_CODE.appDeploymentCleanupFailed, - name: "ApiError", - } satisfies Partial); - - const deploymentAfterFailure = await database - .app() - .select() - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - const runAfterFailure = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(deploymentAfterFailure).toMatchObject({ deletedAt: null }); - expect(runAfterFailure).toMatchObject({ - errorCode: "deployment_deleted", - status: "failed", - }); - expect(failedDeletes).toHaveLength(5); - - await expect( - deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { cloudflareClient: createCloudflareDeleteRecorder(successfulDeletes) }, - ), - ).resolves.toEqual({ ok: true }); - - const deploymentAfterRetry = await database - .app() - .select({ deletedAt: appDeploymentsTable.deletedAt }) - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - - expect(deploymentAfterRetry?.deletedAt).toBeNumber(); - expect(successfulDeletes).toHaveLength(5); - }); - - test("does not expose a live URL after deleting a successful deployment", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - liveUrl: null, - status: "success", - }); - }); - - test("revokes a bound Agent capability after local deployment cleanup succeeds", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { runner }, - ); - await database - .prepare("UPDATE app_deployment_run SET plan_json = ? WHERE id = ?") - .bind( - JSON.stringify({ - agentBindings: [{ env: "MOSOO_AGENT", expose: "public_thread", name: "Support" }], - }), - run.id, - ) - .run(); - - const authority = { - appId: run.appId, - binding: { env: "MOSOO_AGENT", expose: "public_thread" as const, name: "Support" }, - deploymentId: run.deploymentId, - deploymentRunId: run.id, - }; - - await expect(getDeploymentAgentCapabilityAuthority(database, authority)).resolves.toEqual({ - authorized: true, - }); - - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority)).resolves.toEqual({ - authorized: false, - reason: "deployment_deleted", - }); - }); - - test("uses the Pages deployment URL while the custom domain is pending", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const calls: string[] = []; - const pagesUrl = "https://app-example.pages.dev"; - const cloudflareClient = createCloudflareDeleteRecorder([], { - ensurePagesDomain: async () => ({ status: "initializing" }), - ensurePagesProject: async () => ({ projectId: "pages-project-1" }), - getLatestPagesDeployment: async () => ({ - deploymentId: "pages-deployment-1", - url: pagesUrl, - }), - }); - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createTestSandboxHandle(runtimeSubjectId, calls, "deployment"); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient }, - ); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - liveUrl: pagesUrl, - status: "success", - }); - expect(runRow).toMatchObject({ - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - status: "success", - url: pagesUrl, - }); - expect(calls.some((call) => call.includes("wrangler pages deploy"))).toBe(true); - }); - - test("deploys worker modules with a Worker route and custom domain", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const calls: string[] = []; - const cloudflareCalls: string[] = []; - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createWorkerDeploymentSandboxHandle(runtimeSubjectId, calls); - - const cloudflareClient = createCloudflareDeleteRecorder([], { - async deployWorkerModule(input) { - cloudflareCalls.push( - `worker:${input.scriptName}:${input.mainModuleName}:${input.scriptContent.trim()}`, - ); - return { deploymentId: "worker-deployment-1", versionId: "worker-version-1" }; - }, - async ensureWorkerDomain(input) { - cloudflareCalls.push(`worker-domain:${input.hostname}:${input.scriptName}`); - }, - async ensureWorkerRoute(input) { - cloudflareCalls.push(`worker-route:${input.hostname}:${input.scriptName}`); - }, - }); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, configPath: ".mosoo.toml", repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient }, - ); - - const targetName = `app-${APP_ID.toLowerCase()}`; - const hostname = `${targetName}.apps.localhost`; - const targetUrl = `https://${hostname}`; - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - liveUrl: targetUrl, - status: "success", - }); - expect(runRow).toMatchObject({ - externalDeploymentId: "worker-deployment-1", - externalProjectId: null, - externalVersionId: "worker-version-1", - status: "success", - targetKind: "cloudflare_worker", - targetScriptName: targetName, - url: targetUrl, - }); - expect(cloudflareCalls).toEqual([ - `worker:${targetName}:index.js:export default { fetch() { return new Response('ok'); } };`, - `worker-route:${hostname}:${targetName}`, - `worker-domain:${hostname}:${targetName}`, - ]); - }); - - test("dead letters active deployment runs without overwriting terminal runs", async () => { - const database = createDatabase(); - const { bindings, queue } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const queued = queue.sent[0]; - - if (queued === undefined) { - throw new Error("Expected deployment dispatch queue message."); - } - - await database.app().update(apiCommandsTable).set({ payloadJson: "{}" }).run(); - - await processApiCommandDeadLetterMessage( - bindings as ApiBindings, - createRecordedQueueMessage({ body: queued.body }).message, - () => NOW_MS + 1, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "queue_dead_lettered", - id: run.id, - status: "failed", - }); - - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - await processApiCommandDeadLetterMessage( - bindings as ApiBindings, - createRecordedQueueMessage({ body: queued.body }).message, - () => NOW_MS + 2, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "queue_dead_lettered", - id: run.id, - status: "failed", - }); - }); - - test("lists deployment runs newest-first", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(1), - status: "failed", - }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(2), - status: "success", - url: targetUrl, - }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(3), - status: "queued", - }); - await seedQueuedDispatch(database, runListRunId(3)); - - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - - expect(runs.map((run) => run.id)).toEqual([runListRunId(3), runListRunId(2), runListRunId(1)]); - expect(runs[0]).toMatchObject({ liveUrl: null, status: "queued" }); - expect(runs[1]).toMatchObject({ - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - liveUrl: targetUrl, - plannedUrl: targetUrl, - sourceBranch: "main", - sourceCommitSha: "abc123", - status: "success", - }); - }); - - test("applies the default run list limit and caps requested limits", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - - for (let index = 1; index <= 55; index += 1) { - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(index), - }); - } - - const defaultRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - expect(defaultRuns).toHaveLength(20); - expect(defaultRuns[0]?.id).toBe(runListRunId(55)); - expect(defaultRuns[19]?.id).toBe(runListRunId(36)); - - const limitedRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID, 5); - expect(limitedRuns.map((run) => run.id)).toEqual([ - runListRunId(55), - runListRunId(54), - runListRunId(53), - runListRunId(52), - runListRunId(51), - ]); - - const cappedRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID, 200); - expect(cappedRuns).toHaveLength(50); - expect(cappedRuns[49]?.id).toBe(runListRunId(6)); - - await expect(listAppDeploymentRuns(bindings, VIEWER, APP_ID, 0)).rejects.toThrow( - "limit must be a positive integer.", - ); - }); - - test("does not list deployment runs from another app", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await database - .prepare( - `INSERT INTO app (id, organization_id, owner_account_id, name, created_at, updated_at) - VALUES (?, '01J00000000000000000000006', ?, 'Other App', 1, 1)`, - ) - .bind(OTHER_APP_ID, OWNER_ID) - .run(); - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - await seedDeployment(database, { appId: OTHER_APP_ID, deploymentId: OTHER_DEPLOYMENT_ID }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(1), - }); - await seedDeploymentRun(database, { - appId: OTHER_APP_ID, - deploymentId: OTHER_DEPLOYMENT_ID, - runId: runListRunId(2), - }); - - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - const otherRuns = await listAppDeploymentRuns(bindings, VIEWER, OTHER_APP_ID); - - expect(runs.map((run) => run.id)).toEqual([runListRunId(1)]); - expect(otherRuns.map((run) => run.id)).toEqual([runListRunId(2)]); - }); - - test("keeps listing runs after deleteAppDeployment and hides their live URLs", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - // The deployment is soft-deleted: run history stays listed, but liveUrl is - // suppressed because the deployment row carries deletedAt. - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - - expect(runs).toHaveLength(1); - expect(runs[0]).toMatchObject({ - id: run.id, - liveUrl: null, - status: "success", - }); - }); -}); diff --git a/apps/api/tests/app-overview.test.ts b/apps/api/tests/app-overview.test.ts deleted file mode 100644 index b1e83887..00000000 --- a/apps/api/tests/app-overview.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { isInputObjectType, isObjectType } from "graphql"; - -import { createGraphQLSchema } from "../src/adapters/graphql/create-graphql-schema"; -import { createAppDeploymentRunDispatchDedupeKey } from "../src/modules/api-command/application/api-command-enqueue"; -import { - getAppOverview, - getControlPlaneOverview, -} from "../src/modules/apps/application/app-overview.service"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createApiTestFixture } from "./helpers/api-test-fixture"; - -const OVERVIEW_DEPLOYMENT_ID = "01J000000000000000000000D1"; -const OVERVIEW_DEPLOYMENT_RUN_ID = "01J000000000000000000000D2"; - -function createOverviewDeploymentUrl(appId: string, domain: string): string { - return `https://app-${appId.toLowerCase()}.${domain}`; -} - -function makeForeignViewer(): AuthenticatedViewer { - return { - email: "foreign@example.com", - emailVerified: true, - id: "01J000000000000000000000F1", - imageUrl: null, - name: "Foreign Viewer", - }; -} - -async function insertOverviewAgent( - fixture: Awaited>, - input: { - id: string; - name: string; - updatedAt: number; - }, -): Promise { - await fixture.database - .prepare( - `INSERT INTO agent ( - config_json, - created_at, - description, - id, - kind, - model, - name, - owner_account_id, - app_id, - prompt, - provider, - runtime_id, - status, - updated_at, - visibility - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - JSON.stringify({ - packageMcpServers: [], - packageResolution: null, - packageSkills: [], - providerOptions: {}, - }), - 1, - "Extra overview fixture.", - input.id, - "cattle", - "gpt-5.4", - input.name, - fixture.viewer.id, - fixture.ids.appId, - "Help with overview tests.", - "openai", - "openai-runtime", - "published", - input.updatedAt, - "private", - ) - .run(); -} - -async function insertOverviewCredentialMetadata( - fixture: Awaited>, -): Promise { - await fixture.database - .prepare( - `INSERT INTO vendor_credential ( - api_base, - api_key_secret_id, - created_at, - id, - is_default, - models, - name, - app_id, - updated_at, - vendor_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - "https://api.example.com/v1", - "01J000000000000000000000F2", - 1, - "01J000000000000000000000F3", - 1, - JSON.stringify(["custom-a", "custom-b"]), - "Custom Provider", - fixture.ids.appId, - 1, - "openai-compatible", - ) - .run(); -} - -async function insertOverviewDeploymentMetadata( - fixture: Awaited>, -): Promise<{ liveUrl: string }> { - const liveUrl = createOverviewDeploymentUrl( - fixture.ids.appId, - fixture.bindings.MOSOO_APP_DEPLOYMENT_DOMAIN, - ); - - await fixture.database - .prepare( - `INSERT INTO app_deployment ( - app_id, - created_at, - default_branch, - deleted_at, - id, - last_successful_url, - mosoo_subdomain, - owner_account_id, - repo_name, - repo_owner, - repo_url, - source_kind, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 1, - "main", - null, - OVERVIEW_DEPLOYMENT_ID, - liveUrl, - `app-${fixture.ids.appId.toLowerCase()}`, - fixture.viewer.id, - "awire", - "samzong", - "https://github.com/samzong/awire.git", - "github_public", - 2, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, - created_at, - deployment_id, - error_code, - error_message, - id, - source_branch, - source_commit_sha, - status, - target_kind, - updated_at, - url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 1, - OVERVIEW_DEPLOYMENT_ID, - null, - null, - OVERVIEW_DEPLOYMENT_RUN_ID, - "main", - "abc123", - "success", - "cloudflare_pages", - 2, - liveUrl, - ) - .run(); - - return { liveUrl }; -} - -describe("App overview", () => { - test("keeps the GraphQL overview surface App-scoped and secret-free", () => { - const schema = createGraphQLSchema(); - const query = schema.getQueryType(); - const mutation = schema.getMutationType(); - const appOverview = schema.getType("AppOverview"); - const credential = schema.getType("AppOverviewProviderCredential"); - const deployment = schema.getType("AppDeployment"); - const deploymentRun = schema.getType("AppDeploymentRun"); - const deployInput = schema.getType("DeployAppInput"); - - if ( - !query || - !mutation || - !isObjectType(appOverview) || - !isObjectType(credential) || - !isObjectType(deployment) || - !isObjectType(deploymentRun) || - !isInputObjectType(deployInput) - ) { - throw new Error("Expected App overview GraphQL types."); - } - - const overview = query.getFields().appOverview; - const deploymentStatus = query.getFields().appDeploymentStatus; - const controlPlaneOverview = query.getFields().controlPlaneOverview; - const deploy = mutation.getFields().deployApp; - const deleteDeployment = mutation.getFields().deleteAppDeployment; - - expect(overview).toBeDefined(); - expect(String(overview.args.find((arg) => arg.name === "appId")?.type)).toBe("ULID!"); - expect(String(overview.args.find((arg) => arg.name === "agentLimit")?.type)).toBe("Int"); - expect(String(overview.args.find((arg) => arg.name === "credentialLimit")?.type)).toBe("Int"); - expect(String(appOverview.getFields().deployment.type)).toBe("AppDeployment"); - expect(String(deployment.getFields().latestRun.type)).toBe("AppDeploymentRun"); - expect(String(deploymentRun.getFields().status.type)).toBe("AppDeploymentRunStatus!"); - expect(String(deploymentStatus.type)).toBe("AppDeploymentRun"); - expect(String(deploy.type)).toBe("AppDeploymentRun!"); - expect(String(deleteDeployment.type)).toBe("OperationResult!"); - expect(String(deployInput.getFields().repoUrl.type)).toBe("String!"); - expect(controlPlaneOverview).toBeDefined(); - expect(String(controlPlaneOverview.args.find((arg) => arg.name === "appLimit")?.type)).toBe( - "Int", - ); - expect(credential.getFields().maskedApiKey).toBeUndefined(); - expect(credential.getFields().apiBase).toBeUndefined(); - expect(String(credential.getFields().status.type)).toBe("AppOverviewProviderCredentialStatus!"); - }); - - test("returns limited control-plane summary without reading credential secrets", async () => { - const fixture = await createApiTestFixture(); - await insertOverviewAgent(fixture, { - id: "01J000000000000000000000F4", - name: "Newest Agent", - updatedAt: 2, - }); - await insertOverviewCredentialMetadata(fixture); - const deploymentFixture = await insertOverviewDeploymentMetadata(fixture); - - const overview = await getAppOverview(fixture.bindings, fixture.viewer, { - agentLimit: 1, - appId: fixture.ids.appId, - credentialLimit: 10, - }); - - expect(overview.app).toMatchObject({ - id: fixture.ids.appId, - name: "Default App", - }); - expect(overview.deployment).toMatchObject({ - latestRun: { - liveUrl: deploymentFixture.liveUrl, - status: "success", - targetKind: "cloudflare_pages", - }, - liveUrl: deploymentFixture.liveUrl, - plannedUrl: deploymentFixture.liveUrl, - repoName: "awire", - repoOwner: "samzong", - }); - expect(overview.boundAgents).toEqual([]); - expect(overview.agents).toMatchObject({ - hasMore: true, - limit: 1, - }); - expect(overview.agents.items).toEqual([ - expect.objectContaining({ - id: "01J000000000000000000000F4", - model: "gpt-5.4", - name: "Newest Agent", - provider: "openai", - runtimeId: "openai-runtime", - status: "published", - }), - ]); - expect(overview.providerCredentials).toMatchObject({ - configuredCount: 1, - hasMore: false, - limit: 10, - }); - expect(overview.providerCredentials.items).toEqual([ - { - appId: fixture.ids.appId, - hasCustomApiBase: true, - id: "01J000000000000000000000F3", - isDefault: true, - modelCount: 2, - name: "Custom Provider", - status: "configured", - vendorId: "openai-compatible", - }, - ]); - expect(overview.providerCredentials.byVendor).toEqual([ - { - count: 1, - defaultCredentialId: "01J000000000000000000000F3", - vendorId: "openai-compatible", - }, - ]); - }); - - test("keeps bound agents from the latest parsed deployment plan during a new active run", async () => { - const fixture = await createApiTestFixture(); - await insertOverviewDeploymentMetadata(fixture); - await insertOverviewAgent(fixture, { - id: "01J000000000000000000000F4", - name: "quizmaster", - updatedAt: 2, - }); - - await fixture.database - .prepare("UPDATE app_deployment_run SET plan_json = ? WHERE id = ?") - .bind( - JSON.stringify({ - agentBindings: [{ env: "QUIZ_THREAD_URL", expose: "public_thread", name: "quizmaster" }], - }), - OVERVIEW_DEPLOYMENT_RUN_ID, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, - created_at, - deployment_id, - error_code, - error_message, - id, - source_branch, - source_commit_sha, - status, - target_kind, - updated_at, - url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 3, - OVERVIEW_DEPLOYMENT_ID, - null, - null, - "01J000000000000000000000E2", - "main", - "def456", - "preparing", - null, - 4, - null, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO api_command ( - attempt_count, - claim_expires_at, - claim_owner, - completed_at, - created_at, - dedupe_key, - id, - kind, - last_error_code, - last_error_message, - payload_json, - status, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - 0, - null, - null, - null, - 3, - createAppDeploymentRunDispatchDedupeKey("01J000000000000000000000E2"), - "01J000000000000000000000E3", - "app_deployment_run_dispatch", - null, - null, - JSON.stringify({ appDeploymentRunId: "01J000000000000000000000E2" }), - "queued", - 3, - ) - .run(); - - const overview = await getAppOverview(fixture.bindings, fixture.viewer, { - appId: fixture.ids.appId, - }); - - expect(overview.deployment?.latestRun).toMatchObject({ - id: "01J000000000000000000000E2", - status: "preparing", - }); - expect(overview.boundAgents).toEqual([ - { - agentId: "01J000000000000000000000F4", - envVar: "QUIZ_THREAD_URL", - expose: "public_thread", - name: "quizmaster", - }, - ]); - }); - - test("returns current-user control-plane overview for generated CLI list flows", async () => { - const fixture = await createApiTestFixture(); - await insertOverviewCredentialMetadata(fixture); - - const overview = await getControlPlaneOverview(fixture.bindings, fixture.viewer, { - agentLimit: 10, - appLimit: 10, - credentialLimit: 10, - }); - - expect(overview.activeOrganization).toMatchObject({ - id: fixture.ids.organizationId, - name: "mosoo API Test", - }); - expect(overview.apps).toMatchObject({ - hasMore: false, - limit: 10, - }); - expect(overview.apps.items).toHaveLength(1); - expect(overview.apps.items[0]).toMatchObject({ - app: { - id: fixture.ids.appId, - name: "Default App", - }, - deployment: null, - agents: { - hasMore: false, - limit: 10, - }, - providerCredentials: { - configuredCount: 1, - hasMore: false, - limit: 10, - }, - }); - }); - - test("fails closed for viewers that do not own the App", async () => { - const fixture = await createApiTestFixture(); - - await expect( - getAppOverview(fixture.bindings, makeForeignViewer(), { - appId: fixture.ids.appId, - }), - ).rejects.toThrow("You do not have permission"); - }); - - test("rejects invalid overview limits through the API error envelope", async () => { - const fixture = await createApiTestFixture(); - - await expect( - getAppOverview(fixture.bindings, fixture.viewer, { - agentLimit: 0, - appId: fixture.ids.appId, - }), - ).rejects.toThrow("agentLimit must be a positive integer."); - }); -}); diff --git a/apps/api/tests/bound-agent-idempotency.e2e.test.ts b/apps/api/tests/bound-agent-idempotency.e2e.test.ts deleted file mode 100644 index 10ca1712..00000000 --- a/apps/api/tests/bound-agent-idempotency.e2e.test.ts +++ /dev/null @@ -1,858 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createPlatformId, parsePlatformId } from "@mosoo/id"; -import type { AgentDeploymentVersionId, SessionId, SessionMessageId } from "@mosoo/id"; -import { Hono } from "hono"; - -import { registerPublicApiRoute } from "../src/adapters/http/routes/public-api-route"; -import type { ApiCommandMessage } from "../src/modules/api-command/application/api-command-message"; -import { getAccountViewer } from "../src/modules/auth/application/public-api-caller.service"; -import { createBoundAgentThreadAndWait } from "../src/modules/public-api/app-agent-bound-ask.service"; -import { - beginBoundAgentCallIdempotency, - hashBoundAgentCallIdempotencyBody, - hashBoundAgentCallIdempotencySubject, -} from "../src/modules/public-api/app-agent-bound-idempotency.service"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { queueSessionRun } from "../src/modules/runtime/application/session-run.service"; -import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createApiCommandQueueStub, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - nowMsForTest, -} from "./helpers/public-api-http-test-fixture"; -import type { ApiCommandQueueStub, SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -interface DispatchCommandPayload { - session: { id: string }; - sessionRunId: string; -} - -interface DurableCounts { - apiCommand: number; - idempotency: number; - run: number; - session: number; -} - -function createBoundAgentRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - - registerPublicApiRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function capabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -async function insertDeploymentAuthority( - database: SqliteD1Database, - bindings: AppAgentCapabilityClaims["binding"][], -): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, NULL, ?)") - .bind(PUBLIC_API_TEST_IDS.app, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, 'success')", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: bindings }), - ) - .run(); -} - -function createCompletingApiCommandQueue(database: SqliteD1Database): ApiCommandQueueStub { - const sent: ApiCommandQueueStub["sent"] = []; - - return { - sent, - async send(body: ApiCommandMessage, options): Promise { - sent.push({ - body, - contentType: options?.contentType ?? "json", - delaySeconds: options?.delaySeconds ?? null, - id: `completed-${sent.length + 1}`, - }); - - const command = await database - .prepare("SELECT payload_json AS payloadJson FROM api_command WHERE id = ?") - .bind(body.commandId) - .first<{ payloadJson: string }>(); - - if (command === null) { - throw new Error("Queued API command is missing from the durable ledger."); - } - - const payload = JSON.parse(command.payloadJson) as DispatchCommandPayload; - const timestampMs = nowMsForTest() + sent.length; - - await database - .prepare( - `UPDATE session_run - SET status = 'completed', - completed_at = ?, - status_changed_at = ?, - status_event = 'run.complete', - status_seq = status_seq + 1, - status_source = 'driver', - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, timestampMs, payload.sessionRunId) - .run(); - await database - .prepare( - `UPDATE session - SET status = 'IDLE', - status_seq = status_seq + 1, - message_seq_cursor = message_seq_cursor + 1, - last_message_at = ?, - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, payload.session.id) - .run(); - - const session = await database - .prepare("SELECT message_seq_cursor AS seq FROM session WHERE id = ?") - .bind(payload.session.id) - .first<{ seq: number }>(); - - if (session === null) { - throw new Error("Queued Session is missing."); - } - - await database - .prepare( - `INSERT INTO session_message ( - content_text, - created_at, - created_by_account_id, - id, - plan_json, - role, - segments_json, - seq, - session_id, - session_run_id - ) VALUES (?, ?, ?, ?, NULL, 'assistant', NULL, ?, ?, ?)`, - ) - .bind( - "The original bound request completed.", - timestampMs, - PUBLIC_API_TEST_IDS.ownerAccount, - createPlatformId(), - session.seq, - payload.session.id, - payload.sessionRunId, - ) - .run(); - await database - .prepare( - `UPDATE api_command - SET status = 'completed', - completed_at = ?, - last_error_code = NULL, - last_error_message = NULL, - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, body.commandId) - .run(); - }, - }; -} - -function failFirstMatchingStatement(database: D1Database, pattern: RegExp): D1Database { - let failed = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldFail = pattern.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldFail && - !failed && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - return async () => { - failed = true; - throw new Error(`Injected bound admission failure for: ${query}`); - }; - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -async function requestBoundAgent(input: { - claims: AppAgentCapabilityClaims; - database: D1Database; - idempotencyKey?: string; - message: string; - queue: ApiCommandQueueStub; -}): Promise { - const bindings = createPublicHttpTestBindings(input.database, { - apiCommandQueue: input.queue, - }) as ApiBindings; - const token = await mintAppAgentCapabilityToken( - bindings.RUNTIME_ACTION_TOKEN_SECRET, - input.claims, - ); - const headers = new Headers({ "Content-Type": "application/json" }); - - if (input.idempotencyKey !== undefined) { - headers.set("Idempotency-Key", input.idempotencyKey); - } - - return createBoundAgentRouteTestApp().request( - new Request(`https://api.example.com/api/v1/bound/${token}`, { - body: JSON.stringify({ message: input.message }), - headers, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); -} - -async function readDurableCounts(database: SqliteD1Database): Promise { - const [session, run, apiCommand, idempotency] = await Promise.all( - ["session", "session_run", "api_command", "bound_agent_call_idempotency_key"].map((table) => - database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).first<{ count: number }>(), - ), - ); - - if (session === null || run === null || apiCommand === null || idempotency === null) { - throw new Error("Durable count query did not return a row."); - } - - return { - apiCommand: apiCommand.count, - idempotency: idempotency.count, - run: run.count, - session: session.count, - }; -} - -async function withProviderProbeMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => Response.json({ data: [{ id: "gpt-5.4" }] }); - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -async function withAcceleratedClock(operation: () => Promise): Promise { - const originalNow = Date.now; - let nowMs = originalNow(); - Date.now = () => { - nowMs += 30_000; - return nowMs; - }; - - try { - return await operation(); - } finally { - Date.now = originalNow; - } -} - -async function createFixture(bindings = [capabilityClaims().binding]) { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, bindings); - const queue = createCompletingApiCommandQueue(database); - - return { database, queue }; -} - -describe("bound Agent HTTP idempotency", () => { - test("serializes concurrent reservations onto one stable Session identity", async () => { - const database = await createPublicHttpContractDatabase(); - const claims = capabilityClaims(); - const input = { - bodyHash: await hashBoundAgentCallIdempotencyBody("concurrent request"), - idempotencyKey: "concurrent-reservation-327", - subjectHash: await hashBoundAgentCallIdempotencySubject(claims), - }; - - const reservations = await Promise.all([ - beginBoundAgentCallIdempotency(database, input), - beginBoundAgentCallIdempotency(database, input), - ]); - - expect(new Set(reservations.map((reservation) => reservation.reservationId)).size).toBe(1); - expect(new Set(reservations.map((reservation) => reservation.sessionId)).size).toBe(1); - expect(reservations.map((reservation) => reservation.status).toSorted()).toEqual([ - "existing", - "reserved", - ]); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ count: 1 }); - }); - - test("serializes concurrent HTTP retries onto one Session and Run", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const requests = await Promise.all([ - requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "concurrent-http-327", - message: "one concurrent logical request", - queue, - }), - requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "concurrent-http-327", - message: "one concurrent logical request", - queue, - }), - ]); - const bodies = await Promise.all(requests.map((response) => response.json())); - - expect(requests.map((response) => response.status)).toEqual([200, 200]); - expect(bodies[1]).toEqual(bodies[0]); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - }); - - test("does not duplicate admission when the original service call times out", async () => { - const { database } = await createFixture(); - const queue = createApiCommandQueueStub(); - const bindings = createPublicHttpTestBindings(database, { - apiCommandQueue: queue, - }) as ApiBindings; - const claims = capabilityClaims({ exp: Date.now() + 24 * 60 * 60 * 1000 }); - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); - const request = { - bindings, - executionContext: null, - idempotencyKey: "timeout-retry-327", - input: { message: "same logical request" }, - requestUrl: `https://api.example.com/api/v1/bound/${token}`, - token, - } as const; - - await withProviderProbeMock(() => - withAcceleratedClock(async () => { - await expect(createBoundAgentThreadAndWait(request)).rejects.toMatchObject({ - code: "deployment_agent_call_timeout", - status: 504, - }); - await expect(createBoundAgentThreadAndWait(request)).rejects.toMatchObject({ - code: "deployment_agent_call_timeout", - status: 504, - }); - }), - ); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - }); - - test("recovers the original Session and Run after an ambiguous HTTP result", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "logical-request-327", - message: "same logical request", - queue, - }); - const firstBody = await first.json(); - - expect(first.status).toBe(200); - - // Discarding the first result models a response lost after durable admission. - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "logical-request-327", - message: "same logical request", - queue, - }); - - expect(retried.status).toBe(200); - expect(await retried.json()).toEqual(firstBody); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - const reservation = await database - .prepare( - `SELECT id, session_id AS sessionId - FROM bound_agent_call_idempotency_key`, - ) - .first<{ id: string; sessionId: string }>(); - const event = await database - .prepare("SELECT source_event_id AS sourceEventId FROM session_event ORDER BY seq LIMIT 1") - .first<{ sourceEventId: string }>(); - - expect(reservation).not.toBeNull(); - expect(event?.sourceEventId).toBe(reservation?.id); - }); - - test("recovers the first Run without a binding or event receipt after a later Run", async () => { - const { database, queue } = await createFixture(); - let firstBody: unknown; - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "original-run-327", - message: "original logical request", - queue, - }); - firstBody = await first.json(); - expect(first.status).toBe(200); - - const reservation = await database - .prepare( - `SELECT run_id AS runId, session_id AS sessionId - FROM bound_agent_call_idempotency_key`, - ) - .first<{ runId: string; sessionId: string }>(); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (reservation === null || viewer === null) { - throw new Error("Bound idempotency recovery fixture is incomplete."); - } - - // Model interruption before both recovery links become durable. The - // reserved Session itself must still prevent a later Run from becoming - // the replay target for the original key. - await database.prepare("UPDATE bound_agent_call_idempotency_key SET run_id = NULL").run(); - await database.prepare("UPDATE session_event SET source_event_id = id").run(); - - const later = await queueSessionRun({ - bindings: createPublicHttpTestBindings(database, { - apiCommandQueue: queue, - }) as ApiBindings, - executionContext: null, - input: { - accessViewer: viewer, - attachmentIds: [], - clientRequestId: null, - prompt: "intentional later Run", - session: { - agent_id: PUBLIC_API_TEST_IDS.agent, - app_id: PUBLIC_API_TEST_IDS.app, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(reservation.sessionId, "bound Session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/graphql", - viewer, - }); - - expect(later.run.id).not.toBe(reservation.runId); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "original-run-327", - message: "original logical request", - queue, - }); - - expect(retried.status).toBe(200); - expect(await retried.json()).toEqual(firstBody); - await expect( - database.prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ runId: reservation.runId }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 1, - run: 2, - session: 1, - }); - expect(queue.sent).toHaveLength(2); - }); - - test("fails closed when one key is reused for a different body", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "body-conflict-327", - message: "first body", - queue, - }); - const conflict = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "body-conflict-327", - message: "different body", - queue, - }); - - expect(first.status).toBe(200); - expect(conflict.status).toBe(409); - expect(await conflict.json()).toEqual({ - error: { - code: "idempotency_conflict", - message: "Idempotency-Key was already used for a different request.", - }, - }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("scopes the same key to the verified deployment binding identity", async () => { - const alternateClaims = capabilityClaims({ - binding: { - env: "MOSOO_SECOND_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - }); - const { database, queue } = await createFixture([ - capabilityClaims().binding, - alternateClaims.binding, - ]); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "binding-scoped-327", - message: "same body", - queue, - }); - const second = await requestBoundAgent({ - claims: alternateClaims, - database, - idempotencyKey: "binding-scoped-327", - message: "same body", - queue, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 2, - run: 2, - session: 2, - }); - }); - - test("retries the same reserved Session after Session creation fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bINSERT\s+INTO\s+"session"(?:\s|\()/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "session-recovery-327", - message: "recover after Session failure", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 0, - idempotency: 1, - run: 0, - session: 0, - }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "session-recovery-327", - message: "recover after Session failure", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("resumes the reserved Session after Run admission fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bINSERT\s+INTO\s+"session_run"(?:\s|\()/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-recovery-327", - message: "recover after Run failure", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 0, - idempotency: 1, - run: 0, - session: 1, - }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-recovery-327", - message: "recover after Run failure", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("repairs the original Run binding after its first persistence attempt fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bUPDATE\s+"bound_agent_call_idempotency_key"\s+SET\b/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-binding-recovery-327", - message: "recover the accepted Run binding", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - await expect( - database.prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ runId: null }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-binding-recovery-327", - message: "recover the accepted Run binding", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - expect( - await database - .prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key") - .first<{ runId: string | null }>(), - ).toEqual({ - runId: expect.any(String), - }); - }); - - test("rechecks capability revocation before recovering an existing key", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "revoked-retry-327", - message: "authorize every retry", - queue, - }); - expect(first.status).toBe(200); - - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - - const revoked = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "revoked-retry-327", - message: "authorize every retry", - queue, - }); - - expect(revoked.status).toBe(409); - expect(await revoked.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("preserves the existing non-idempotent behavior when no key is supplied", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - message: "intentional call one", - queue, - }); - const second = await requestBoundAgent({ - claims: capabilityClaims(), - database, - message: "intentional call two", - queue, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 0, - run: 2, - session: 2, - }); - }); -}); diff --git a/apps/api/tests/bound-capability-fixtures.ts b/apps/api/tests/bound-capability-fixtures.ts deleted file mode 100644 index b4bf29e4..00000000 --- a/apps/api/tests/bound-capability-fixtures.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { Hono } from "hono"; - -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpTestBindings, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; -import { requestPublicApiWithBindings } from "./public-thread-api-fixtures"; - -export const BOUND_DEPLOYMENT_ID = "01J0000000000000000000000D"; -export const BOUND_DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; -export const BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID = "01J0000000000000000000000S"; -export const BOUND_OTHER_DEPLOYMENT_ID = "01J0000000000000000000000E"; -export const BOUND_OTHER_DEPLOYMENT_RUN_ID = "01J0000000000000000000000T"; - -export const BOUND_BINDING = { - env: "MOSOO_AGENT_URL", - expose: "public_thread", - name: "Public API Agent", -} as const; - -export function boundCapabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { ...BOUND_BINDING }, - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -/** - * The minimal Deployment authority tables the capability checks read. Mirrors - * the production columns the authority service touches; the public HTTP core - * schema does not include them. - */ -export function createBoundDeploymentAuthoritySchema(database: SqliteD1Database): void { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); -} - -export async function insertBoundDeployment( - database: SqliteD1Database, - input: { - agentBindings?: unknown[]; - deletedAt?: number | null; - deploymentId?: string; - deploymentRunId?: string; - } = {}, -): Promise { - const deploymentId = input.deploymentId ?? BOUND_DEPLOYMENT_ID; - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(PUBLIC_API_TEST_IDS.app, input.deletedAt ?? null, deploymentId) - .run(); - await insertBoundDeploymentRun(database, { - agentBindings: input.agentBindings ?? [BOUND_BINDING], - deploymentId, - deploymentRunId: input.deploymentRunId ?? BOUND_DEPLOYMENT_RUN_ID, - }); -} - -export async function insertBoundDeploymentRun( - database: SqliteD1Database, - input: { - agentBindings: unknown[]; - deploymentId: string; - deploymentRunId: string; - status?: string; - }, -): Promise { - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - input.deploymentId, - input.deploymentRunId, - JSON.stringify({ agentBindings: input.agentBindings }), - input.status ?? "success", - ) - .run(); -} - -export async function deleteBoundDeployment( - database: SqliteD1Database, - deploymentId = BOUND_DEPLOYMENT_ID, -): Promise { - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), deploymentId) - .run(); -} - -export async function mintBoundCapabilityToken( - bindings: ApiBindings, - claims: AppAgentCapabilityClaims = boundCapabilityClaims(), -): Promise { - return mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); -} - -export function boundCapabilityUrl(token: string, path = ""): string { - return `https://api.example.com/api/v1/bound/${token}${path}`; -} - -export interface BoundCapabilityClient { - bindings: ApiBindings; - request: (path: string, init?: RequestInit) => Promise; - token: string; -} - -export async function createBoundCapabilityClient(input: { - app: Hono; - bindings: ApiBindings; - claims?: AppAgentCapabilityClaims; -}): Promise { - const token = await mintBoundCapabilityToken(input.bindings, input.claims); - - return { - bindings: input.bindings, - request: (path, init) => - requestPublicApiWithBindings( - input.app, - new Request(boundCapabilityUrl(token, path), init), - input.bindings, - ), - token, - }; -} - -export function createBoundTestBindings( - database: SqliteD1Database, - options: Parameters[1] = {}, -): ApiBindings { - return createPublicHttpTestBindings(database, options) as ApiBindings; -} diff --git a/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts b/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts deleted file mode 100644 index 22b9c7cb..00000000 --- a/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { insertSessionMessage } from "../src/modules/sessions/infrastructure/session-message-store.repository"; -import { - BOUND_DEPLOYMENT_ID, - BOUND_DEPLOYMENT_RUN_ID, - BOUND_OTHER_DEPLOYMENT_ID, - BOUND_OTHER_DEPLOYMENT_RUN_ID, - BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - BOUND_BINDING, - boundCapabilityClaims, - createBoundCapabilityClient, - createBoundDeploymentAuthoritySchema, - createBoundTestBindings, - deleteBoundDeployment, - insertBoundDeployment, - insertBoundDeploymentRun, -} from "./bound-capability-fixtures"; -import { - PublicApiMemoryFileBucket, - PUBLIC_API_TEST_IDS, - TOKENS, - createPublicHttpContractDatabase, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; -import { - bearer, - createPublicThreadApiTestApp, - expectArray, - expectRecord, - expectString, - readJson, - requestPublicApiWithBindings, - withProviderProbeMock, -} from "./public-thread-api-fixtures"; - -const ATTACHMENT_BODY = "Avatar bytes.\n"; -const ARTIFACT_BODY = "PK codex-pet.zip"; -const FINAL_OUTPUT_TEXT = "Your pet is ready: outputs/codex-pet.zip"; - -interface BoundSurface { - bindings: ReturnType; - bucket: PublicApiMemoryFileBucket; - database: SqliteD1Database; - ownerRequest: (path: string, init?: RequestInit) => Promise; - request: (path: string, init?: RequestInit) => Promise; - token: string; -} - -async function createBoundSurface(claims = boundCapabilityClaims()): Promise { - const database = await createPublicHttpContractDatabase(); - createBoundDeploymentAuthoritySchema(database); - await insertBoundDeployment(database); - - const app = createPublicThreadApiTestApp(); - const bucket = new PublicApiMemoryFileBucket(); - const bindings = createBoundTestBindings(database, { - fileBucket: bucket as unknown as R2Bucket, - }); - const client = await createBoundCapabilityClient({ app, bindings, claims }); - - return { - bindings, - bucket, - database, - ownerRequest: (path, init) => - requestPublicApiWithBindings( - app, - new Request(`https://api.example.com/api/v1${path}`, { - ...init, - headers: { - ...Object.fromEntries(new Headers(init?.headers)), - Authorization: bearer(TOKENS.owner), - }, - }), - bindings, - ), - request: client.request, - token: client.token, - }; -} - -function attachmentForm(name = "avatar.png", body = ATTACHMENT_BODY): FormData { - const formData = new FormData(); - formData.set("file", new File([new TextEncoder().encode(body)], name, { type: "image/png" })); - return formData; -} - -function createThreadBody(fileId: string, userId = "end-user-42"): string { - return JSON.stringify({ - input: { - content: [{ text: "Turn the attached avatar into a pet.", type: "text" }], - type: "user.message", - }, - resources: [{ file_id: fileId, type: "file" }], - userId, - }); -} - -async function uploadAttachment(surface: BoundSurface): Promise { - const response = await surface.request("/files", { body: attachmentForm(), method: "POST" }); - - expect(response.status).toBe(201); - const file = expectRecord(expectRecord(await readJson(response))["file"]); - expect(file).toMatchObject({ name: "avatar.png", size: ATTACHMENT_BODY.length }); - - return expectString(file["id"]); -} - -async function createThread( - surface: BoundSurface, - fileId: string, -): Promise<{ runId: string; threadId: string }> { - const response = await surface.request("/threads", { - body: createThreadBody(fileId), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - - expect(response.status).toBe(201); - const payload = await readJson(response); - const run = expectRecord(payload["run"]); - expect(["queued", "running"]).toContain(run["status"]); - - return { - runId: expectString(run["id"]), - threadId: expectString(expectRecord(payload["thread"])["id"]), - }; -} - -async function readRunProvenance( - database: SqliteD1Database, - runId: string, -): Promise | null> { - return database - .prepare( - `SELECT bound_capability_agent_id, bound_capability_app_id, bound_capability_binding_env, - bound_capability_binding_name, bound_capability_deployment_id, - bound_capability_deployment_run_id - FROM session_run - WHERE id = ?`, - ) - .bind(runId) - .first>(); -} - -async function simulateArtifactAndCompletion( - surface: BoundSurface, - input: { runId: string; threadId: string }, -): Promise { - const artifactId = PUBLIC_API_TEST_IDS.fileAlt; - const objectKey = `session/${input.threadId}/artifact/${artifactId}/codex-pet.zip`; - - await surface.database - .prepare( - `INSERT INTO file_record ( - id, scope_kind, scope_id, session_kind, status, name, path, parent_path, object_key, - owner_id, owner_kind, purpose, expires_at, mime_type, size, etag, committed, version, - created_by_account_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - artifactId, - "session", - input.threadId, - "artifact", - "ready", - "codex-pet.zip", - `artifact/${artifactId}/codex-pet.zip`, - `artifact/${artifactId}`, - objectKey, - input.threadId, - "session", - "session_artifact", - null, - "application/zip", - ARTIFACT_BODY.length, - null, - 1, - 1, - PUBLIC_API_TEST_IDS.ownerAccount, - 2, - 2, - ) - .run(); - await surface.bucket.put(objectKey, ARTIFACT_BODY, { - httpMetadata: { contentType: "application/zip" }, - }); - - await insertSessionMessage(surface.database, { - content: FINAL_OUTPUT_TEXT, - createdByAccountId: PUBLIC_API_TEST_IDS.ownerAccount, - role: "assistant", - segments: [{ kind: "text", text: FINAL_OUTPUT_TEXT }], - sessionId: input.threadId, - sessionRunId: input.runId, - }); - await surface.database - .app() - .update(sessionRunsTable) - .set({ - completedAt: 1_150, - errorCode: null, - errorDetailsJson: null, - errorMessage: null, - status: "completed", - updatedAt: 1_150, - }) - .where(eq(sessionRunsTable.id, input.runId)) - .run(); - await surface.database - .app() - .update(sessionsTable) - .set({ lastRunId: input.runId, status: "IDLE", updatedAt: 1_150 }) - .where(eq(sessionsTable.id, input.threadId)) - .run(); - - return artifactId; -} - -describe("bound capability Public Thread API e2e", () => { - test("runs upload -> Thread/Run -> artifact download through the deployment identity", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const draftRow = await surface.database - .prepare( - "SELECT created_by_account_id, owner_id, purpose, scope_kind FROM file_record WHERE id = ?", - ) - .bind(fileId) - .first>(); - expect(draftRow).toEqual({ - created_by_account_id: PUBLIC_API_TEST_IDS.ownerAccount, - owner_id: PUBLIC_API_TEST_IDS.app, - purpose: "app_draft", - scope_kind: "app_draft", - }); - - // A capability only sees a file once it is attached to one of its Threads. - const draftLookup = await surface.request(`/files/${fileId}`); - expect(draftLookup.status).toBe(404); - - const { runId, threadId } = await createThread(surface, fileId); - - const sessionRow = await surface.database - .prepare( - "SELECT agent_id, app_id, creator_account_id, end_user_id, metadata_json FROM session WHERE id = ?", - ) - .bind(threadId) - .first<{ - agent_id: string; - app_id: string; - creator_account_id: string; - end_user_id: string; - metadata_json: string; - }>(); - expect(sessionRow).toMatchObject({ - agent_id: PUBLIC_API_TEST_IDS.agent, - app_id: PUBLIC_API_TEST_IDS.app, - creator_account_id: PUBLIC_API_TEST_IDS.ownerAccount, - end_user_id: "end-user-42", - }); - expect(JSON.parse(sessionRow?.metadata_json ?? "{}")).toEqual({ - public_api: { - created_by: { - binding_env: BOUND_BINDING.env, - binding_name: BOUND_BINDING.name, - deployment_id: BOUND_DEPLOYMENT_ID, - deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - kind: "deployment_capability", - }, - idempotency_key: null, - source: "public_api", - }, - }); - - expect(await readRunProvenance(surface.database, runId)).toEqual({ - bound_capability_agent_id: PUBLIC_API_TEST_IDS.agent, - bound_capability_app_id: PUBLIC_API_TEST_IDS.app, - bound_capability_binding_env: BOUND_BINDING.env, - bound_capability_binding_name: BOUND_BINDING.name, - bound_capability_deployment_id: BOUND_DEPLOYMENT_ID, - bound_capability_deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - }); - - const claimedRow = await surface.database - .prepare("SELECT scope_id, scope_kind, session_kind FROM file_record WHERE id = ?") - .bind(fileId) - .first>(); - expect(claimedRow).toEqual({ - scope_id: threadId, - scope_kind: "session", - session_kind: "attachment", - }); - - const pending = await surface.request(`/threads/${threadId}`); - expect(pending.status).toBe(200); - const pendingPayload = await readJson(pending); - expect(expectRecord(pendingPayload["thread"])["id"]).toBe(threadId); - expect(expectRecord(pendingPayload["run"])["id"]).toBe(runId); - expect(expectRecord(pendingPayload["run"])["status"]).not.toBe("completed"); - // Responses never echo the capability token back to the deployed App. - expect(JSON.stringify(pendingPayload)).not.toContain(surface.token); - - const events = await surface.request(`/threads/${threadId}/events`); - expect(events.status).toBe(200); - expectArray((await readJson(events))["events"]); - - const artifactId = await simulateArtifactAndCompletion(surface, { runId, threadId }); - - const completed = await surface.request(`/threads/${threadId}`); - expect(completed.status).toBe(200); - expect(expectRecord((await readJson(completed))["run"])).toMatchObject({ - finalOutput: { text: FINAL_OUTPUT_TEXT }, - id: runId, - status: "completed", - }); - - const listed = await surface.request(`/threads/${threadId}/files`); - expect(listed.status).toBe(200); - const files = expectArray((await readJson(listed))["files"]).map((file) => - expectRecord(file), - ); - expect(files.map((file) => [file["id"], file["kind"], file["name"]])).toEqual( - expect.arrayContaining([ - [fileId, "attachment", "avatar.png"], - [artifactId, "artifact", "codex-pet.zip"], - ]), - ); - - const artifactMetadata = await surface.request(`/files/${artifactId}`); - expect(artifactMetadata.status).toBe(200); - expect(expectRecord((await readJson(artifactMetadata))["file"])).toMatchObject({ - id: artifactId, - name: "codex-pet.zip", - }); - - const download = await surface.request(`/files/${artifactId}/content?disposition=attachment`); - expect(download.status).toBe(200); - expect(download.headers.get("content-type")).toStartWith("application/zip"); - expect(download.headers.get("content-disposition")).toContain('filename="codex-pet.zip"'); - expect(await download.text()).toBe(ARTIFACT_BODY); - - const threads = await surface.request("/threads"); - expect(threads.status).toBe(200); - expect( - expectArray((await readJson(threads))["threads"]).map( - (thread) => expectRecord(thread)["id"], - ), - ).toEqual([threadId]); - - // Continue the Thread: the follow-up Run carries the same provenance. - const followUp = await surface.request(`/threads/${threadId}/events`, { - body: JSON.stringify({ - events: [{ text: "Make the tail longer.", type: "user_message" }], - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - expect(followUp.status).toBe(200); - const followUpEvent = expectRecord( - expectArray(expectRecord(await readJson(followUp))["events"])[0], - ); - const followUpRunId = expectString(expectRecord(followUpEvent["run"])["id"]); - expect(followUpRunId).not.toBe(runId); - expect(await readRunProvenance(surface.database, followUpRunId)).toMatchObject({ - bound_capability_deployment_id: BOUND_DEPLOYMENT_ID, - bound_capability_deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - }); - }); - }); - - test("keeps the capability inside its App, declared Agent, and Deployment", async () => { - const surface = await createBoundSurface(); - await insertBoundDeployment(surface.database, { - deploymentId: BOUND_OTHER_DEPLOYMENT_ID, - deploymentRunId: BOUND_OTHER_DEPLOYMENT_RUN_ID, - }); - const otherDeployment = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ - deploymentId: BOUND_OTHER_DEPLOYMENT_ID, - deploymentRunId: BOUND_OTHER_DEPLOYMENT_RUN_ID, - }), - }); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const { threadId } = await createThread(surface, fileId); - - // The owner's own Access Token Thread for the same Agent is invisible. - const ownerThread = await surface.ownerRequest( - `/agents/${PUBLIC_API_TEST_IDS.agent}/threads`, - { - body: JSON.stringify({ userId: "owner-customer" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ); - expect(ownerThread.status).toBe(201); - const ownerThreadId = expectString( - expectRecord(expectRecord(await readJson(ownerThread))["thread"])["id"], - ); - - for (const path of [ - `/threads/${ownerThreadId}`, - `/threads/${ownerThreadId}/events`, - `/threads/${ownerThreadId}/files`, - `/files/${PUBLIC_API_TEST_IDS.file}/content`, - ]) { - const response = await surface.request(path); - expect(response.status).toBe(404); - } - const ownerThreadContinue = await surface.request(`/threads/${ownerThreadId}/events`, { - body: JSON.stringify({ events: [{ text: "hijack", type: "user_message" }] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - expect(ownerThreadContinue.status).toBe(404); - - // Another Deployment of the same App and Agent cannot see this Thread. - const crossDeployment = await otherDeployment.request(`/threads/${threadId}`); - expect(crossDeployment.status).toBe(404); - const crossDeploymentList = await otherDeployment.request("/threads"); - expect(crossDeploymentList.status).toBe(200); - expect(expectArray((await readJson(crossDeploymentList))["threads"])).toEqual([]); - - // The owner still sees the deployment's Thread through the Access Token API. - const ownerView = await surface.ownerRequest(`/threads/${threadId}`); - expect(ownerView.status).toBe(200); - - // A capability minted for an Agent outside the App is refused outright. - const foreignAgent = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ appId: PUBLIC_API_TEST_IDS.organization }), - }); - const foreignUpload = await foreignAgent.request("/files", { - body: attachmentForm(), - method: "POST", - }); - expect(foreignUpload.status).toBe(409); - expect(expectRecord(await readJson(foreignUpload))["error"]).toMatchObject({ - code: "agent_not_published", - }); - - // A tampered token never authenticates. - const forged = await requestPublicApiWithBindings( - createPublicThreadApiTestApp(), - new Request(`https://api.example.com/api/v1/bound/${surface.token}x/threads`), - surface.bindings, - ); - expect(forged.status).toBe(401); - }); - }); - - test("rejects every bound operation once the deployment is removed or replaced", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const { threadId } = await createThread(surface, fileId); - - await deleteBoundDeployment(surface.database); - - const revokedError = { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }; - - for (const [path, init] of [ - [`/threads/${threadId}`, undefined], - [`/threads/${threadId}/files`, undefined], - ["/threads", undefined], - ["/files", { body: attachmentForm(), method: "POST" }], - [ - "/threads", - { - body: createThreadBody(fileId), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ], - [ - `/threads/${threadId}/events`, - { - body: JSON.stringify({ events: [{ text: "again", type: "user_message" }] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ], - ] as const) { - const response = await surface.request(path, init); - expect(response.status).toBe(409); - expect(expectRecord(await readJson(response))["error"]).toEqual(revokedError); - } - - // The owner keeps full access to the Thread the deployment created. - const ownerView = await surface.ownerRequest(`/threads/${threadId}`); - expect(ownerView.status).toBe(200); - }); - - // A successful replacement revision that drops the binding revokes the old URL. - const replaced = await createBoundSurface(); - await insertBoundDeploymentRun(replaced.database, { - agentBindings: [], - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - }); - const response = await replaced.request("/files", { body: attachmentForm(), method: "POST" }); - expect(response.status).toBe(409); - }); - - test("replays an idempotent create across capability revisions of one deployment", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const body = createThreadBody(fileId); - const headers = { - "Content-Type": "application/json", - "Idempotency-Key": "pet-7", - }; - - const first = await surface.request("/threads", { body, headers, method: "POST" }); - expect(first.status).toBe(201); - const threadId = expectString(expectRecord((await readJson(first))["thread"])["id"]); - - // Redeploy: the new revision keeps the binding, so the old token is - // replaced and the Worker retries with the freshly minted URL. - await insertBoundDeploymentRun(surface.database, { - agentBindings: [BOUND_BINDING], - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - }); - const nextRevision = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID }), - }); - - const staleRetry = await surface.request("/threads", { body, headers, method: "POST" }); - expect(staleRetry.status).toBe(409); - - const retry = await nextRevision.request("/threads", { body, headers, method: "POST" }); - expect(retry.status).toBe(201); - expect(retry.headers.get("Idempotency-Replayed")).toBe("true"); - expect(expectRecord((await readJson(retry))["thread"])["id"]).toBe(threadId); - - // The new revision reads the Thread the previous revision created. - const retrieve = await nextRevision.request(`/threads/${threadId}`); - expect(retrieve.status).toBe(200); - }); - }); - - test("cleans up the Thread when deletion wins the guarded Run insert race", async () => { - const surface = await createBoundSurface(); - const revoking = revokeDeploymentWhenRunInsertStarts(surface.database); - const bindings = createBoundTestBindings(revoking as unknown as SqliteD1Database, { - fileBucket: surface.bucket as unknown as R2Bucket, - }); - const client = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings, - }); - - await withProviderProbeMock(async () => { - const response = await client.request("/threads", { - body: JSON.stringify({ - input: { content: [{ text: "Hello", type: "text" }], type: "user.message" }, - userId: "end-user-42", - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - - expect(response.status).toBe(409); - expect(expectRecord(await readJson(response))["error"]).toEqual({ - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }); - await expect( - surface.database - .prepare("SELECT COUNT(*) AS count FROM session") - .first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - await expect( - surface.database - .prepare("SELECT COUNT(*) AS count FROM session_run") - .first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); - }); -}); - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await deleteBoundDeployment(database); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} diff --git a/apps/api/tests/channel-connection-do-fixtures.ts b/apps/api/tests/channel-connection-do-fixtures.ts deleted file mode 100644 index ff73025e..00000000 --- a/apps/api/tests/channel-connection-do-fixtures.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { expect } from "bun:test"; - -import type { ChannelConnection } from "../src/adapters/durable-objects/channel-connection.do"; -import type { ChannelConnectionProvider } from "../src/adapters/durable-objects/channel-connection.do"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createDiscordAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import type { DiscordGatewayRuntimeSnapshot } from "../src/modules/channels/discord/discord-gateway-health"; -import type { DiscordGatewaySocket } from "../src/modules/channels/discord/discord-gateway-socket"; -import type { - DiscordGatewayDurableObjectState, - DiscordGatewayStartResult, -} from "../src/modules/channels/discord/discord-gateway.do"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -export const STARTED_SNAPSHOT: DiscordGatewayRuntimeSnapshot = { - connectedAtMs: null, - heartbeatIntervalMs: null, - lastCloseCode: null, - lastDispatchAtMs: null, - lastErrorCode: null, - lastHeartbeatAckAtMs: null, - lastHeartbeatSentAtMs: null, - resumeGatewayUrl: null, - sequence: null, - sessionId: null, - status: "connecting", - statusChangedAtMs: 1_000, -}; - -type FakeDurableObjectStorageContract = DiscordGatewayDurableObjectState["storage"]; - -export class FakeDurableObjectStorage implements FakeDurableObjectStorageContract { - readonly values = new Map(); - alarmTime: number | null = null; - - async delete(key: string): Promise { - return this.values.delete(key); - } - - async deleteAlarm(): Promise { - this.alarmTime = null; - } - - async get(key: string): Promise { - return this.values.get(key) as T | undefined; - } - - async put(key: string, value: unknown): Promise { - this.values.set(key, value); - } - - async setAlarm(scheduledTime: Date | number): Promise { - this.alarmTime = typeof scheduledTime === "number" ? scheduledTime : scheduledTime.getTime(); - } -} - -export class FakeGatewaySocket implements DiscordGatewaySocket { - readonly closeListeners: Array<(event: { code: number }) => void> = []; - readonly errorListeners: Array<(event: Event) => void> = []; - readonly messageListeners: Array<(event: { data: ArrayBuffer | string }) => void> = []; - readonly sentFrames: string[] = []; - closeCode: number | null = null; - closeReason: string | null = null; - - onClose(listener: (event: { code: number }) => void): void { - this.closeListeners.push(listener); - } - - onError(listener: (event: Event) => void): void { - this.errorListeners.push(listener); - } - - onMessage(listener: (event: { data: ArrayBuffer | string }) => void): void { - this.messageListeners.push(listener); - } - - close(code?: number, reason?: string): void { - this.closeCode = code ?? null; - this.closeReason = reason ?? null; - } - - emitClose(code: number): void { - for (const listener of this.closeListeners) { - listener({ code }); - } - } - - emitMessage(data: string): void { - for (const listener of this.messageListeners) { - listener({ data }); - } - } - - send(data: string): void { - this.sentFrames.push(data); - } -} - -export function installDiscordFetch(): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "discord-bot-1", - username: "mosoobot", - }); - } - - if (requestUrl === "https://discord.com/api/v10/channels/dm-1") { - return Response.json({ - id: "dm-1", - type: 1, - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -export async function createDiscordBindingFixture(): Promise<{ - bindingId: string; - bindings: ApiBindings; - database: Awaited>; -}> { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const restoreFetch = installDiscordFetch(); - - try { - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - return { - bindingId: binding.id, - bindings, - database, - }; - } finally { - restoreFetch(); - } -} - -export function createMessageCreateFrame(sequence: number): string { - return JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: sequence, - t: "MESSAGE_CREATE", - }); -} - -export async function settleGatewayEvent(): Promise { - await new Promise((resolve) => setTimeout(resolve, 10)); -} - -export function readSentFrame(socket: FakeGatewaySocket, index: number): Record { - const frame = socket.sentFrames.at(index); - - if (!frame) { - throw new Error(`Expected sent frame at index ${index}.`); - } - - const parsed: unknown = JSON.parse(frame); - - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error("Expected sent frame to be a JSON object."); - } - - return parsed; -} - -function createFakeDurableObjectId(name: string): DurableObjectId { - return { - equals(other) { - return other.toString() === this.toString(); - }, - name, - toString() { - return `fake-channel-connection:${name}`; - }, - }; -} - -export function createFakeChannelConnectionNamespace( - startedBindingIds: string[], -): DurableObjectNamespace { - const namespace = { - get(id: DurableObjectId) { - return { - async fetch() { - return Response.json({ ok: false }, { status: 501 }); - }, - id, - name: id.name, - async snapshot(provider: ChannelConnectionProvider, bindingId: string) { - expect(provider).toBe("discord"); - return { - active: startedBindingIds.includes(bindingId), - bindingId, - snapshot: STARTED_SNAPSHOT, - }; - }, - async start( - provider: ChannelConnectionProvider, - bindingId: string, - ): Promise { - expect(provider).toBe("discord"); - startedBindingIds.push(bindingId); - return { - bindingId, - snapshot: STARTED_SNAPSHOT, - status: "started", - }; - }, - async stop(provider: ChannelConnectionProvider, bindingId: string) { - expect(provider).toBe("discord"); - return { - bindingId, - status: "stopped", - } as const; - }, - } as DurableObjectStub; - }, - getByName(name: string) { - return namespace.get(namespace.idFromName(name)); - }, - idFromName(name: string) { - return createFakeDurableObjectId(name); - }, - idFromString(id: string) { - return createFakeDurableObjectId(id); - }, - jurisdiction() { - return namespace; - }, - newUniqueId() { - return createFakeDurableObjectId("unique"); - }, - } as DurableObjectNamespace; - - return namespace; -} diff --git a/apps/api/tests/channel-connection-do.test.ts b/apps/api/tests/channel-connection-do.test.ts deleted file mode 100644 index 00acc656..00000000 --- a/apps/api/tests/channel-connection-do.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentChannelBindingsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { readChannelConnectionOwnerSnapshot } from "../src/modules/channels/application/channel-connection-state.service"; -import { runDiscordGatewayConnectionMaintenance } from "../src/modules/channels/application/discord-gateway-connection-maintenance.service"; -import { parseDiscordGatewayDispatchEnvelope } from "../src/modules/channels/discord/discord-events"; -import type { DiscordGatewayRelayRequest } from "../src/modules/channels/discord/discord-gateway-relay"; -import type { DiscordGatewayStartResult } from "../src/modules/channels/discord/discord-gateway.do"; -import { DiscordGatewayConnectionRuntimeService } from "../src/modules/channels/discord/discord-gateway.do"; -import { - FakeDurableObjectStorage, - FakeGatewaySocket, - STARTED_SNAPSHOT, - createDiscordBindingFixture, - createFakeChannelConnectionNamespace, - createMessageCreateFrame, - installDiscordFetch, - readSentFrame, - settleGatewayEvent, -} from "./channel-connection-do-fixtures"; - -describe("ChannelConnection Durable Object", () => { - test("starts from a deterministic DO owner, relays enriched dispatches, and heartbeats on alarm", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - const relayRequests: DiscordGatewayRelayRequest[] = []; - let nowMs = 1_000; - const restoreFetch = installDiscordFetch(); - const service = new DiscordGatewayConnectionRuntimeService( - { storage }, - { - ...bindings, - MOSOO_API_BASE_URL: "https://api.mosoo.example", - }, - { - connectGateway(url) { - expect(url).toBe("wss://gateway.discord.gg/?v=10&encoding=json"); - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => nowMs, - relayFetch: async (request) => { - relayRequests.push(request); - return Response.json({ accepted: true, ok: true }); - }, - }, - ); - - try { - await expect(service.start(bindingId)).resolves.toMatchObject({ - bindingId, - status: "started", - }); - expect(service.snapshot(bindingId)).toMatchObject({ - active: true, - bindingId, - }); - expect(storage.alarmTime).toBe(6_000); - - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - socket.emitMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - await settleGatewayEvent(); - expect(readSentFrame(socket, 0)).toMatchObject({ op: 2 }); - expect(storage.alarmTime).toBe(46_000); - - nowMs = 1_100; - socket.emitMessage( - JSON.stringify({ - d: { resume_gateway_url: "wss://resume.discord.example", session_id: "session-1" }, - op: 0, - s: 7, - t: "READY", - }), - ); - await settleGatewayEvent(); - - nowMs = 1_200; - socket.emitMessage(createMessageCreateFrame(8)); - await settleGatewayEvent(); - - expect(relayRequests).toHaveLength(1); - const parsedRelay = parseDiscordGatewayDispatchEnvelope(relayRequests[0]?.body ?? ""); - expect(parsedRelay.ok).toBe(true); - if (!parsedRelay.ok) { - throw new Error("Expected relayed Discord Gateway body to parse."); - } - expect(parsedRelay.envelope.message.channelType).toBe(1); - - nowMs = 1_300; - await service.alarm(); - expect(readSentFrame(socket, 1)).toEqual({ d: 8, op: 1 }); - - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastHeartbeatAtMs: 1_300, - lastInboundAtMs: 1_200, - leaseOwnerId: expect.any(String), - status: "running", - }); - } finally { - restoreFetch(); - } - }); - - test("keeps the Gateway socket alive when a relay dispatch fails", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - let nowMs = 1_000; - const restoreFetch = installDiscordFetch(); - const service = new DiscordGatewayConnectionRuntimeService( - { storage }, - { - ...bindings, - MOSOO_API_BASE_URL: "https://api.mosoo.example", - }, - { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => nowMs, - relayFetch: async () => Response.json({ ok: false }, { status: 503 }), - }, - ); - - try { - await service.start(bindingId); - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - socket.emitMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - await settleGatewayEvent(); - - nowMs = 1_200; - socket.emitMessage(createMessageCreateFrame(8)); - await settleGatewayEvent(); - - expect(socket.closeCode).toBeNull(); - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastErrorCode: "relay_http_503", - status: "running", - }); - } finally { - restoreFetch(); - } - }); - - test("reconnects promptly when the Gateway asks the client to reconnect", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - let nowMs = 1_000; - const service = new DiscordGatewayConnectionRuntimeService({ storage }, bindings, { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => nowMs, - relayFetch: async () => Response.json({ accepted: true, ok: true }), - }); - - await service.start(bindingId); - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - socket.emitMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - await settleGatewayEvent(); - expect(storage.alarmTime).toBe(46_000); - - nowMs = 1_200; - socket.emitMessage(JSON.stringify({ d: null, op: 7 })); - await settleGatewayEvent(); - - expect(socket.closeCode).toBe(4000); - expect(storage.alarmTime).toBe(6_200); - expect(service.snapshot(bindingId)).toEqual({ - active: false, - bindingId: null, - snapshot: null, - }); - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - status: "reconnecting", - }); - }); - - test("reconnects the Gateway connection when heartbeat ACKs stop arriving", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - let nowMs = 1_000; - const service = new DiscordGatewayConnectionRuntimeService({ storage }, bindings, { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => nowMs, - relayFetch: async () => Response.json({ accepted: true, ok: true }), - }); - - await service.start(bindingId); - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - socket.emitMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - await settleGatewayEvent(); - expect(storage.alarmTime).toBe(46_000); - - nowMs = 46_000; - await service.alarm(); - expect(readSentFrame(socket, 1)).toEqual({ d: null, op: 1 }); - expect(storage.alarmTime).toBe(91_000); - - nowMs = 91_000; - await service.alarm(); - - expect(socket.closeCode).toBe(4000); - expect(socket.closeReason).toBeString(); - expect(storage.alarmTime).toBe(96_000); - expect(service.snapshot(bindingId)).toEqual({ - active: false, - bindingId: null, - snapshot: null, - }); - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastErrorCode: "heartbeat_ack_timeout", - status: "reconnecting", - }); - }); - - test("marks Discord bindings errored and clears alarms on fatal Gateway close codes", async () => { - const { bindingId, bindings, database } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - const restoreFetch = installDiscordFetch(); - const service = new DiscordGatewayConnectionRuntimeService({ storage }, bindings, { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => 1_000, - }); - - try { - await service.start(bindingId); - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - socket.emitClose(4014); - await settleGatewayEvent(); - - const binding = await database - .app() - .select({ - lastErrorCode: agentChannelBindingsTable.lastErrorCode, - status: agentChannelBindingsTable.status, - }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, bindingId)) - .get(); - - expect(binding).toEqual({ - lastErrorCode: "discord_gateway_disallowed_intents", - status: "error", - }); - expect(storage.alarmTime).toBeNull(); - expect(service.snapshot(bindingId)).toEqual({ - active: false, - bindingId: null, - snapshot: null, - }); - } finally { - restoreFetch(); - } - }); - - test("clears fatal close local state even when binding error persistence fails", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - const restoreFetch = installDiscordFetch(); - const service = new DiscordGatewayConnectionRuntimeService({ storage }, bindings, { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => 1_000, - }); - - try { - await service.start(bindingId); - const socket = sockets[0]; - if (!socket) { - throw new Error("Expected fake Discord Gateway socket."); - } - - const originalPrepare = bindings.DB.prepare; - bindings.DB.prepare = () => { - throw new Error("D1 unavailable during fatal close."); - }; - - try { - socket.emitClose(4014); - await settleGatewayEvent(); - } finally { - bindings.DB.prepare = originalPrepare; - } - - expect(storage.alarmTime).toBeNull(); - expect(service.snapshot(bindingId)).toEqual({ - active: false, - bindingId: null, - snapshot: null, - }); - } finally { - restoreFetch(); - } - }); - - test("stops the active owner so alarms cannot restart a deleted binding", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const storage = new FakeDurableObjectStorage(); - const sockets: FakeGatewaySocket[] = []; - const service = new DiscordGatewayConnectionRuntimeService({ storage }, bindings, { - connectGateway() { - const socket = new FakeGatewaySocket(); - sockets.push(socket); - return socket; - }, - nowMs: () => 1_000, - }); - - await expect(service.start(bindingId)).resolves.toMatchObject({ - bindingId, - status: "started", - }); - expect(service.snapshot(bindingId)).toMatchObject({ - active: true, - bindingId, - }); - - await expect(service.stop(bindingId)).resolves.toEqual({ - bindingId, - status: "stopped", - }); - expect(service.snapshot(bindingId)).toEqual({ - active: false, - bindingId: null, - snapshot: null, - }); - expect(storage.alarmTime).toBeNull(); - - await service.alarm(); - expect(sockets).toHaveLength(1); - }); - - test("scheduled maintenance lists published active Discord bindings", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const startedBindingIds: string[] = []; - const result = await runDiscordGatewayConnectionMaintenance(bindings, new Date(0), { - startConnection: async (_bindings, input): Promise => { - startedBindingIds.push(input.bindingId); - return { - bindingId: input.bindingId, - snapshot: STARTED_SNAPSHOT, - status: "started", - }; - }, - }); - - expect(startedBindingIds).toEqual([bindingId]); - expect(result).toEqual({ - failed: 0, - started: 1, - total: 1, - }); - }); - - test("scheduled maintenance starts published active Discord bindings through the DO client boundary", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const startedBindingIds: string[] = []; - const result = await runDiscordGatewayConnectionMaintenance( - { - ...bindings, - ChannelConnection: createFakeChannelConnectionNamespace(startedBindingIds), - }, - new Date(0), - ); - - expect(startedBindingIds).toEqual([bindingId]); - expect(result).toEqual({ - failed: 0, - started: 1, - total: 1, - }); - }); -}); diff --git a/apps/api/tests/channel-connection-state.test.ts b/apps/api/tests/channel-connection-state.test.ts deleted file mode 100644 index 841f95f2..00000000 --- a/apps/api/tests/channel-connection-state.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createDiscordAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import { - claimChannelConnectionOwner, - readChannelConnectionOwnerSnapshot, - releaseChannelConnectionOwner, - renewChannelConnectionOwnerLease, -} from "../src/modules/channels/application/channel-connection-state.service"; -import type { DiscordGatewayWritableSocket } from "../src/modules/channels/discord/discord-gateway-client"; -import { DiscordGatewayClient } from "../src/modules/channels/discord/discord-gateway-client"; -import { - createDiscordGatewayRuntimeStatePayload, - parseDiscordGatewayResumeStateFromRuntimeState, -} from "../src/modules/channels/discord/discord-gateway-runtime-state"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -class FakeGatewaySocket implements DiscordGatewayWritableSocket { - readonly sentFrames: string[] = []; - - close(): void {} - - send(data: string): void { - this.sentFrames.push(data); - } -} - -function installDiscordIdentityFetch(): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "discord-bot-1", - username: "mosoobot", - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -async function createDiscordBindingFixture(): Promise<{ - bindingId: string; - bindings: ApiBindings; -}> { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const restoreFetch = installDiscordIdentityFetch(); - - try { - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - return { - bindingId: binding.id, - bindings, - }; - } finally { - restoreFetch(); - } -} - -async function readRuntimeStateJson(bindings: ApiBindings, bindingId: string): Promise { - const row = await bindings.DB.prepare( - "select runtime_state_json from channel_runtime_state where binding_id = ?", - ) - .bind(bindingId) - .first<{ runtime_state_json: string }>(); - - if (!row) { - throw new Error("Expected channel connection state row."); - } - - return row.runtime_state_json; -} - -describe("channel connection state", () => { - test("claims one runtime owner per provider binding until the lease expires", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - - const firstClaim = await claimChannelConnectionOwner({ - bindingId, - bindings, - leaseDurationMs: 30_000, - nowMs: 1000, - ownerId: "owner-a", - provider: "discord", - }); - - expect(firstClaim).toMatchObject({ - key: { - accountId: null, - bindingId, - provider: "discord", - }, - leaseExpiresAtMs: 31_000, - leaseOwnerId: "owner-a", - status: "starting", - }); - - await expect( - claimChannelConnectionOwner({ - bindingId, - bindings, - leaseDurationMs: 30_000, - nowMs: 2000, - ownerId: "owner-b", - provider: "discord", - }), - ).resolves.toBeNull(); - - await expect( - renewChannelConnectionOwnerLease({ - bindingId, - bindings, - leaseDurationMs: 30_000, - nowMs: 31_000, - ownerId: "owner-a", - provider: "discord", - state: { - status: "running", - statusChangedAtMs: 31_000, - }, - }), - ).resolves.toBeNull(); - - const takeover = await claimChannelConnectionOwner({ - bindingId, - bindings, - leaseDurationMs: 30_000, - nowMs: 31_000, - ownerId: "owner-b", - provider: "discord", - }); - - expect(takeover).toMatchObject({ - leaseExpiresAtMs: 61_000, - leaseOwnerId: "owner-b", - status: "starting", - }); - }); - - test("renews only the current owner and persists Discord Gateway health fields", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - let nowMs = 10_000; - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => nowMs, - onDispatch() {}, - socket, - token: "discord-token", - }); - - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - nowMs = 10_100; - client.handleMessage( - JSON.stringify({ - d: { resume_gateway_url: "wss://resume.discord.example", session_id: "session-1" }, - op: 0, - s: 7, - t: "READY", - }), - ); - nowMs = 10_200; - client.handleMessage( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 8, - t: "MESSAGE_CREATE", - }), - ); - nowMs = 10_300; - client.handleMessage(JSON.stringify({ d: null, op: 11 })); - - const claimed = await claimChannelConnectionOwner({ - bindingId, - bindings, - leaseDurationMs: 60_000, - nowMs, - ownerId: "gateway-01J00000000000000000000001", - provider: "discord", - state: createDiscordGatewayRuntimeStatePayload(client.getSnapshot()), - }); - - expect(claimed).toMatchObject({ - lastHeartbeatAtMs: 10_300, - lastInboundAtMs: 10_200, - leaseExpiresAtMs: 70_300, - leaseOwnerId: "gateway-01J00000000000000000000001", - status: "running", - statusChangedAtMs: 10_000, - }); - - await expect( - renewChannelConnectionOwnerLease({ - bindingId, - bindings, - leaseDurationMs: 60_000, - nowMs: 20_000, - ownerId: "gateway-owner-2", - provider: "discord", - state: createDiscordGatewayRuntimeStatePayload(client.getSnapshot()), - }), - ).resolves.toBeNull(); - - const renewed = await renewChannelConnectionOwnerLease({ - bindingId, - bindings, - leaseDurationMs: 60_000, - nowMs: 20_000, - ownerId: "gateway-01J00000000000000000000001", - provider: "discord", - state: createDiscordGatewayRuntimeStatePayload(client.getSnapshot()), - }); - - expect(renewed).toMatchObject({ - leaseExpiresAtMs: 80_000, - leaseOwnerId: "gateway-01J00000000000000000000001", - status: "running", - statusChangedAtMs: 10_000, - }); - - const runtimeStateJson = await readRuntimeStateJson(bindings, bindingId); - expect(parseDiscordGatewayResumeStateFromRuntimeState(runtimeStateJson)).toEqual({ - resumeGatewayUrl: "wss://resume.discord.example", - sequence: 8, - sessionId: "session-1", - }); - }); - - test("releases only the current owner and keeps the last observable state readable", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - - await claimChannelConnectionOwner({ - bindingId, - bindings, - leaseDurationMs: 30_000, - nowMs: 1000, - ownerId: "owner-a", - provider: "discord", - state: { - lastErrorCode: "socket_closed", - status: "reconnecting", - statusChangedAtMs: 1000, - }, - }); - - await expect( - releaseChannelConnectionOwner({ - bindingId, - bindings, - nowMs: 2000, - ownerId: "owner-b", - provider: "discord", - }), - ).resolves.toBeNull(); - - const released = await releaseChannelConnectionOwner({ - bindingId, - bindings, - nowMs: 2000, - ownerId: "owner-a", - provider: "discord", - }); - - expect(released).toMatchObject({ - lastErrorCode: "socket_closed", - leaseExpiresAtMs: null, - leaseOwnerId: null, - status: "stopped", - }); - - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - status: "stopped", - }); - }); - - test("does not parse incomplete Discord Gateway resume state", async () => { - expect(parseDiscordGatewayResumeStateFromRuntimeState("{}")).toBeNull(); - expect(parseDiscordGatewayResumeStateFromRuntimeState("{")).toBeNull(); - expect( - parseDiscordGatewayResumeStateFromRuntimeState( - JSON.stringify({ - resumeGatewayUrl: "wss://resume.discord.example", - sequence: null, - sessionId: "session-1", - status: "connected", - statusChangedAtMs: 1000, - }), - ), - ).toBeNull(); - }); -}); diff --git a/apps/api/tests/channel-final-delivery-fetch-fixtures.ts b/apps/api/tests/channel-final-delivery-fetch-fixtures.ts deleted file mode 100644 index 2479a67f..00000000 --- a/apps/api/tests/channel-final-delivery-fetch-fixtures.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { ChannelFinalDeliveryMessage } from "../src/modules/channels/application/channel-final-delivery-message"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import type { ChannelFinalDeliveryQueueStub } from "./helpers/public-api-http-test-fixture"; -import { - createChannelFinalDeliveryQueueStub, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, -} from "./helpers/public-api-http-test-fixture"; - -export const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -export type PublicHttpContractDatabase = Awaited< - ReturnType ->; - -interface TestEnvironment { - bindings: ApiBindings; - database: PublicHttpContractDatabase; - queue: ChannelFinalDeliveryQueueStub; -} - -function readJsonObjectBody(body: string): Record { - const parsed: unknown = JSON.parse(body); - - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed; - } - - throw new Error("Expected provider request body to be a JSON object."); -} - -export async function createTestEnvironment(): Promise { - const database = await createPublicHttpContractDatabase(); - const queue = createChannelFinalDeliveryQueueStub(); - const bindings = createPublicHttpTestBindings(database, { queue }) as ApiBindings; - - return { bindings, database, queue }; -} - -export function takeQueuedMessageBody( - queue: ChannelFinalDeliveryQueueStub, - jobId: string, -): ChannelFinalDeliveryMessage { - const match = queue.sent.find((entry) => entry.body.jobId === jobId); - - if (!match) { - throw new Error(`Expected queued message for ${jobId}.`); - } - - return match.body; -} - -export function installTelegramFetch( - telegramBodies: unknown[], - input: { - onSendMessage?: () => Promise; - } = {}, -): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://api.telegram.org/bottelegram-token/getMe") { - return Response.json({ - ok: true, - result: { - first_name: "mosoo Telegram", - id: 9001, - is_bot: true, - username: "mosoo_telegram_bot", - }, - }); - } - - if (requestUrl === "https://api.telegram.org/bottelegram-token/sendMessage") { - if (typeof init?.body === "string") { - telegramBodies.push(readJsonObjectBody(init.body)); - } - await input.onSendMessage?.(); - - return Response.json({ - ok: true, - result: { chat: { id: 42 }, message_id: telegramBodies.length }, - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -export function installDiscordFetch(discordRequests: { body: unknown; url: string }[]): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "discord-bot-1", - username: "mosoobot", - }); - } - - if (requestUrl.startsWith("https://discord.com/api/v10/channels/discord-channel-1/messages/")) { - if (typeof init?.body === "string") { - discordRequests.push({ - body: readJsonObjectBody(init.body), - url: requestUrl, - }); - } - - return Response.json({ id: requestUrl.split("/").at(-1) ?? "working-message" }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -export function installLarkFetch(input: { - readonly replyResponse?: unknown; - readonly replyRequests?: unknown[]; -}): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - if (requestUrl === "https://open.feishu.cn/open-apis/bot/v3/info") { - return Response.json({ - bot: { - app_name: "mosoo Lark", - open_id: "ou_bot", - }, - code: 0, - }); - } - - if (requestUrl.startsWith("https://open.feishu.cn/open-apis/im/v1/messages/")) { - if (typeof init?.body === "string") { - input.replyRequests?.push(readJsonObjectBody(init.body)); - } - - return Response.json(input.replyResponse ?? { code: 0, data: {} }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} diff --git a/apps/api/tests/channel-final-delivery-job-fixtures.ts b/apps/api/tests/channel-final-delivery-job-fixtures.ts deleted file mode 100644 index 8875333e..00000000 --- a/apps/api/tests/channel-final-delivery-job-fixtures.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { sessionMessagesTable, sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { - createDiscordAgentChannelBinding, - createLarkAgentChannelBinding, - createTelegramAgentChannelBinding, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import { enqueueChannelFinalDeliveryJob } from "../src/modules/channels/application/channel-final-delivery.service"; -import { - createChannelSessionClient, - resolveAgentChannelBindingContextById, -} from "../src/modules/channels/application/channel-session.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import type { PublicHttpContractDatabase } from "./channel-final-delivery-fetch-fixtures"; -import { OWNER_VIEWER } from "./channel-final-delivery-fetch-fixtures"; -import { - createTestExecutionContext, - nowMsForTest, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -interface CompletedFinalDeliveryJob { - bindingId: string; - jobId: string; - runId: string; - sessionId: string; -} - -export async function createCompletedTelegramFinalDeliveryJob(input: { - bindings: ApiBindings; - database: PublicHttpContractDatabase; - externalEventId: string; -}): Promise { - const binding = await createTelegramAgentChannelBinding(input.bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - const context = await resolveAgentChannelBindingContextById(input.bindings, { - bindingId: binding.id, - provider: "telegram", - }); - - if (!context) { - throw new Error("Expected Telegram binding context."); - } - - const sessionCommand = await createChannelSessionClient({ - binding: context, - bindings: input.bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/telegram/events/binding", - }).createOrContinueSession({ - clientRequestId: input.externalEventId, - text: "Review this", - trigger: { - eventId: input.externalEventId, - externalActorId: "telegram:user:42", - externalMessageId: `${input.externalEventId}:message`, - externalThreadId: "42:main", - providerMetadata: { - chat_id: "42", - message_id: 77, - }, - requiresExistingSession: false, - }, - }); - - if (!sessionCommand.sessionId) { - throw new Error("Expected channel session id."); - } - - if (!sessionCommand.runId) { - throw new Error("Expected channel run id."); - } - - await insertCompletedAssistantReply({ - database: input.database, - messageId: `${input.externalEventId}:assistant-message-final`, - runId: sessionCommand.runId, - seq: 2, - sessionId: sessionCommand.sessionId, - text: "Final answer", - }); - const jobId = await enqueueChannelFinalDeliveryJob( - input.bindings, - { - bindingId: binding.id, - externalEventId: input.externalEventId, - payload: { - chatId: "42", - messageThreadId: null, - provider: "telegram", - }, - provider: "telegram", - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }, - nowMsForTest(), - ); - - if (!jobId) { - throw new Error("Expected Telegram final delivery job to be queued."); - } - - return { - bindingId: binding.id, - jobId, - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }; -} - -export async function createCompletedDiscordFinalDeliveryJob(input: { - bindings: ApiBindings; - database: PublicHttpContractDatabase; - externalEventId: string; -}): Promise { - const binding = await createDiscordAgentChannelBinding(input.bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - const context = await resolveAgentChannelBindingContextById(input.bindings, { - bindingId: binding.id, - provider: "discord", - }); - - if (!context) { - throw new Error("Expected Discord binding context."); - } - - const sessionCommand = await createChannelSessionClient({ - binding: context, - bindings: input.bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/discord/events/binding", - }).createOrContinueSession({ - clientRequestId: input.externalEventId, - text: "Review this", - trigger: { - eventId: input.externalEventId, - externalActorId: "discord:user:discord-user-1", - externalMessageId: "discord-channel-1:message-1", - externalThreadId: "dm:discord-channel-1", - providerMetadata: { - channel_id: "discord-channel-1", - message_id: "message-1", - }, - requiresExistingSession: false, - }, - }); - - if (!sessionCommand.sessionId) { - throw new Error("Expected channel session id."); - } - - if (!sessionCommand.runId) { - throw new Error("Expected channel run id."); - } - - await insertCompletedAssistantReply({ - database: input.database, - messageId: `${input.externalEventId}:assistant-message-final`, - runId: sessionCommand.runId, - seq: 2, - sessionId: sessionCommand.sessionId, - text: "Final answer", - }); - const jobId = await enqueueChannelFinalDeliveryJob( - input.bindings, - { - bindingId: binding.id, - externalEventId: input.externalEventId, - payload: { - channelId: "discord-channel-1", - provider: "discord", - workingMessage: { - channelId: "discord-channel-1", - messageId: "working-message-1", - }, - }, - provider: "discord", - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }, - nowMsForTest(), - ); - - if (!jobId) { - throw new Error("Expected Discord final delivery job to be queued."); - } - - return { - bindingId: binding.id, - jobId, - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }; -} - -export async function createCompletedLarkFinalDeliveryJob(input: { - bindings: ApiBindings; - database: PublicHttpContractDatabase; - externalEventId: string; -}): Promise { - const binding = await createLarkAgentChannelBinding(input.bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const context = await resolveAgentChannelBindingContextById(input.bindings, { - bindingId: binding.id, - provider: "lark", - }); - - if (!context) { - throw new Error("Expected Lark binding context."); - } - - const sessionCommand = await createChannelSessionClient({ - binding: context, - bindings: input.bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/lark/events/binding", - }).createOrContinueSession({ - clientRequestId: input.externalEventId, - text: "Review this", - trigger: { - eventId: input.externalEventId, - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - providerMetadata: { - chat_id: "oc_chat", - message_id: "om_message", - }, - requiresExistingSession: false, - }, - }); - - if (!sessionCommand.sessionId) { - throw new Error("Expected channel session id."); - } - - if (!sessionCommand.runId) { - throw new Error("Expected channel run id."); - } - - await insertCompletedAssistantReply({ - database: input.database, - messageId: `${input.externalEventId}:assistant-message-final`, - runId: sessionCommand.runId, - seq: 2, - sessionId: sessionCommand.sessionId, - text: "Final answer", - }); - const jobId = await enqueueChannelFinalDeliveryJob( - input.bindings, - { - bindingId: binding.id, - externalEventId: input.externalEventId, - payload: { - messageId: "om_message", - provider: "lark", - }, - provider: "lark", - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }, - nowMsForTest(), - ); - - if (!jobId) { - throw new Error("Expected Lark final delivery job to be queued."); - } - - return { - bindingId: binding.id, - jobId, - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }; -} - -async function insertCompletedAssistantReply(input: { - database: PublicHttpContractDatabase; - messageId: string; - runId: string; - seq: number; - sessionId: string; - text: string; -}): Promise { - await input.database - .app() - .update(sessionRunsTable) - .set({ - completedAt: nowMsForTest(), - status: "completed", - updatedAt: nowMsForTest(), - }) - .where(eq(sessionRunsTable.id, input.runId)) - .run(); - await input.database - .app() - .insert(sessionMessagesTable) - .values({ - contentText: input.text, - createdAt: nowMsForTest(), - createdByAccountId: "01J00000000000000000000001", - id: input.messageId, - planJson: null, - role: "assistant", - segmentsJson: null, - seq: input.seq, - sessionId: input.sessionId, - sessionRunId: input.runId, - }) - .run(); - await input.database - .app() - .update(sessionsTable) - .set({ - lastRunId: input.runId, - status: "IDLE", - updatedAt: nowMsForTest(), - }) - .where(eq(sessionsTable.id, input.sessionId)) - .run(); -} diff --git a/apps/api/tests/channel-final-delivery-scheduling.test.ts b/apps/api/tests/channel-final-delivery-scheduling.test.ts deleted file mode 100644 index 148aaa4d..00000000 --- a/apps/api/tests/channel-final-delivery-scheduling.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - agentChannelBindingsTable, - channelFinalDeliveryJobsTable, - sessionRunsTable, - sessionsTable, -} from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import type { ChannelFinalDeliveryMessage } from "../src/modules/channels/application/channel-final-delivery-message"; -import { - enqueueChannelFinalDeliveryJob, - processChannelFinalDeliveryMessage, -} from "../src/modules/channels/application/channel-final-delivery.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - createTestEnvironment, - installDiscordFetch, - installLarkFetch, - installTelegramFetch, - takeQueuedMessageBody, -} from "./channel-final-delivery-fetch-fixtures"; -import { - createCompletedDiscordFinalDeliveryJob, - createCompletedLarkFinalDeliveryJob, - createCompletedTelegramFinalDeliveryJob, -} from "./channel-final-delivery-job-fixtures"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { createRecordedQueueMessage, nowMsForTest } from "./helpers/public-api-http-test-fixture"; - -function readJsonObjectBody(body: string): Record { - const parsed: unknown = JSON.parse(body); - - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed; - } - - throw new Error("Expected Telegram request body to be a JSON object."); -} - -describe("channel final delivery scheduling", () => { - test("delivers Telegram replies via the queue consumer and acks the message", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:1", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "telegram:update:1")) - .get(); - - expect(telegramBodies).toEqual([ - { - chat_id: "42", - text: `mosoo session ${seed.sessionId}\n\nFinal answer`, - }, - ]); - expect(job).toEqual({ - attemptCount: 1, - status: "delivered", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("edits Discord working messages and acks the queue message", async () => { - const discordRequests: { body: unknown; url: string }[] = []; - const restoreFetch = installDiscordFetch(discordRequests); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedDiscordFinalDeliveryJob({ - bindings, - database, - externalEventId: "discord:message:1", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "discord:message:1")) - .get(); - - expect(discordRequests.map((request) => request.body)).toEqual([ - { - allowed_mentions: { parse: [] }, - content: `mosoo session ${seed.sessionId}\n\nFinal answer`, - }, - ]); - expect(job).toEqual({ - attemptCount: 1, - status: "delivered", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("waiting for run completion requeues without spending delivery attempts", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:still-running", - }); - await database - .app() - .update(sessionRunsTable) - .set({ - status: "running", - updatedAt: nowMsForTest(), - }) - .where(eq(sessionRunsTable.id, seed.runId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .get(); - - expect(telegramBodies).toEqual([]); - expect(job).toEqual({ - attemptCount: 0, - lastErrorCode: null, - status: "dispatched", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent.at(-1)).toEqual({ - body: { jobId: seed.jobId }, - contentType: "json", - delaySeconds: 30, - id: "queued-2", - }); - } finally { - restoreFetch(); - } - }); - - test("blocks provider calls when delete cleanup has terminated the session", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:delete-cleanup", - }); - await database - .app() - .update(sessionsTable) - .set({ - archivedAt: nowMsForTest(), - status: "TERMINATED", - updatedAt: nowMsForTest(), - }) - .where(eq(sessionsTable.id, seed.sessionId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .get(); - - expect(telegramBodies).toEqual([]); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: "session_not_deliverable", - status: "failed", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("delivers queued final replies for archived non-terminated sessions", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:archived", - }); - await database - .app() - .update(sessionsTable) - .set({ - archivedAt: nowMsForTest(), - updatedAt: nowMsForTest(), - }) - .where(eq(sessionsTable.id, seed.sessionId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .get(); - - expect(telegramBodies).toEqual([ - { - chat_id: "42", - text: `mosoo session ${seed.sessionId}\n\nFinal answer`, - }, - ]); - expect(job).toEqual({ - attemptCount: 1, - status: "delivered", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("active delivery leases requeue replayed messages without resending replies", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:active-claim", - }); - await database - .app() - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount: 1, - lastErrorCode: `delivery_claim:worker-a:${nowMsForTest() + 25_000}`, - updatedAt: nowMsForTest(), - }) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .get(); - - expect(telegramBodies).toEqual([]); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: `delivery_claim:worker-a:${nowMsForTest() + 25_000}`, - status: "dispatched", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent.at(-1)).toEqual({ - body: { jobId: seed.jobId }, - contentType: "json", - delaySeconds: 25, - id: "queued-2", - }); - } finally { - restoreFetch(); - } - }); - - test("marks Lark binding error and acks when delivery fails with a credential-scoped error", async () => { - const restoreFetch = installLarkFetch({ - replyResponse: { code: 230035, msg: "Send Message Permission deny." }, - }); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedLarkFinalDeliveryJob({ - bindings, - database, - externalEventId: "lark:event:permission-failure", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const binding = await database - .app() - .select({ - lastErrorCode: agentChannelBindingsTable.lastErrorCode, - status: agentChannelBindingsTable.status, - }) - .from(agentChannelBindingsTable) - .where(eq(agentChannelBindingsTable.id, seed.bindingId)) - .get(); - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "lark:event:permission-failure")) - .get(); - - expect(binding).toEqual({ - lastErrorCode: "lark_230035", - status: "error", - }); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: "lark_230035", - status: "failed", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("retries when binding credentials cannot be decrypted", async () => { - const discordRequests: { body: unknown; url: string }[] = []; - const restoreFetch = installDiscordFetch(discordRequests); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedDiscordFinalDeliveryJob({ - bindings, - database, - externalEventId: "discord:message:secret-failure", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const brokenBindings = { - ...bindings, - VAULT_ROOT_SECRET: "wrong-vault-secret", - } satisfies ApiBindings; - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(brokenBindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "discord:message:secret-failure")) - .get(); - - expect(discordRequests).toEqual([]); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: "OperationError", - status: "dispatched", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent.at(-1)).toEqual({ - body: { jobId: seed.jobId }, - contentType: "json", - delaySeconds: 60, - id: "queued-2", - }); - } finally { - restoreFetch(); - } - }); - - test("retries when the provider send times out", async () => { - const telegramBodies: unknown[] = []; - let abortObserved = false; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://api.telegram.org/bottelegram-token/getMe") { - return Response.json({ - ok: true, - result: { - first_name: "mosoo Telegram", - id: 9001, - is_bot: true, - username: "mosoo_telegram_bot", - }, - }); - } - - if (requestUrl === "https://api.telegram.org/bottelegram-token/sendMessage") { - if (typeof init?.body === "string") { - telegramBodies.push(readJsonObjectBody(init.body)); - } - - return await new Promise((_resolve, reject) => { - const signal = init?.signal; - - if (!signal) { - reject(new Error("Expected Telegram sendMessage to receive an AbortSignal.")); - return; - } - - signal.addEventListener("abort", () => { - abortObserved = true; - reject(new DOMException("Aborted", "AbortError")); - }); - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:send-timeout", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage( - bindings, - recorded.message, - { providerRequestTimeoutMs: 1 }, - nowMsForTest, - ); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "telegram:update:send-timeout")) - .get(); - - expect(abortObserved).toBe(true); - expect(telegramBodies).toHaveLength(1); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: "ChannelWebApiTimeoutError", - status: "dispatched", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent.at(-1)).toEqual({ - body: { jobId: seed.jobId }, - contentType: "json", - delaySeconds: 60, - id: "queued-2", - }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("fails the durable job after the final delivery retry attempt", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies, { - async onSendMessage() { - throw new Error("provider unavailable"); - }, - }); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:max-retries", - }); - await database - .app() - .update(channelFinalDeliveryJobsTable) - .set({ - attemptCount: 7, - updatedAt: nowMsForTest(), - }) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.externalEventId, "telegram:update:max-retries")) - .get(); - - expect(telegramBodies).toHaveLength(1); - expect(job).toEqual({ - attemptCount: 8, - lastErrorCode: "Error", - status: "failed", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent).toHaveLength(1); - } finally { - restoreFetch(); - } - }); - - test("fails malformed job payloads without retrying poison messages", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:bad-payload", - }); - - await database - .app() - .update(channelFinalDeliveryJobsTable) - .set({ - payloadJson: JSON.stringify({ - provider: "slack", - }), - }) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .run(); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await database - .app() - .select({ - attemptCount: channelFinalDeliveryJobsTable.attemptCount, - lastErrorCode: channelFinalDeliveryJobsTable.lastErrorCode, - status: channelFinalDeliveryJobsTable.status, - }) - .from(channelFinalDeliveryJobsTable) - .where(eq(channelFinalDeliveryJobsTable.id, seed.jobId)) - .get(); - - expect(telegramBodies).toEqual([]); - expect(job).toEqual({ - attemptCount: 1, - lastErrorCode: "ChannelFinalDeliveryPayloadError", - status: "failed", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); - - test("dedupes producer-side: repeated enqueue with same key sends only one queue message", async () => { - const restoreFetch = installTelegramFetch([]); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:dedupe", - }); - - expect(queue.sent).toHaveLength(1); - expect(queue.sent[0]?.body).toEqual({ jobId: seed.jobId }); - - const duplicateJobId = await enqueueChannelFinalDeliveryJob( - bindings, - { - bindingId: seed.bindingId, - externalEventId: "telegram:update:dedupe", - payload: { - chatId: "42", - messageThreadId: null, - provider: "telegram", - }, - provider: "telegram", - runId: seed.runId, - sessionId: seed.sessionId, - }, - nowMsForTest(), - ); - - expect(duplicateJobId).toBeNull(); - expect(queue.sent).toHaveLength(1); - } finally { - restoreFetch(); - } - }); - - test("consumer-side idempotency: processing a delivered message twice does not resend", async () => { - const telegramBodies: unknown[] = []; - const restoreFetch = installTelegramFetch(telegramBodies); - - try { - const { bindings, database, queue } = await createTestEnvironment(); - const seed = await createCompletedTelegramFinalDeliveryJob({ - bindings, - database, - externalEventId: "telegram:update:idempotent", - }); - - const queued = takeQueuedMessageBody(queue, seed.jobId); - const first = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, first.message, {}, nowMsForTest); - - const second = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, second.message, {}, nowMsForTest); - - expect(telegramBodies).toHaveLength(1); - expect(first.recorded).toEqual([{ type: "ack" }]); - expect(second.recorded).toEqual([{ type: "ack" }]); - } finally { - restoreFetch(); - } - }); -}); diff --git a/apps/api/tests/channel-provider-contract.test.ts b/apps/api/tests/channel-provider-contract.test.ts deleted file mode 100644 index df945c7b..00000000 --- a/apps/api/tests/channel-provider-contract.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AGENT_CHANNEL_BINDING_PROVIDERS, - AGENT_CHANNEL_WEBHOOK_PROVIDERS, - AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS, - buildAgentChannelWebhookPath, - buildAgentChannelWebhookUrl, -} from "@mosoo/contracts/channel"; -import { isEnumType } from "graphql"; - -import { createGraphQLSchema } from "../src/adapters/graphql/create-graphql-schema"; - -const apiSchema = createGraphQLSchema(); - -function collectGraphqlEnumValues(enumName: string): string[] { - const enumType = apiSchema.getType(enumName); - - if (!isEnumType(enumType)) { - throw new Error(`Expected GraphQL enum ${enumName}.`); - } - - return enumType.getValues().map((value) => value.name); -} - -describe("channel provider contract", () => { - test("keeps GraphQL provider enum aligned with the shared provider registry", () => { - expect(collectGraphqlEnumValues("ChannelProvider").toSorted()).toEqual( - [...AGENT_CHANNEL_BINDING_PROVIDERS].toSorted(), - ); - }); - - test("keeps channel webhook route patterns aligned with the shared webhook registry", () => { - expect(Object.keys(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS).toSorted()).toEqual( - [...AGENT_CHANNEL_WEBHOOK_PROVIDERS].toSorted(), - ); - expect(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS.slack).not.toContain(":bindingId"); - for (const provider of AGENT_CHANNEL_WEBHOOK_PROVIDERS) { - expect(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS[provider]).toContain(`/channels/${provider}/`); - if (provider !== "slack") { - expect(AGENT_CHANNEL_WEBHOOK_ROUTE_PATTERNS[provider]).toContain(":bindingId"); - } - } - }); - - test("builds public webhook paths and urls from the shared channel contract", () => { - const slackPath = buildAgentChannelWebhookPath({ provider: "slack" }); - expect(slackPath).toStartWith("/api/"); - expect(slackPath).toContain("/channels/slack/events"); - - const discordPath = buildAgentChannelWebhookPath({ - bindingId: "binding/1", - provider: "discord", - }); - expect(discordPath).toContain("/channels/discord/events/"); - expect(discordPath).toContain(encodeURIComponent("binding/1")); - - const telegramUrl = new URL( - buildAgentChannelWebhookUrl({ - bindingId: "binding-1", - origin: "https://api.example.com/", - provider: "telegram", - }), - ); - expect(telegramUrl.origin).toBe("https://api.example.com"); - expect(telegramUrl.pathname).toBe( - buildAgentChannelWebhookPath({ bindingId: "binding-1", provider: "telegram" }), - ); - }); -}); diff --git a/apps/api/tests/channel-session-fixtures.ts b/apps/api/tests/channel-session-fixtures.ts deleted file mode 100644 index 92dbe186..00000000 --- a/apps/api/tests/channel-session-fixtures.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; - -export const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function parseJsonRecord(value: string): Record { - const parsed: unknown = JSON.parse(value); - - if (!isRecord(parsed)) { - throw new Error("Expected JSON object."); - } - - return parsed; -} - -export function readRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new Error(`${label} must be an object.`); - } - - return value; -} diff --git a/apps/api/tests/discord-channel-adapter-dispatch.test.ts b/apps/api/tests/discord-channel-adapter-dispatch.test.ts deleted file mode 100644 index 69a82e17..00000000 --- a/apps/api/tests/discord-channel-adapter-dispatch.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - normalizeDiscordGatewayWorkTrigger, - parseDiscordGatewayDispatchEnvelope, -} from "../src/modules/channels/discord/discord-events"; -import { verifyDiscordRelaySignature } from "../src/modules/channels/discord/discord-signing"; - -function bytesToHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -async function signDiscordRelayBody(input: { - body: string; - relaySecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.relaySecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(`v0:${input.timestamp}:${input.body}`), - ); - - return `v0=${bytesToHex(signature)}`; -} - -describe("Discord channel dispatch", () => { - test("verifies relay signatures over the raw dispatch body", async () => { - const body = JSON.stringify({ d: { id: "message-1" }, op: 0, s: 1, t: "MESSAGE_CREATE" }); - const timestamp = "1779646500"; - const relaySecret = "relay-secret"; - const signature = await signDiscordRelayBody({ body, relaySecret, timestamp }); - - await expect( - verifyDiscordRelaySignature({ - body, - headers: new Headers({ - "x-mosoo-discord-relay-signature": signature, - "x-mosoo-discord-relay-timestamp": timestamp, - }), - nowSeconds: 1779646500, - relaySecret, - }), - ).resolves.toEqual({ ok: true }); - - await expect( - verifyDiscordRelaySignature({ - body, - headers: new Headers({ - "x-mosoo-discord-relay-signature": signature, - "x-mosoo-discord-relay-timestamp": timestamp, - }), - nowSeconds: 1779646500, - relaySecret: "wrong-secret", - }), - ).resolves.toMatchObject({ code: "signature_mismatch", ok: false, status: 401 }); - }); - - test("rejects malformed and stale relay timestamps", async () => { - const body = JSON.stringify({ d: { id: "message-1" }, op: 0, s: 1, t: "MESSAGE_CREATE" }); - const relaySecret = "relay-secret"; - const timestamp = "1779646500"; - const signature = await signDiscordRelayBody({ body, relaySecret, timestamp }); - - await expect( - verifyDiscordRelaySignature({ - body, - headers: new Headers({ - "x-mosoo-discord-relay-signature": signature, - "x-mosoo-discord-relay-timestamp": `${timestamp}junk`, - }), - nowSeconds: 1779646500, - relaySecret, - }), - ).resolves.toMatchObject({ code: "missing_header", ok: false, status: 400 }); - - await expect( - verifyDiscordRelaySignature({ - body, - headers: new Headers({ - "x-mosoo-discord-relay-signature": signature, - "x-mosoo-discord-relay-timestamp": timestamp, - }), - nowSeconds: 1779650101, - relaySecret, - }), - ).resolves.toMatchObject({ code: "stale_timestamp", ok: false, status: 401 }); - }); - - test("normalizes DM gateway messages into message-scoped thread keys", () => { - const parsed = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 42, - t: "MESSAGE_CREATE", - }), - ); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect( - normalizeDiscordGatewayWorkTrigger(parsed.envelope, { - botUserId: "bot-1", - }), - ).toEqual({ - authorDisplayName: "Ada", - authorId: "user-1", - channelId: "dm-1", - channelType: 1, - eventId: "discord:message:message-1", - externalActorId: "discord:user:user-1", - externalMessageId: "dm-1:message-1", - externalThreadId: "dm:dm-1:message:message-1", - guildId: null, - messageId: "message-1", - text: "review this", - }); - }); - - test("requires a bot mention for guild channel messages", () => { - const parsed = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - content: "<@bot-1> review this", - guild_id: "guild-1", - id: "message-1", - relay_channel_type: 0, - }, - op: 0, - s: 43, - t: "MESSAGE_CREATE", - }), - ); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect( - normalizeDiscordGatewayWorkTrigger(parsed.envelope, { - botUserId: "bot-1", - }), - ).toMatchObject({ - externalThreadId: "guild:guild-1:channel:channel-1:message:message-1", - text: "review this", - }); - - const unmentioned = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - content: "review this", - guild_id: "guild-1", - id: "message-2", - relay_channel_type: 0, - }, - op: 0, - s: 44, - t: "MESSAGE_CREATE", - }), - ); - - expect(unmentioned.ok).toBe(true); - if (!unmentioned.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect(normalizeDiscordGatewayWorkTrigger(unmentioned.envelope, { botUserId: "bot-1" })).toBe( - null, - ); - }); - - test("ignores bot authors and group DMs from gateway messages", () => { - const botMessage = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: true, id: "bot-2", username: "Other Bot" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 45, - t: "MESSAGE_CREATE", - }), - ); - const groupDm = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "group-dm-1", - content: "review this", - id: "message-2", - relay_channel_type: 3, - }, - op: 0, - s: 46, - t: "MESSAGE_CREATE", - }), - ); - - expect(botMessage.ok).toBe(true); - expect(groupDm.ok).toBe(true); - if (!botMessage.ok || !groupDm.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect(normalizeDiscordGatewayWorkTrigger(botMessage.envelope, { botUserId: "bot-1" })).toBe( - null, - ); - expect(normalizeDiscordGatewayWorkTrigger(groupDm.envelope, { botUserId: "bot-1" })).toBe(null); - }); - - test("drops dispatches when relay channel type enrichment is missing", () => { - const missingChannelType = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: 47, - t: "MESSAGE_CREATE", - }), - ); - const rawGatewayChannelType = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - channel_type: 1, - content: "review this", - id: "message-2", - }, - op: 0, - s: 48, - t: "MESSAGE_CREATE", - }), - ); - const rawGuildChannelType = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - channel_type: 0, - content: "<@bot-1> review this", - guild_id: "guild-1", - id: "message-3", - }, - op: 0, - s: 49, - t: "MESSAGE_CREATE", - }), - ); - - expect(missingChannelType.ok).toBe(true); - expect(rawGatewayChannelType.ok).toBe(true); - expect(rawGuildChannelType.ok).toBe(true); - if (!missingChannelType.ok || !rawGatewayChannelType.ok || !rawGuildChannelType.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect( - normalizeDiscordGatewayWorkTrigger(missingChannelType.envelope, { botUserId: "bot-1" }), - ).toBe(null); - expect( - normalizeDiscordGatewayWorkTrigger(rawGatewayChannelType.envelope, { botUserId: "bot-1" }), - ).toBe(null); - expect( - normalizeDiscordGatewayWorkTrigger(rawGuildChannelType.envelope, { botUserId: "bot-1" }), - ).toBe(null); - }); - - test("parses empty message content but drops it before session creation", () => { - const parsed = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 47, - t: "MESSAGE_CREATE", - }), - ); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - expect(normalizeDiscordGatewayWorkTrigger(parsed.envelope, { botUserId: "bot-1" })).toBe(null); - }); - - test("keeps same-channel Discord messages in separate external threads", () => { - const first = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - content: "<@bot-1> first question", - guild_id: "guild-1", - id: "message-1", - relay_channel_type: 0, - }, - op: 0, - s: 50, - t: "MESSAGE_CREATE", - }), - ); - const second = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - content: "<@bot-1> second question", - guild_id: "guild-1", - id: "message-2", - relay_channel_type: 0, - }, - op: 0, - s: 51, - t: "MESSAGE_CREATE", - }), - ); - - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - if (!first.ok || !second.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - const firstTrigger = normalizeDiscordGatewayWorkTrigger(first.envelope, { botUserId: "bot-1" }); - const secondTrigger = normalizeDiscordGatewayWorkTrigger(second.envelope, { - botUserId: "bot-1", - }); - - expect(firstTrigger?.externalThreadId).toBe( - "guild:guild-1:channel:channel-1:message:message-1", - ); - expect(secondTrigger?.externalThreadId).toBe( - "guild:guild-1:channel:channel-1:message:message-2", - ); - expect(firstTrigger?.externalThreadId).not.toBe(secondTrigger?.externalThreadId); - }); -}); diff --git a/apps/api/tests/discord-channel-adapter.test.ts b/apps/api/tests/discord-channel-adapter.test.ts deleted file mode 100644 index 91fd648f..00000000 --- a/apps/api/tests/discord-channel-adapter.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ChannelSessionCommandClient } from "../src/modules/channels/application/channel-session.types"; -import { processDiscordWorkTrigger } from "../src/modules/channels/discord/discord-first-party-adapter"; -import { - DiscordWebApiClient, - DiscordWebApiError, -} from "../src/modules/channels/discord/discord-web-api"; -import { readFetchUrl } from "./helpers/fetch-request-url"; - -function readJsonRequestBody(init: RequestInit | undefined): unknown { - if (typeof init?.body !== "string") { - throw new Error("Expected mocked Discord request body to be a JSON string."); - } - - return JSON.parse(init.body); -} - -describe("Discord channel adapter", () => { - test("sends Discord messages and edits through the channel message API", async () => { - const originalFetch = globalThis.fetch; - const requests: { body: unknown; method: string; url: string }[] = []; - globalThis.fetch = async (url, init) => { - requests.push({ - body: readJsonRequestBody(init), - method: init?.method ?? "GET", - url: readFetchUrl(url), - }); - return Response.json({ id: `message-${requests.length}` }); - }; - - try { - const client = new DiscordWebApiClient("discord-token"); - await expect( - client.sendMessage({ - channelId: "channel-1", - text: "Agent is working...", - }), - ).resolves.toEqual({ - channelId: "channel-1", - messageId: "message-1", - }); - await client.editMessage({ - channelId: "channel-1", - messageId: "message-1", - text: "Done", - }); - - expect(requests).toEqual([ - { - body: { allowed_mentions: { parse: [] }, content: "Agent is working..." }, - method: "POST", - url: "https://discord.com/api/v10/channels/channel-1/messages", - }, - { - body: { allowed_mentions: { parse: [] }, content: "Done" }, - method: "PATCH", - url: "https://discord.com/api/v10/channels/channel-1/messages/message-1", - }, - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("maps Discord API failures to typed errors", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - Response.json({ code: 50001, message: "Missing Access" }, { status: 403 }); - - try { - await expect( - new DiscordWebApiClient("discord-token").sendMessage({ - channelId: "channel-1", - text: "hello", - }), - ).rejects.toEqual(new DiscordWebApiError("sendMessage", "Missing Access")); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not mark Discord bindings errored for channel-scoped Missing Access failures", async () => { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - const errorCodes: string[] = []; - globalThis.fetch = async () => - Response.json({ code: 50001, message: "Missing Access" }, { status: 403 }); - globalThis.reportError = () => {}; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - errorCodes.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Discord gateway relay path must not poll final replies."); - }, - }; - - try { - await processDiscordWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - botToken: "discord-token", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Failed Discord working reply must not schedule final delivery."); - }, - }, - sessionClient, - trigger: { - authorDisplayName: "Ada", - authorId: "user-1", - channelId: "channel-1", - channelType: null, - eventId: "discord:message:message-1", - externalActorId: "discord:user:user-1", - externalMessageId: "channel-1:message-1", - externalThreadId: "guild:guild-1:channel:channel-1", - guildId: "guild-1", - messageId: "message-1", - text: "review this", - }, - }); - - expect(errorCodes).toEqual([]); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - }); - - test("writes Discord working and final replies through durable final delivery", async () => { - const originalFetch = globalThis.fetch; - const requestBodies: unknown[] = []; - const finalDeliveryJobs: unknown[] = []; - globalThis.fetch = async (_url, init) => { - requestBodies.push(readJsonRequestBody(init)); - return Response.json({ id: `message-${requestBodies.length}` }); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError() {}, - async retrieveSessionReply() { - throw new Error("Discord gateway relay path must not poll final replies."); - }, - }; - - try { - await processDiscordWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - botToken: "discord-token", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue(job) { - finalDeliveryJobs.push(job); - }, - }, - sessionClient, - trigger: { - authorDisplayName: "Ada", - authorId: "user-1", - channelId: "channel-1", - channelType: null, - eventId: "discord:message:message-1", - externalActorId: "discord:user:user-1", - externalMessageId: "channel-1:message-1", - externalThreadId: "guild:guild-1:channel:channel-1", - guildId: "guild-1", - messageId: "message-1", - text: "review this", - }, - }); - - expect(requestBodies).toEqual([ - { - allowed_mentions: { parse: [] }, - content: - "mosoo session created: https://mosoo.ai/agent/01J00000000000000000000009?tab=consume&sessionId=session-1. Agent is working...", - }, - ]); - expect(finalDeliveryJobs).toEqual([ - { - bindingId: "binding-1", - externalEventId: "discord:message:message-1", - payload: { - channelId: "channel-1", - provider: "discord", - workingMessage: { - channelId: "channel-1", - messageId: "message-1", - }, - }, - provider: "discord", - runId: "run-1", - sessionId: "session-1", - }, - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); diff --git a/apps/api/tests/discord-channel-events-route.test.ts b/apps/api/tests/discord-channel-events-route.test.ts deleted file mode 100644 index f2228d28..00000000 --- a/apps/api/tests/discord-channel-events-route.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import { apiCommandsTable, channelThreadSessionsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; - -import { registerDiscordEventsRoute } from "../src/adapters/http/routes/discord-events-route"; -import type { ApiCommandMessage } from "../src/modules/api-command/application/api-command-message"; -import { processApiCommandMessage } from "../src/modules/api-command/application/api-command-processor"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createDiscordAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createRecordedQueueMessage, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -import type { ApiCommandQueueStub } from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -function createDiscordRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - registerDiscordEventsRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function createDiscordEventsUrl(bindingId: string): string { - return buildAgentChannelWebhookUrl({ - bindingId, - origin: "https://api.example.com", - provider: "discord", - }); -} - -function readApiCommandQueue(bindings: ApiBindings): ApiCommandQueueStub { - return bindings.API_COMMAND_QUEUE as ApiCommandQueueStub; -} - -function bytesToHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -async function signDiscordRelayBody(input: { - body: string; - relaySecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.relaySecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(`v0:${input.timestamp}:${input.body}`), - ); - - return `v0=${bytesToHex(signature)}`; -} - -async function withDiscordFetchMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url, init) => { - if (readFetchUrl(url) === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "bot-1", - username: "mosoobot", - }); - } - - if (init?.method === "POST") { - return Response.json({ id: "working-message-1" }); - } - - return Response.json({}); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -async function withFailingDiscordMessageFetch(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "bot-1", - username: "mosoobot", - }); - } - - if ( - init?.method === "POST" && - requestUrl === "https://discord.com/api/v10/channels/dm-1/messages" - ) { - return Response.json({ code: 50001, message: "Missing Access" }, { status: 403 }); - } - - if (init?.method === "POST") { - return Response.json({ id: "working-message-1" }); - } - - return Response.json({ data: [{ id: "gpt-5.4" }] }); - }; - globalThis.reportError = () => {}; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } -} - -describe("Discord channel events route", () => { - test("rejects Discord relay dispatches when the per-binding signature mismatches", async () => { - await withDiscordFetchMock(async () => { - const app = createDiscordRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - const body = JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 1, - t: "MESSAGE_CREATE", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(createDiscordEventsUrl(binding.id), { - body, - headers: { - "x-mosoo-discord-relay-signature": await signDiscordRelayBody({ - body, - relaySecret: "wrong-secret", - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ - code: "signature_mismatch", - ok: false, - }); - }); - }); - - test("accepts signed Discord DM MESSAGE_CREATE dispatches for a Agent API Endpoint binding", async () => { - await withDiscordFetchMock(async () => { - const app = createDiscordRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - const body = JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 2, - t: "MESSAGE_CREATE", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(createDiscordEventsUrl(binding.id), { - body, - headers: { - "x-mosoo-discord-relay-signature": await signDiscordRelayBody({ - body, - relaySecret: "discord-relay-secret", - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - accepted: true, - adapter: "discord", - ok: true, - }); - }); - }); - - test("creates separate sessions for multiple Discord messages in the same guild channel", async () => { - await withDiscordFetchMock(async () => { - const app = createDiscordRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - for (const messageId of ["message-1", "message-2"]) { - const body = JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "channel-1", - content: `<@bot-1> review ${messageId}`, - guild_id: "guild-1", - id: messageId, - relay_channel_type: 0, - }, - op: 0, - s: messageId === "message-1" ? 10 : 11, - t: "MESSAGE_CREATE", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(createDiscordEventsUrl(binding.id), { - body, - headers: { - "x-mosoo-discord-relay-signature": await signDiscordRelayBody({ - body, - relaySecret: "discord-relay-secret", - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - } - - const channelCommandMessages = [...readApiCommandQueue(bindings).sent]; - expect(channelCommandMessages).toHaveLength(2); - - for (const entry of channelCommandMessages) { - const recorded = createRecordedQueueMessage({ body: entry.body }); - await processApiCommandMessage(bindings, recorded.message); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - } - - const rows = await database - .app() - .select({ - externalThreadId: channelThreadSessionsTable.externalThreadId, - sessionId: channelThreadSessionsTable.sessionId, - }) - .from(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.id)) - .orderBy(channelThreadSessionsTable.externalThreadId) - .all(); - - expect(rows).toEqual([ - { - externalThreadId: "guild:guild-1:channel:channel-1:message:message-1", - sessionId: expect.any(String), - }, - { - externalThreadId: "guild:guild-1:channel:channel-1:message:message-2", - sessionId: expect.any(String), - }, - ]); - expect(rows[0]?.sessionId).not.toBe(rows[1]?.sessionId); - }); - }); - - test("accepts Discord relay work before retrying failed trigger processing", async () => { - await withFailingDiscordMessageFetch(async () => { - const app = createDiscordRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - const body = JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - relay_channel_type: 1, - }, - op: 0, - s: 4, - t: "MESSAGE_CREATE", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(createDiscordEventsUrl(binding.id), { - body, - headers: { - "x-mosoo-discord-relay-signature": await signDiscordRelayBody({ - body, - relaySecret: "discord-relay-secret", - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - accepted: true, - adapter: "discord", - ok: true, - }); - - const queued = readApiCommandQueue(bindings).sent[0]?.body; - if (!queued) { - throw new Error("Expected Discord trigger command to be queued."); - } - - const recorded = createRecordedQueueMessage({ body: queued }); - await processApiCommandMessage(bindings, recorded.message); - expect(recorded.recorded).toEqual([{ delaySeconds: 30, type: "retry" }]); - - const row = await database - .app() - .select({ - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.id, queued.commandId)) - .get(); - - expect(row).toEqual({ - lastErrorCode: "discord_work_trigger_failed", - status: "queued", - }); - }); - }); - - test("acknowledges unsupported Discord dispatches without retrying", async () => { - await withDiscordFetchMock(async () => { - const app = createDiscordRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - const body = JSON.stringify({ - d: {}, - op: 0, - s: 3, - t: "TYPING_START", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(createDiscordEventsUrl(binding.id), { - body, - headers: { - "x-mosoo-discord-relay-signature": await signDiscordRelayBody({ - body, - relaySecret: "discord-relay-secret", - timestamp, - }), - "x-mosoo-discord-relay-timestamp": timestamp, - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ignored: true, ok: true }); - }); - }); -}); diff --git a/apps/api/tests/discord-gateway-owner.test.ts b/apps/api/tests/discord-gateway-owner.test.ts deleted file mode 100644 index 057c2d8e..00000000 --- a/apps/api/tests/discord-gateway-owner.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; - -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createDiscordAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import { readChannelConnectionOwnerSnapshot } from "../src/modules/channels/application/channel-connection-state.service"; -import { parseDiscordGatewayDispatchEnvelope } from "../src/modules/channels/discord/discord-events"; -import type { DiscordGatewayWritableSocket } from "../src/modules/channels/discord/discord-gateway-client"; -import { - DiscordGatewayConnectionRelayError, - DiscordGatewayRuntimeOwner, -} from "../src/modules/channels/discord/discord-gateway-owner"; -import type { DiscordGatewayRelayRequest } from "../src/modules/channels/discord/discord-gateway-relay"; -import { verifyDiscordRelaySignature } from "../src/modules/channels/discord/discord-signing"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -class FakeGatewaySocket implements DiscordGatewayWritableSocket { - readonly sentFrames: string[] = []; - closeCode: number | null = null; - - close(code?: number): void { - this.closeCode = code ?? null; - } - - send(data: string): void { - this.sentFrames.push(data); - } -} - -function installDiscordIdentityFetch(): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://discord.com/api/v10/users/@me") { - return Response.json({ - bot: true, - id: "discord-bot-1", - username: "mosoobot", - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -async function createDiscordBindingFixture(): Promise<{ - bindingId: string; - bindings: ApiBindings; -}> { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const restoreFetch = installDiscordIdentityFetch(); - - try { - const binding = await createDiscordAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - applicationId: "discord-app-1", - botToken: "discord-token", - appId: PUBLIC_API_TEST_IDS.app, - relaySecret: "discord-relay-secret", - }); - - return { - bindingId: binding.id, - bindings, - }; - } finally { - restoreFetch(); - } -} - -function createMessageCreateFrame(input: { sequence: number }): string { - return JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: input.sequence, - t: "MESSAGE_CREATE", - }); -} - -describe("Discord Gateway runtime owner", () => { - test("claims the runtime lease and relays Gateway dispatches through the signed callback contract", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - let nowMs = 1_000; - const socket = new FakeGatewaySocket(); - const relayRequests: DiscordGatewayRelayRequest[] = []; - const owner = await DiscordGatewayRuntimeOwner.claim({ - apiBaseUrl: "https://api.mosoo.example", - bindingId, - bindings, - botToken: "discord-token", - leaseDurationMs: 60_000, - nowMs: () => nowMs, - ownerId: "gateway-01J00000000000000000000001", - relayFetch: async (request) => { - relayRequests.push(request); - return Response.json({ accepted: true, ok: true }); - }, - relaySecret: "discord-relay-secret", - resolveRelayChannelType: async () => 1, - socket, - }); - - expect(owner).not.toBeNull(); - if (!owner) { - throw new Error("Expected Discord Gateway runtime owner."); - } - - expect( - await readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).toMatchObject({ - leaseExpiresAtMs: 61_000, - leaseOwnerId: "gateway-01J00000000000000000000001", - status: "starting", - }); - - expect( - await owner.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })), - ).toBe("identified"); - expect(socket.sentFrames).toHaveLength(1); - - nowMs = 1_100; - expect( - await owner.handleMessage( - JSON.stringify({ - d: { resume_gateway_url: "wss://resume.discord.example", session_id: "session-1" }, - op: 0, - s: 7, - t: "READY", - }), - ), - ).toBe("ignored"); - - nowMs = 1_200; - expect(await owner.handleMessage(createMessageCreateFrame({ sequence: 8 }))).toBe("dispatch"); - expect(relayRequests).toHaveLength(1); - expect(relayRequests[0]?.url).toBe( - buildAgentChannelWebhookUrl({ - bindingId, - origin: "https://api.mosoo.example", - provider: "discord", - }), - ); - - const relayRequest = relayRequests[0]; - if (!relayRequest) { - throw new Error("Expected Discord relay request."); - } - - await expect( - verifyDiscordRelaySignature({ - body: relayRequest.body, - headers: new Headers(relayRequest.headers), - nowSeconds: 1, - relaySecret: "discord-relay-secret", - }), - ).resolves.toEqual({ ok: true }); - - const parsedRelayBody = parseDiscordGatewayDispatchEnvelope(relayRequest.body); - expect(parsedRelayBody.ok).toBe(true); - if (!parsedRelayBody.ok) { - throw new Error("Expected relay body to parse."); - } - expect(parsedRelayBody.envelope.message.channelType).toBe(1); - - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastInboundAtMs: 1_200, - leaseExpiresAtMs: 61_200, - leaseOwnerId: "gateway-01J00000000000000000000001", - status: "running", - }); - }); - - test("rejects duplicate runtime owners before the lease expires", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const socket = new FakeGatewaySocket(); - - await DiscordGatewayRuntimeOwner.claim({ - apiBaseUrl: "https://api.mosoo.example", - bindingId, - bindings, - botToken: "discord-token", - leaseDurationMs: 60_000, - nowMs: () => 1_000, - ownerId: "gateway-01J00000000000000000000001", - relaySecret: "discord-relay-secret", - resolveRelayChannelType: async () => 1, - socket, - }); - - await expect( - DiscordGatewayRuntimeOwner.claim({ - apiBaseUrl: "https://api.mosoo.example", - bindingId, - bindings, - botToken: "discord-token", - leaseDurationMs: 60_000, - nowMs: () => 2_000, - ownerId: "gateway-owner-2", - relaySecret: "discord-relay-secret", - resolveRelayChannelType: async () => 1, - socket: new FakeGatewaySocket(), - }), - ).resolves.toBeNull(); - }); - - test("records relay failures as recoverable runtime health", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - let nowMs = 1_000; - const socket = new FakeGatewaySocket(); - const owner = await DiscordGatewayRuntimeOwner.claim({ - apiBaseUrl: "https://api.mosoo.example", - bindingId, - bindings, - botToken: "discord-token", - leaseDurationMs: 60_000, - nowMs: () => nowMs, - ownerId: "gateway-01J00000000000000000000001", - relayFetch: async () => Response.json({ ok: false }, { status: 503 }), - relaySecret: "discord-relay-secret", - resolveRelayChannelType: async () => 1, - socket, - }); - - expect(owner).not.toBeNull(); - if (!owner) { - throw new Error("Expected Discord Gateway runtime owner."); - } - - await owner.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - nowMs = 1_200; - - await expect(owner.handleMessage(createMessageCreateFrame({ sequence: 8 }))).rejects.toThrow( - DiscordGatewayConnectionRelayError, - ); - expect(socket.closeCode).toBeNull(); - - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastErrorCode: "relay_http_503", - status: "running", - }); - }); - - test("records Gateway protocol parse failures as reconnecting runtime health", async () => { - const { bindingId, bindings } = await createDiscordBindingFixture(); - const owner = await DiscordGatewayRuntimeOwner.claim({ - apiBaseUrl: "https://api.mosoo.example", - bindingId, - bindings, - botToken: "discord-token", - leaseDurationMs: 60_000, - nowMs: () => 1_000, - ownerId: "gateway-01J00000000000000000000001", - relaySecret: "discord-relay-secret", - resolveRelayChannelType: async () => 1, - socket: new FakeGatewaySocket(), - }); - - expect(owner).not.toBeNull(); - if (!owner) { - throw new Error("Expected Discord Gateway runtime owner."); - } - - await expect(owner.handleMessage("{")).rejects.toThrow(); - - await expect( - readChannelConnectionOwnerSnapshot({ - bindingId, - bindings, - provider: "discord", - }), - ).resolves.toMatchObject({ - lastErrorCode: "gateway_protocol_error", - status: "reconnecting", - }); - }); -}); diff --git a/apps/api/tests/discord-gateway-runtime.test.ts b/apps/api/tests/discord-gateway-runtime.test.ts deleted file mode 100644 index 4df5e5bb..00000000 --- a/apps/api/tests/discord-gateway-runtime.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; - -import type { DiscordGatewayDispatchEnvelope } from "../src/modules/channels/discord/discord-events"; -import { parseDiscordGatewayDispatchEnvelope } from "../src/modules/channels/discord/discord-events"; -import type { DiscordGatewayWritableSocket } from "../src/modules/channels/discord/discord-gateway-client"; -import { - DISCORD_GATEWAY_DEFAULT_INTENTS, - DiscordGatewayClient, -} from "../src/modules/channels/discord/discord-gateway-client"; -import { summarizeDiscordGatewayHealth } from "../src/modules/channels/discord/discord-gateway-health"; -import { - buildDiscordGatewayRelayBody, - createDiscordGatewayRelayRequest, -} from "../src/modules/channels/discord/discord-gateway-relay"; -import { verifyDiscordRelaySignature } from "../src/modules/channels/discord/discord-signing"; - -class FakeGatewaySocket implements DiscordGatewayWritableSocket { - readonly sentFrames: string[] = []; - closeCode: number | null = null; - closeReason: string | null = null; - - close(code?: number, reason?: string): void { - this.closeCode = code ?? null; - this.closeReason = reason ?? null; - } - - send(data: string): void { - this.sentFrames.push(data); - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readSentFrame(socket: FakeGatewaySocket, index: number): Record { - const frame = socket.sentFrames.at(index); - - if (!frame) { - throw new Error(`Expected gateway frame at index ${index}.`); - } - - const parsed: unknown = JSON.parse(frame); - - if (!isRecord(parsed)) { - throw new Error("Expected sent gateway frame to be a JSON object."); - } - - return parsed; -} - -function createMessageCreateEnvelope(): DiscordGatewayDispatchEnvelope { - const parsed = parseDiscordGatewayDispatchEnvelope( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: 42, - t: "MESSAGE_CREATE", - }), - ); - - if (!parsed.ok) { - throw new Error("Expected Discord dispatch parse success."); - } - - return parsed.envelope; -} - -describe("Discord gateway runtime scaffold", () => { - test("identifies on HELLO and sends heartbeats with the latest sequence", () => { - let nowMs = 1000; - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => nowMs, - onDispatch() {}, - socket, - token: "discord-token", - }); - - expect( - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })), - ).toBe("identified"); - expect(readSentFrame(socket, 0)).toEqual({ - d: { - intents: DISCORD_GATEWAY_DEFAULT_INTENTS, - properties: { - browser: "mosoo", - device: "mosoo", - os: "cloudflare", - }, - token: "discord-token", - }, - op: 2, - }); - - nowMs = 1200; - expect( - client.handleMessage( - JSON.stringify({ - d: { session_id: "session-1", resume_gateway_url: "wss://resume.example" }, - op: 0, - s: 7, - t: "READY", - }), - ), - ).toBe("ignored"); - client.sendHeartbeat(); - - expect(readSentFrame(socket, 1)).toEqual({ d: 7, op: 1 }); - expect(client.getSnapshot()).toMatchObject({ - heartbeatIntervalMs: 45_000, - lastHeartbeatSentAtMs: 1200, - resumeGatewayUrl: "wss://resume.example", - sequence: 7, - sessionId: "session-1", - status: "connected", - }); - }); - - test("resumes when HELLO arrives with a previous session", () => { - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => 2000, - onDispatch() {}, - resumeState: { - resumeGatewayUrl: "wss://resume.example", - sequence: 99, - sessionId: "session-1", - }, - socket, - token: "discord-token", - }); - - expect( - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })), - ).toBe("resumed"); - expect(readSentFrame(socket, 0)).toEqual({ - d: { - seq: 99, - session_id: "session-1", - token: "discord-token", - }, - op: 6, - }); - }); - - test("identifies instead of sending invalid Resume frames when resume state is incomplete", () => { - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => 2000, - onDispatch() {}, - resumeState: { - resumeGatewayUrl: null, - sequence: null, - sessionId: "session-1", - }, - socket, - token: "discord-token", - }); - - expect( - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })), - ).toBe("identified"); - expect(readSentFrame(socket, 0)).toMatchObject({ - d: { - token: "discord-token", - }, - op: 2, - }); - expect(client.getSnapshot()).toMatchObject({ - resumeGatewayUrl: null, - sequence: null, - sessionId: null, - status: "connected", - }); - }); - - test("preserves reconnecting status after expected Gateway reconnect closes", () => { - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => 3000, - onDispatch() {}, - socket, - token: "discord-token", - }); - - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - - expect(client.handleMessage(JSON.stringify({ d: null, op: 7 }))).toBe("reconnect_requested"); - expect(client.getSnapshot()).toMatchObject({ - lastCloseCode: 4000, - status: "reconnecting", - }); - - client.handleClose(4000); - expect(client.getSnapshot()).toMatchObject({ - lastCloseCode: 4000, - status: "reconnecting", - }); - }); - - test("marks connecting and reconnecting gateway snapshots stale", () => { - const socket = new FakeGatewaySocket(); - const client = new DiscordGatewayClient({ - nowMs: () => 1000, - onDispatch() {}, - socket, - token: "discord-token", - }); - - expect( - summarizeDiscordGatewayHealth(client.getSnapshot(), { - nowMs: 20_000, - staleAfterMs: 10_000, - }), - ).toEqual({ - reason: "connecting_stale", - stale: true, - status: "stale", - }); - - client.handleError("socket_error"); - - expect( - summarizeDiscordGatewayHealth(client.getSnapshot(), { - nowMs: 20_000, - staleAfterMs: 10_000, - }), - ).toEqual({ - reason: "socket_error", - stale: true, - status: "stale", - }); - }); - - test("rejects dispatches without a sequence instead of clearing resume state", () => { - let nowMs = 3000; - const socket = new FakeGatewaySocket(); - const dispatches: DiscordGatewayDispatchEnvelope[] = []; - const client = new DiscordGatewayClient({ - nowMs: () => nowMs, - onDispatch(dispatch) { - dispatches.push(dispatch); - }, - resumeState: { - resumeGatewayUrl: "wss://resume.example", - sequence: 99, - sessionId: "session-1", - }, - socket, - token: "discord-token", - }); - - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - nowMs = 3100; - expect( - client.handleMessage( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: null, - t: "MESSAGE_CREATE", - }), - ), - ).toBe("protocol_error"); - - expect(dispatches).toEqual([]); - expect(socket.closeCode).toBe(4000); - expect(client.getSnapshot()).toMatchObject({ - lastErrorCode: "missing_dispatch_sequence", - resumeGatewayUrl: "wss://resume.example", - sequence: 99, - sessionId: "session-1", - status: "reconnecting", - }); - }); - - test("dispatches MESSAGE_CREATE and keeps runtime health observable", () => { - let nowMs = 3000; - const socket = new FakeGatewaySocket(); - const dispatches: DiscordGatewayDispatchEnvelope[] = []; - const client = new DiscordGatewayClient({ - nowMs: () => nowMs, - onDispatch(dispatch) { - dispatches.push(dispatch); - }, - socket, - token: "discord-token", - }); - - client.handleMessage(JSON.stringify({ d: { heartbeat_interval: 45_000 }, op: 10 })); - nowMs = 3100; - client.handleMessage( - JSON.stringify({ - d: { - author: { bot: false, id: "user-1", username: "Ada" }, - channel_id: "dm-1", - content: "review this", - id: "message-1", - }, - op: 0, - s: 42, - t: "MESSAGE_CREATE", - }), - ); - nowMs = 3200; - expect(client.handleMessage(JSON.stringify({ d: null, op: 11 }))).toBe("heartbeat_ack"); - - expect(dispatches).toHaveLength(1); - expect(dispatches[0]?.message.id).toBe("message-1"); - expect( - summarizeDiscordGatewayHealth(client.getSnapshot(), { - nowMs: 3500, - staleAfterMs: 10_000, - }), - ).toEqual({ - reason: null, - stale: false, - status: "connected", - }); - expect( - summarizeDiscordGatewayHealth(client.getSnapshot(), { - nowMs: 30_000, - staleAfterMs: 10_000, - }), - ).toEqual({ - reason: "heartbeat_ack_stale", - stale: true, - status: "stale", - }); - }); - - test("builds signed relay requests that the existing Discord route verifier accepts", async () => { - const envelope = createMessageCreateEnvelope(); - const body = buildDiscordGatewayRelayBody({ - envelope, - relayChannelType: 1, - }); - const parsed = parseDiscordGatewayDispatchEnvelope(body); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - throw new Error("Expected relay body to parse."); - } - - expect(parsed.envelope.message.channelType).toBe(1); - - const request = await createDiscordGatewayRelayRequest({ - apiBaseUrl: "https://api.example.com/", - bindingId: "binding-1", - envelope, - nowSeconds: 1779646500, - relayChannelType: 1, - relaySecret: "relay-secret", - }); - - expect(request.url).toBe( - buildAgentChannelWebhookUrl({ - bindingId: "binding-1", - origin: "https://api.example.com/", - provider: "discord", - }), - ); - await expect( - verifyDiscordRelaySignature({ - body: request.body, - headers: new Headers(request.headers), - nowSeconds: 1779646500, - relaySecret: "relay-secret", - }), - ).resolves.toEqual({ ok: true }); - }); -}); diff --git a/apps/api/tests/helpers/channel-final-delivery-queue-fixture.ts b/apps/api/tests/helpers/api-command-queue-fixture.ts similarity index 64% rename from apps/api/tests/helpers/channel-final-delivery-queue-fixture.ts rename to apps/api/tests/helpers/api-command-queue-fixture.ts index e5c8a7e2..3cd37ca5 100644 --- a/apps/api/tests/helpers/channel-final-delivery-queue-fixture.ts +++ b/apps/api/tests/helpers/api-command-queue-fixture.ts @@ -1,5 +1,4 @@ import type { ApiCommandMessage } from "../../src/modules/api-command/application/api-command-message"; -import type { ChannelFinalDeliveryMessage } from "../../src/modules/channels/application/channel-final-delivery-message"; const TEST_MESSAGE_TIME_MS = Date.parse("2026-05-08T00:00:00.000Z"); @@ -10,13 +9,6 @@ export interface CapturedApiCommandMessage { id: string; } -export interface CapturedChannelFinalDeliveryMessage { - body: ChannelFinalDeliveryMessage; - contentType: string; - delaySeconds: number | null; - id: string; -} - export interface ApiCommandQueueStub { readonly sent: CapturedApiCommandMessage[]; send( @@ -25,14 +17,6 @@ export interface ApiCommandQueueStub { ): Promise; } -export interface ChannelFinalDeliveryQueueStub { - readonly sent: CapturedChannelFinalDeliveryMessage[]; - send( - body: ChannelFinalDeliveryMessage, - options?: { contentType?: string; delaySeconds?: number }, - ): Promise; -} - export function createApiCommandQueueStub(): ApiCommandQueueStub { const sent: CapturedApiCommandMessage[] = []; @@ -49,22 +33,6 @@ export function createApiCommandQueueStub(): ApiCommandQueueStub { }; } -export function createChannelFinalDeliveryQueueStub(): ChannelFinalDeliveryQueueStub { - const sent: CapturedChannelFinalDeliveryMessage[] = []; - - return { - sent, - async send(body, options): Promise { - sent.push({ - body, - contentType: options?.contentType ?? "json", - delaySeconds: options?.delaySeconds ?? null, - id: `queued-${sent.length + 1}`, - }); - }, - }; -} - export interface RecordedQueueMessageAction { type: "ack" | "retry"; delaySeconds?: number; diff --git a/apps/api/tests/helpers/api-test-fixture.ts b/apps/api/tests/helpers/api-test-fixture.ts index b07dec7e..7682a259 100644 --- a/apps/api/tests/helpers/api-test-fixture.ts +++ b/apps/api/tests/helpers/api-test-fixture.ts @@ -326,46 +326,6 @@ function createApiTestSchema(database: SqliteD1Database): void { updated_at integer NOT NULL ); - CREATE TABLE app_deployment ( - app_id text NOT NULL, - created_at integer NOT NULL, - default_branch text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL, - last_successful_url text, - latest_run_id text, - mosoo_subdomain text NOT NULL, - owner_account_id text NOT NULL, - repo_name text NOT NULL, - repo_owner text NOT NULL, - repo_url text NOT NULL, - source_kind text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - created_at integer NOT NULL, - deployment_id text NOT NULL, - error_code text, - error_message text, - external_deployment_id text, - external_project_id text, - external_version_id text, - generated_wrangler_config_json text, - id text PRIMARY KEY NOT NULL, - mosoo_config_json text, - plan_json text, - source_branch text NOT NULL, - source_commit_sha text NOT NULL, - status text NOT NULL, - target_kind text, - target_project_name text, - target_script_name text, - updated_at integer NOT NULL, - url text - ); - CREATE TABLE api_command ( attempt_count integer DEFAULT 0 NOT NULL, claim_expires_at integer, @@ -709,6 +669,7 @@ function createApiTestSchema(database: SqliteD1Database): void { wrapped_dek text NOT NULL, wrapped_dek_iv text NOT NULL ); + `); } diff --git a/apps/api/tests/helpers/public-api-http-core-schema.sql b/apps/api/tests/helpers/public-api-http-core-schema.sql index e57a0930..b3edd52c 100644 --- a/apps/api/tests/helpers/public-api-http-core-schema.sql +++ b/apps/api/tests/helpers/public-api-http-core-schema.sql @@ -251,115 +251,3 @@ CREATE INDEX api_command_status_updated_idx CREATE INDEX api_command_claim_idx ON api_command (status, claim_expires_at); - -CREATE TABLE agent_channel_binding ( - id text PRIMARY KEY NOT NULL, - agent_id text NOT NULL, - app_id text NOT NULL, - provider text NOT NULL, - encrypted_creds_secret_id text NOT NULL, - external_bot_id text NOT NULL, - external_tenant_id text NOT NULL, - display_metadata_json text DEFAULT '{}' NOT NULL, - status text NOT NULL, - last_error_code text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX agent_channel_binding_agent_provider_idx - ON agent_channel_binding (agent_id, provider); - -CREATE UNIQUE INDEX agent_channel_binding_provider_tenant_bot_idx - ON agent_channel_binding (provider, external_tenant_id, external_bot_id); - -CREATE INDEX agent_channel_binding_app_status_idx - ON agent_channel_binding (app_id, status); - -CREATE TABLE channel_event_receipt ( - id text PRIMARY KEY NOT NULL, - provider text NOT NULL, - external_tenant_id text NOT NULL, - external_event_id text NOT NULL, - binding_id text NOT NULL, - session_id text, - created_at integer NOT NULL, - expires_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX channel_event_receipt_provider_tenant_event_idx - ON channel_event_receipt (provider, external_tenant_id, external_event_id); - -CREATE INDEX channel_event_receipt_binding_updated_idx - ON channel_event_receipt (binding_id, updated_at); - -CREATE INDEX channel_event_receipt_expires_idx - ON channel_event_receipt (expires_at); - -CREATE TABLE channel_thread_session ( - id text PRIMARY KEY NOT NULL, - provider text NOT NULL, - binding_id text NOT NULL, - external_thread_id text NOT NULL, - session_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX channel_thread_session_provider_binding_thread_idx - ON channel_thread_session (provider, binding_id, external_thread_id); - -CREATE INDEX channel_thread_session_session_idx - ON channel_thread_session (session_id); - -CREATE TABLE channel_final_delivery_job ( - id text PRIMARY KEY NOT NULL, - provider text NOT NULL, - binding_id text NOT NULL, - external_event_id text NOT NULL, - session_id text NOT NULL, - run_id text NOT NULL, - payload_json text NOT NULL, - status text NOT NULL, - attempt_count integer DEFAULT 0 NOT NULL, - last_error_code text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX channel_final_delivery_provider_binding_event_idx - ON channel_final_delivery_job (provider, binding_id, external_event_id); - -CREATE INDEX channel_final_delivery_session_idx - ON channel_final_delivery_job (session_id); - -CREATE INDEX channel_final_delivery_run_idx - ON channel_final_delivery_job (run_id); - -CREATE TABLE channel_runtime_state ( - id text PRIMARY KEY NOT NULL, - provider text NOT NULL, - binding_id text NOT NULL, - runtime_account_id text DEFAULT '' NOT NULL, - status text NOT NULL, - lease_owner_id text, - lease_expires_at integer, - last_heartbeat_at integer, - last_inbound_at integer, - last_poll_at integer, - last_error_code text, - runtime_state_json text DEFAULT '{}' NOT NULL, - status_changed_at integer NOT NULL, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX channel_runtime_state_provider_binding_account_idx - ON channel_runtime_state (provider, binding_id, runtime_account_id); - -CREATE INDEX channel_runtime_state_status_lease_idx - ON channel_runtime_state (status, lease_expires_at); - -CREATE INDEX channel_runtime_state_binding_updated_idx - ON channel_runtime_state (binding_id, updated_at); diff --git a/apps/api/tests/helpers/public-api-http-test-fixture.ts b/apps/api/tests/helpers/public-api-http-test-fixture.ts index 09e55a76..e95bf435 100644 --- a/apps/api/tests/helpers/public-api-http-test-fixture.ts +++ b/apps/api/tests/helpers/public-api-http-test-fixture.ts @@ -19,23 +19,18 @@ import type { VendorCredentialId } from "@mosoo/id"; import { hashTokenValue } from "../../src/modules/auth/application/personal-access-token.service"; import { storeVendorCredentialSecret } from "../../src/modules/vendor-credentials/application/vendor-credential.secret-resolution"; import type { ApiBindings } from "../../src/platform/cloudflare/worker-types"; -import type { ApiCommandQueueStub } from "./channel-final-delivery-queue-fixture"; -import type { ChannelFinalDeliveryQueueStub } from "./channel-final-delivery-queue-fixture"; -import { createApiCommandQueueStub } from "./channel-final-delivery-queue-fixture"; -import { createChannelFinalDeliveryQueueStub } from "./channel-final-delivery-queue-fixture"; +import type { ApiCommandQueueStub } from "./api-command-queue-fixture"; +import { createApiCommandQueueStub } from "./api-command-queue-fixture"; import { SqliteD1Database } from "./sqlite-d1"; export { SqliteD1Database } from "./sqlite-d1"; export { createApiCommandQueueStub, - createChannelFinalDeliveryQueueStub, createRecordedQueueMessage, type ApiCommandQueueStub, type CapturedApiCommandMessage, - type CapturedChannelFinalDeliveryMessage, - type ChannelFinalDeliveryQueueStub, type RecordedQueueMessage, type RecordedQueueMessageAction, -} from "./channel-final-delivery-queue-fixture"; +} from "./api-command-queue-fixture"; const CONTRACT_SCHEMA_SQL = readFileSync( new URL("./public-api-http-core-schema.sql", import.meta.url), @@ -268,7 +263,6 @@ export function createPublicHttpTestBindings( options: { apiCommandQueue?: ApiCommandQueueStub; fileBucket?: R2Bucket; - queue?: ChannelFinalDeliveryQueueStub; sessionNamespace?: ApiBindings["Session"]; } = {}, ): Record { @@ -278,12 +272,10 @@ export function createPublicHttpTestBindings( AUTH_EMAIL_FROM: "mosoo AUTH ", BETTER_AUTH_SECRET: "test-secret", API_COMMAND_QUEUE: options.apiCommandQueue ?? createApiCommandQueueStub(), - CHANNEL_FINAL_DELIVERY_QUEUE: options.queue ?? createChannelFinalDeliveryQueueStub(), CLOUDFLARE_ACCOUNT_ID: "test-account", DB: database, FILE_BUCKET: options.fileBucket ?? unavailableBinding("FILE_BUCKET"), FILE_BUCKET_NAME: "mosoo-file", - MOSOO_APP_DEPLOYMENT_DOMAIN: "apps.localhost", R2_ACCESS_KEY_ID: "test-access-key", R2_SECRET_ACCESS_KEY: "test-secret-key", RUNTIME_ACTION_TOKEN_SECRET: "test-runtime-action-token", diff --git a/apps/api/tests/helpers/public-api-http-wechat-schema.sql b/apps/api/tests/helpers/public-api-http-wechat-schema.sql index b0af521c..ee3db6ac 100644 --- a/apps/api/tests/helpers/public-api-http-wechat-schema.sql +++ b/apps/api/tests/helpers/public-api-http-wechat-schema.sql @@ -1,81 +1,3 @@ -CREATE TABLE wechat_channel_account ( - id text PRIMARY KEY NOT NULL, - agent_id text NOT NULL, - app_id text NOT NULL, - owner_account_id text NOT NULL, - external_account_id text NOT NULL, - external_bot_id text NOT NULL, - base_url text NOT NULL, - encrypted_creds_secret_id text NOT NULL, - cursor text, - status text NOT NULL, - last_error_code text, - last_heartbeat_at integer, - last_inbound_at integer, - last_poll_at integer, - runtime_state_json text DEFAULT '{}' NOT NULL, - status_changed_at integer NOT NULL, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX wechat_channel_account_agent_idx - ON wechat_channel_account (agent_id); - -CREATE UNIQUE INDEX wechat_channel_account_external_idx - ON wechat_channel_account (external_account_id, external_bot_id); - -CREATE INDEX wechat_channel_account_status_idx - ON wechat_channel_account (status, updated_at); - -CREATE INDEX wechat_channel_account_app_status_idx - ON wechat_channel_account (app_id, status); - -CREATE TABLE wechat_channel_pairing ( - id text PRIMARY KEY NOT NULL, - agent_id text NOT NULL, - app_id text NOT NULL, - created_by_account_id text NOT NULL, - qr_token_hash text NOT NULL, - expires_at integer NOT NULL, - consumed_at integer, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX wechat_channel_pairing_qr_token_hash_idx - ON wechat_channel_pairing (qr_token_hash); - -CREATE INDEX wechat_channel_pairing_agent_creator_idx - ON wechat_channel_pairing (agent_id, created_by_account_id, consumed_at); - -CREATE INDEX wechat_channel_pairing_app_creator_idx - ON wechat_channel_pairing (app_id, created_by_account_id, consumed_at); - -CREATE INDEX wechat_channel_pairing_expires_idx - ON wechat_channel_pairing (expires_at); - -CREATE TABLE wechat_context_token ( - id text PRIMARY KEY NOT NULL, - account_id text NOT NULL, - external_account_id text NOT NULL, - peer_id text NOT NULL, - to_user_id text NOT NULL, - context_token_key text NOT NULL, - encrypted_context_token_secret_id text NOT NULL, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX wechat_context_token_key_idx - ON wechat_context_token (context_token_key); - -CREATE UNIQUE INDEX wechat_context_token_account_peer_idx - ON wechat_context_token (account_id, external_account_id, peer_id); - -CREATE INDEX wechat_context_token_account_updated_idx - ON wechat_context_token (account_id, updated_at); - CREATE TABLE public_api_rate_limit_window ( bucket_key text NOT NULL, request_count integer DEFAULT 0 NOT NULL, @@ -98,23 +20,6 @@ CREATE TABLE public_api_idempotency_key ( updated_at integer NOT NULL ); -CREATE TABLE bound_agent_call_idempotency_key ( - id text PRIMARY KEY NOT NULL, - subject_hash text NOT NULL, - idempotency_key text NOT NULL, - body_hash text NOT NULL, - session_id text NOT NULL, - run_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX bound_agent_call_idempotency_subject_key_idx - ON bound_agent_call_idempotency_key (subject_hash, idempotency_key); - -CREATE INDEX bound_agent_call_idempotency_updated_idx - ON bound_agent_call_idempotency_key (updated_at); - CREATE TABLE session ( id text PRIMARY KEY NOT NULL, app_id text NOT NULL, @@ -149,12 +54,6 @@ CREATE TABLE session_run ( id text PRIMARY KEY NOT NULL, session_id text NOT NULL, agent_id text NOT NULL, - bound_capability_agent_id text, - bound_capability_app_id text, - bound_capability_binding_env text, - bound_capability_binding_name text, - bound_capability_deployment_id text, - bound_capability_deployment_run_id text, created_by_account_id text NOT NULL, deployment_version_id text, deployment_version_number integer, diff --git a/apps/api/tests/http-route-platform-id-error.test.ts b/apps/api/tests/http-route-platform-id-error.test.ts index c845adc7..328f91ed 100644 --- a/apps/api/tests/http-route-platform-id-error.test.ts +++ b/apps/api/tests/http-route-platform-id-error.test.ts @@ -7,7 +7,6 @@ import { platformIdRouteErrorMessage, platformIdRouteErrorResponse, } from "../src/adapters/http/routes/platform-id-route-error"; -import { registerTelegramEventsRoute } from "../src/adapters/http/routes/telegram-events-route"; import { createRuntimeActionToken } from "../src/modules/runtime/infrastructure/runtime-boot-token"; import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; import { @@ -23,14 +22,6 @@ function createDriverRouteTestApp(): Hono { return app; } -function createTelegramRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - registerTelegramEventsRoute(publicApi); - app.route("/api", publicApi); - return app; -} - describe("HTTP route platform ID errors", () => { test("recognizes platform ID parse errors without swallowing unrelated TypeErrors", () => { expect(platformIdRouteErrorMessage(new TypeError("Agent ID must be a valid ULID."))).toBe( @@ -67,25 +58,4 @@ describe("HTTP route platform ID errors", () => { error: "Skill snapshot ID must be a valid ULID.", }); }); - - test("maps malformed channel route IDs to 400", async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const response = await createTelegramRouteTestApp().request( - new Request("https://api.example.com/api/v1/channels/telegram/events/not-a-ulid", { - body: "{}", - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ - code: "invalid_request", - error: "Channel binding ID must be a valid ULID.", - ok: false, - }); - }); }); diff --git a/apps/api/tests/lark-channel-adapter.test.ts b/apps/api/tests/lark-channel-adapter.test.ts deleted file mode 100644 index 613dffc3..00000000 --- a/apps/api/tests/lark-channel-adapter.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ChannelSessionCommandClient } from "../src/modules/channels/application/channel-session.types"; -import { - normalizeLarkWorkTrigger, - parseLarkEventsEnvelope, -} from "../src/modules/channels/lark/lark-events"; -import { processLarkWorkTrigger } from "../src/modules/channels/lark/lark-first-party-adapter"; -import { verifyLarkSignature } from "../src/modules/channels/lark/lark-signing"; -import { LarkWebApiClient } from "../src/modules/channels/lark/lark-web-api"; -import { readFetchUrl } from "./helpers/fetch-request-url"; - -function readJsonRequestBody(init: RequestInit | undefined): unknown { - if (typeof init?.body !== "string") { - throw new Error("Expected mocked Lark request body to be a JSON string."); - } - - return JSON.parse(init.body); -} - -async function signLarkBody(input: { - body: string; - encryptKey: string; - nonce: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const bytes = encoder.encode(input.timestamp + input.nonce + input.encryptKey + input.body); - const digest = await crypto.subtle.digest("SHA-256", bytes); - return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -describe("Lark channel adapter", () => { - test("verifies Lark signatures over the raw request body", async () => { - const body = JSON.stringify({ challenge: "challenge-ok", type: "url_verification" }); - const timestamp = "1779646500"; - const nowMs = Number(timestamp) * 1000; - const nonce = "nonce-1"; - const encryptKey = "encrypt-key"; - const signature = await signLarkBody({ body, encryptKey, nonce, timestamp }); - - await expect( - verifyLarkSignature({ - body, - encryptKey, - headers: new Headers({ - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": signature, - }), - nowMs, - }), - ).resolves.toEqual({ ok: true }); - - await expect( - verifyLarkSignature({ - body, - encryptKey: "wrong-key", - headers: new Headers({ - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": signature, - }), - nowMs, - }), - ).resolves.toMatchObject({ code: "signature_mismatch", ok: false, status: 401 }); - }); - - test("normalizes im.message.receive_v1 events with domain-specific metadata", () => { - const body = JSON.stringify({ - event: { - message: { - chat_id: "oc_chat", - chat_type: "group", - content: JSON.stringify({ text: "@_user_1 review the launch plan" }), - message_id: "om_message", - mentions: [{ id: { open_id: "ou_bot" }, name: "mosoobot" }], - root_id: "om_root", - }, - sender: { - sender_id: { - open_id: "ou_alice", - union_id: "on_union", - user_id: "user_alice", - }, - sender_type: "user", - }, - }, - header: { - event_id: "ev_lark_1", - event_type: "im.message.receive_v1", - tenant_key: "tenant_1", - token: "verification-token", - }, - schema: "2.0", - }); - const parsed = parseLarkEventsEnvelope(body, { - verificationToken: "verification-token", - }); - - expect(parsed.ok).toBe(true); - if (!parsed.ok || parsed.envelope.type !== "event_callback") { - throw new Error("Expected Lark event_callback envelope."); - } - - expect(normalizeLarkWorkTrigger(parsed.envelope)).toEqual({ - chatId: "oc_chat", - chatType: "group", - eventId: "lark:event:ev_lark_1", - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - messageId: "om_message", - parentId: null, - rootId: "om_root", - senderOpenId: "ou_alice", - senderType: "user", - senderUnionId: "on_union", - senderUserId: "user_alice", - tenantKey: "tenant_1", - text: "review the launch plan", - }); - }); - - test("chooses the correct Open Platform origin for Lark and Feishu domains", async () => { - const originalFetch = globalThis.fetch; - const urls: string[] = []; - globalThis.fetch = async (url) => { - urls.push(readFetchUrl(url)); - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - }; - - try { - await new LarkWebApiClient({ - appId: "cli_a", - appSecret: "secret", - domain: "lark", - }).getTenantAccessToken(); - await new LarkWebApiClient({ - appId: "cli_b", - appSecret: "secret", - domain: "feishu", - }).getTenantAccessToken(); - - expect(urls).toEqual([ - "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal", - "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("writes Lark working and final replies through message reply API", async () => { - const originalFetch = globalThis.fetch; - const requestBodies: unknown[] = []; - const finalDeliveryJobs: unknown[] = []; - let capturedConnectionMode: unknown; - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - requestBodies.push(readJsonRequestBody(init)); - return Response.json({ code: 0, data: {} }); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession(command) { - capturedConnectionMode = command.trigger.providerMetadata["connection_mode"]; - expect(command.trigger.externalWorkspaceId).toBe("tenant_1"); - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError() {}, - async retrieveSessionReply() { - throw new Error("Lark webhook path must not poll final replies."); - }, - }; - - try { - await processLarkWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - appId: "cli_a", - appSecret: "secret", - bindingId: "binding-1", - connectionMode: "webhook", - domain: "feishu", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue(job) { - finalDeliveryJobs.push(job); - }, - }, - sessionClient, - trigger: { - chatId: "oc_chat", - chatType: "group", - eventId: "lark:event:ev_lark_1", - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - messageId: "om_message", - parentId: null, - rootId: "om_root", - senderOpenId: "ou_alice", - senderType: "user", - senderUnionId: "on_union", - senderUserId: "user_alice", - tenantKey: "tenant_1", - text: "review this", - }, - }); - - expect(capturedConnectionMode).toBe("webhook"); - expect(requestBodies).toEqual([ - { - content: JSON.stringify({ - text: "mosoo session created: https://mosoo.ai/agent/01J00000000000000000000009?tab=consume&sessionId=session-1. Agent is working...", - }), - msg_type: "text", - }, - ]); - expect(finalDeliveryJobs).toEqual([ - { - bindingId: "binding-1", - externalEventId: "lark:event:ev_lark_1", - payload: { - messageId: "om_message", - provider: "lark", - }, - provider: "lark", - runId: "run-1", - sessionId: "session-1", - }, - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not mark Lark binding error when message reply fails after token acquisition", async () => { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - const markedBindingErrors: string[] = []; - globalThis.reportError = () => {}; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - return Response.json({ code: 999, msg: "message is not replyable" }); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - markedBindingErrors.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Lark webhook path must not poll final replies."); - }, - }; - - try { - await processLarkWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - appId: "cli_a", - appSecret: "secret", - bindingId: "binding-1", - connectionMode: "webhook", - domain: "feishu", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Reply failure must stop final delivery enqueue."); - }, - }, - sessionClient, - trigger: { - chatId: "oc_chat", - chatType: "group", - eventId: "lark:event:ev_lark_reply_failure", - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - messageId: "om_message", - parentId: null, - rootId: "om_root", - senderOpenId: "ou_alice", - senderType: "user", - senderUnionId: "on_union", - senderUserId: "user_alice", - tenantKey: "tenant_1", - text: "review this", - }, - }); - - expect(markedBindingErrors).toEqual([]); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - }); - - test("marks Lark binding error when message reply fails with a permission error", async () => { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - const markedBindingErrors: string[] = []; - globalThis.reportError = () => {}; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - return Response.json({ code: 230035, msg: "Send Message Permission deny." }); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - markedBindingErrors.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Lark webhook path must not poll final replies."); - }, - }; - - try { - await processLarkWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - appId: "cli_a", - appSecret: "secret", - bindingId: "binding-1", - connectionMode: "webhook", - domain: "feishu", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Permission failure must stop final delivery enqueue."); - }, - }, - sessionClient, - trigger: { - chatId: "oc_chat", - chatType: "group", - eventId: "lark:event:ev_lark_permission_failure", - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - messageId: "om_message", - parentId: null, - rootId: "om_root", - senderOpenId: "ou_alice", - senderType: "user", - senderUnionId: "on_union", - senderUserId: "user_alice", - tenantKey: "tenant_1", - text: "review this", - }, - }); - - expect(markedBindingErrors).toEqual(["lark_230035"]); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - }); - - test("marks Lark binding error when tenant token acquisition fails", async () => { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - const markedBindingErrors: string[] = []; - globalThis.reportError = () => {}; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ code: 99991663, msg: "invalid app_secret" }); - } - - throw new Error(`Unexpected Lark fetch: ${requestUrl}`); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - markedBindingErrors.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Lark webhook path must not poll final replies."); - }, - }; - - try { - await processLarkWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - appId: "cli_a", - appSecret: "bad-secret", - bindingId: "binding-1", - connectionMode: "webhook", - domain: "feishu", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Token failure must stop final delivery enqueue."); - }, - }, - sessionClient, - trigger: { - chatId: "oc_chat", - chatType: "group", - eventId: "lark:event:ev_lark_token_failure", - externalActorId: "lark:ou_alice", - externalMessageId: "om_message", - externalThreadId: "oc_chat:om_root", - messageId: "om_message", - parentId: null, - rootId: "om_root", - senderOpenId: "ou_alice", - senderType: "user", - senderUnionId: "on_union", - senderUserId: "user_alice", - tenantKey: "tenant_1", - text: "review this", - }, - }); - - expect(markedBindingErrors).toEqual(["lark_99991663"]); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - }); -}); diff --git a/apps/api/tests/lark-channel-events-route.test.ts b/apps/api/tests/lark-channel-events-route.test.ts deleted file mode 100644 index eb0c53ae..00000000 --- a/apps/api/tests/lark-channel-events-route.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import { agentChannelBindingsTable } from "@mosoo/db"; -import { Hono } from "hono"; - -import { registerLarkEventsRoute } from "../src/adapters/http/routes/lark-events-route"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createLarkAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import { storeAgentChannelBindingCredentialSecret } from "../src/modules/channels/application/channel-credential-secret-resolution"; -import { serializeLarkCredentials } from "../src/modules/channels/lark/lark-credentials"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -function createLarkRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - registerLarkEventsRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function createLarkEventsUrl(bindingId: string): string { - return buildAgentChannelWebhookUrl({ - bindingId, - origin: "https://api.example.com", - provider: "lark", - }); -} - -async function signLarkBody(input: { - body: string; - encryptKey: string; - nonce: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const bytes = encoder.encode(input.timestamp + input.nonce + input.encryptKey + input.body); - const digest = await crypto.subtle.digest("SHA-256", bytes); - - return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); -} - -function currentLarkTimestamp(): string { - return String(Math.floor(Date.now() / 1000)); -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - - return btoa(binary); -} - -async function encryptLarkBody(input: { body: string; encryptKey: string }): Promise { - const encoder = new TextEncoder(); - const keyBytes = await crypto.subtle.digest("SHA-256", encoder.encode(input.encryptKey)); - const key = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-CBC" }, false, [ - "encrypt", - ]); - const iv = new Uint8Array(16); - iv.set([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3]); - const encrypted = new Uint8Array( - await crypto.subtle.encrypt({ iv, name: "AES-CBC" }, key, encoder.encode(input.body)), - ); - const output = new Uint8Array(iv.length + encrypted.length); - output.set(iv, 0); - output.set(encrypted, iv.length); - - return bytesToBase64(output); -} - -async function withLarkFetchMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - if (requestUrl === "https://open.feishu.cn/open-apis/bot/v3/info") { - return Response.json({ - code: 0, - bot: { - app_name: "mosoo Feishu", - open_id: "ou_bot", - }, - }); - } - - return Response.json({ code: 0, data: {} }); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -describe("Lark channel events route", () => { - test("answers signed Lark url_verification challenges for a binding", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - challenge: "challenge-ok", - token: "verification-token", - type: "url_verification", - }); - const timestamp = currentLarkTimestamp(); - const nonce = "nonce-1"; - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": await signLarkBody({ - body, - encryptKey: "encrypt-key", - nonce, - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ challenge: "challenge-ok" }); - }); - }); - - test("decrypts encrypted Lark url_verification callbacks before token validation", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const encrypted = await encryptLarkBody({ - body: JSON.stringify({ - challenge: "encrypted-challenge-ok", - token: "verification-token", - type: "url_verification", - }), - encryptKey: "encrypt-key", - }); - const body = JSON.stringify({ encrypt: encrypted }); - const timestamp = currentLarkTimestamp(); - const nonce = "nonce-1"; - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": await signLarkBody({ - body, - encryptKey: "encrypt-key", - nonce, - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ challenge: "encrypted-challenge-ok" }); - }); - }); - - test("rejects Lark callbacks when the per-binding signature mismatches", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - challenge: "challenge-ok", - token: "verification-token", - type: "url_verification", - }); - const timestamp = currentLarkTimestamp(); - const nonce = "nonce-1"; - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": await signLarkBody({ - body, - encryptKey: "wrong-key", - nonce, - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ - code: "signature_mismatch", - ok: false, - }); - }); - }); - - test("rejects Lark callbacks with incomplete signature headers", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - challenge: "challenge-ok", - token: "verification-token", - type: "url_verification", - }); - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-signature": "partial-signature", - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ - code: "missing_header", - ok: false, - }); - }); - }); - - test("rejects Lark callbacks without signature headers", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - challenge: "challenge-ok", - token: "verification-token", - type: "url_verification", - }); - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ - code: "missing_header", - ok: false, - }); - }); - }); - - test("rejects Lark callbacks with stale signature timestamps", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - challenge: "challenge-ok", - token: "verification-token", - type: "url_verification", - }); - const timestamp = "1"; - const nonce = "nonce-1"; - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": await signLarkBody({ - body, - encryptKey: "encrypt-key", - nonce, - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ - code: "stale_timestamp", - ok: false, - }); - }); - }); - - test("acknowledges webhook callbacks for legacy websocket-mode bindings", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const bindingId = "01J00000000000000000000100"; - const secretId = await storeAgentChannelBindingCredentialSecret(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - credentialsJson: serializeLarkCredentials({ - appId: "cli_a", - appSecret: "app-secret", - connectionMode: "websocket", - domain: "feishu", - encryptKey: null, - verificationToken: null, - }), - appId: PUBLIC_API_TEST_IDS.app, - provider: "lark", - purpose: "channel_binding_create", - }); - await database - .app() - .insert(agentChannelBindingsTable) - .values({ - agentId: PUBLIC_API_TEST_IDS.agent, - createdAt: Date.now(), - displayMetadataJson: "{}", - encryptedCredsSecretId: secretId, - externalBotId: "ou_bot", - externalTenantId: "feishu:cli_a", - id: bindingId, - lastErrorCode: null, - appId: PUBLIC_API_TEST_IDS.app, - provider: "lark", - status: "active", - updatedAt: Date.now(), - }) - .run(); - const response = await app.request( - new Request(createLarkEventsUrl(bindingId), { - body: JSON.stringify({ ignored: "unsigned-webhook-body" }), - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ignored: true, ok: true }); - }); - }); - - test("acknowledges unsupported signed Lark event types without retrying", async () => { - await withLarkFetchMock(async () => { - const app = createLarkRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_a", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - const body = JSON.stringify({ - event: {}, - header: { - event_id: "unsupported-event", - event_type: "contact.user.created_v3", - tenant_key: "tenant-key", - token: "verification-token", - }, - }); - const timestamp = currentLarkTimestamp(); - const nonce = "nonce-1"; - const response = await app.request( - new Request(createLarkEventsUrl(binding.id), { - body, - headers: { - "x-lark-request-nonce": nonce, - "x-lark-request-timestamp": timestamp, - "x-lark-signature": await signLarkBody({ - body, - encryptKey: "encrypt-key", - nonce, - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ignored: true, ok: true }); - }); - }); -}); diff --git a/apps/api/tests/multi-provider-channel-session.test.ts b/apps/api/tests/multi-provider-channel-session.test.ts deleted file mode 100644 index c6a177cf..00000000 --- a/apps/api/tests/multi-provider-channel-session.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { channelThreadSessionsTable, sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { - createLarkAgentChannelBinding, - createTelegramAgentChannelBinding, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import { - createChannelSessionClient, - resolveAgentChannelBindingContextById, -} from "../src/modules/channels/application/channel-session.service"; -import { setSessionRunStatus } from "../src/modules/runtime/infrastructure/session-runs/session-run-store.repository"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { OWNER_VIEWER, parseJsonRecord, readRecord } from "./channel-session-fixtures"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -async function withProviderFetchMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://api.telegram.org/bottelegram-token/getMe") { - return Response.json({ - ok: true, - result: { - first_name: "mosoo Telegram", - id: 9001, - is_bot: true, - username: "mosoo_telegram_bot", - }, - }); - } - - if ( - requestUrl === "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" || - requestUrl === "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal" - ) { - return Response.json({ - code: 0, - tenant_access_token: "tenant-token", - }); - } - - if ( - requestUrl === "https://open.feishu.cn/open-apis/bot/v3/info" || - requestUrl === "https://open.larksuite.com/open-apis/bot/v3/info" - ) { - return Response.json({ - bot: { - app_name: "mosoo Lark", - open_id: "ou_bot", - }, - code: 0, - }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -describe("multi-provider channel sessions", () => { - test("creates Telegram bindings through vault and channel sessions with provider metadata", async () => { - await withProviderFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createTelegramAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - - expect(binding).toMatchObject({ - agentId: "01J00000000000000000000009", - displayMetadata: { - bot_first_name: "mosoo Telegram", - bot_username: "mosoo_telegram_bot", - }, - externalBotId: "9001", - externalTenantId: "9001", - provider: "telegram", - status: "active", - }); - - const context = await resolveAgentChannelBindingContextById(bindings, { - bindingId: binding.id, - provider: "telegram", - }); - expect(context).not.toBeNull(); - if (!context) { - throw new Error("Expected Telegram binding context."); - } - - const client = createChannelSessionClient({ - binding: context, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/telegram/events/binding", - }); - const command = await client.createOrContinueSession({ - clientRequestId: "telegram:update:1", - text: "Review the launch plan.", - trigger: { - eventId: "telegram:update:1", - externalActorId: "telegram:user:42", - externalMessageId: "42:77", - externalThreadId: "42:main", - externalWorkspaceId: "42", - providerMetadata: { - chat_id: "42", - message_id: 77, - }, - requiresExistingSession: false, - }, - }); - - if (!command.sessionId) { - throw new Error("Expected Telegram command to create a session."); - } - - if (!command.runId) { - throw new Error("Expected Telegram command to create a run."); - } - - await setSessionRunStatus(database, { - error: { - code: "test.completed", - details: {}, - message: "Test completed the synthetic channel run.", - retryable: false, - }, - runId: command.runId, - source: "driver", - status: "failed", - }); - - const followUp = await client.createOrContinueSession({ - clientRequestId: "telegram:update:2", - text: "Now list the risks.", - trigger: { - eventId: "telegram:update:2", - externalActorId: "telegram:user:42", - externalMessageId: "42:78", - externalThreadId: "42:main", - externalWorkspaceId: "42", - providerMetadata: { - chat_id: "42", - message_id: 78, - }, - requiresExistingSession: false, - }, - }); - - expect(followUp.sessionId).toBe(command.sessionId); - - const sessionRow = await database - .app() - .select({ - participantAccountId: sessionsTable.participantAccountId, - metadataJson: sessionsTable.metadataJson, - type: sessionsTable.type, - }) - .from(sessionsTable) - .where(eq(sessionsTable.id, command.sessionId)) - .get(); - - expect(sessionRow).toMatchObject({ - participantAccountId: null, - type: "api_channel", - }); - const metadata = parseJsonRecord(sessionRow?.metadataJson ?? "{}"); - expect(readRecord(metadata["triggered_by"], "triggered_by")).toMatchObject({ - binding_id: binding.id, - external_actor_id: "telegram:user:42", - external_message_id: "42:77", - external_thread_id: "42:main", - external_workspace_id: "42", - provider: "telegram", - }); - - const runRow = await database - .app() - .select({ - createdByAccountId: sessionRunsTable.createdByAccountId, - trigger: sessionRunsTable.trigger, - }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.id, command.runId)) - .get(); - - expect(runRow).toEqual({ - createdByAccountId: OWNER_VIEWER.id, - trigger: "user_prompt", - }); - - const threadSessionRows = await database - .app() - .select() - .from(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.id)) - .all(); - - expect(threadSessionRows).toEqual([ - expect.objectContaining({ - externalThreadId: "42:main", - provider: "telegram", - sessionId: command.sessionId, - }), - ]); - }); - }); - - test("creates one Lark provider with domain-specific credentials instead of separate Feishu enum", async () => { - await withProviderFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createLarkAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - larkAppId: "cli_feishu", - appSecret: "app-secret", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "encrypt-key", - appId: PUBLIC_API_TEST_IDS.app, - verificationToken: "verification-token", - }); - - expect(binding).toMatchObject({ - displayMetadata: { - app_name: "mosoo Lark", - bot_open_id: "ou_bot", - domain: "feishu", - }, - externalBotId: "ou_bot", - externalTenantId: "feishu:cli_feishu", - provider: "lark", - }); - }); - }); -}); diff --git a/apps/api/tests/owner-debug-terminal.test.ts b/apps/api/tests/owner-debug-terminal.test.ts deleted file mode 100644 index f3f733c0..00000000 --- a/apps/api/tests/owner-debug-terminal.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - SANDBOX_CACHE_PATH, - SANDBOX_MEMORY_PATH, - SANDBOX_SESSION_ROOT, -} from "@mosoo/agent-driver/paths"; -import type { AccountId } from "@mosoo/id"; - -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { connectOwnerDebugTerminalWebSocket } from "../src/modules/runtime/application/owner-debug-terminal.service"; -import type { SandboxHandle } from "../src/modules/runtime/infrastructure/sandbox-handles"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { API_ERROR_CODE } from "../src/platform/errors"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: PUBLIC_API_TEST_IDS.ownerAccount as AccountId, - imageUrl: null, - name: "Owner", -}; - -function ownerDebugTerminalRequest(): Request { - return new Request("https://api.example.com/api/agent/test/owner-debug-terminal/ws", { - headers: new Headers([["Upgrade", "websocket"]]), - }); -} - -interface TerminalSpy { - createSessionCalls: { cwd?: string; id?: string }[]; - handle: SandboxHandle; - mkdirCalls: string[]; - setKeepAliveCalls: boolean[]; -} - -function createTerminalSandboxHandleSpy(): TerminalSpy { - const mkdirCalls: string[] = []; - const setKeepAliveCalls: boolean[] = []; - const createSessionCalls: { cwd?: string; id?: string }[] = []; - const unavailable = async () => { - throw new Error("Unexpected sandbox test method call."); - }; - const sessionResponse = new Response("ok", { status: 200 }); - - const handle = { - configureNetworkConstraints: async () => {}, - createBackup: unavailable, - createSession: async (options) => { - createSessionCalls.push({ cwd: options?.cwd, id: options?.id }); - return { - exec: unavailable, - mkdir: unavailable, - readFile: unavailable, - startProcess: unavailable, - terminal: async () => sessionResponse, - watch: unavailable, - writeFile: unavailable, - } as unknown as Awaited>; - }, - deleteSession: unavailable, - destroy: unavailable, - exec: unavailable, - getSession: unavailable, - mkdir: async (path) => { - mkdirCalls.push(path); - }, - mountBucket: unavailable, - readFile: unavailable, - restoreBackup: unavailable, - setKeepAlive: async (value) => { - setKeepAliveCalls.push(value); - }, - startProcess: unavailable, - terminal: async () => sessionResponse, - unmountBucket: unavailable, - watch: unavailable, - writeFile: unavailable, - wsConnect: unavailable, - } as unknown as SandboxHandle; - - return { createSessionCalls, handle, mkdirCalls, setKeepAliveCalls }; -} - -describe("owner debug terminal", () => { - test("returns an explicit conflict for Cattle agents", async () => { - const database = await createPublicHttpContractDatabase(); - await database - .prepare("UPDATE agent SET kind = ? WHERE id = ?") - .bind("cattle", PUBLIC_API_TEST_IDS.agent) - .run(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - - await expect( - connectOwnerDebugTerminalWebSocket(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - executionContext: createTestExecutionContext(), - request: ownerDebugTerminalRequest(), - viewer: OWNER_VIEWER, - }), - ).rejects.toMatchObject({ - code: API_ERROR_CODE.ownerDebugTerminalUnavailable, - status: 409, - }); - }); - - test("activates the sandbox before opening the terminal session", async () => { - const database = await createPublicHttpContractDatabase(); - const spy = createTerminalSandboxHandleSpy(); - const bindings = { - ...createPublicHttpTestBindings(database), - runtimeSubjectHandleFactory: () => spy.handle, - } as unknown as ApiBindings; - - await connectOwnerDebugTerminalWebSocket(bindings, { - agentId: PUBLIC_API_TEST_IDS.agent, - executionContext: createTestExecutionContext(), - request: ownerDebugTerminalRequest(), - viewer: OWNER_VIEWER, - }); - - expect( - await database - .prepare("SELECT status FROM sandbox WHERE subject_id = ?") - .bind(PUBLIC_API_TEST_IDS.agent) - .first(), - ).toEqual({ status: "active" }); - expect(spy.setKeepAliveCalls).toEqual([true]); - expect(new Set(spy.mkdirCalls)).toEqual( - new Set([SANDBOX_CACHE_PATH, SANDBOX_MEMORY_PATH, SANDBOX_SESSION_ROOT]), - ); - expect(spy.createSessionCalls).toHaveLength(1); - expect(spy.createSessionCalls[0]?.cwd).toBe("/workspace"); - }); -}); diff --git a/apps/api/tests/platform-errors.test.ts b/apps/api/tests/platform-errors.test.ts index 19e4bb3b..5a747124 100644 --- a/apps/api/tests/platform-errors.test.ts +++ b/apps/api/tests/platform-errors.test.ts @@ -16,8 +16,8 @@ import type { ApiErrorCode, ApiErrorStatus } from "../src/platform/errors"; describe("platform error taxonomy", () => { test("maps public error codes to finite HTTP statuses", () => { expect(getApiErrorStatusForCode(API_ERROR_CODE.notFound)).toBe(API_ERROR_STATUS.notFound); - expect(getApiErrorStatusForCode(API_ERROR_CODE.wechatQrStartFailed)).toBe( - API_ERROR_STATUS.badGateway, + expect(getApiErrorStatusForCode(API_ERROR_CODE.environmentArtifactFailed)).toBe( + API_ERROR_STATUS.conflict, ); expect( createApiError(API_ERROR_CODE.websocketRequired, "Expected WebSocket upgrade."), diff --git a/apps/api/tests/prod-schema-guard.test.ts b/apps/api/tests/prod-schema-guard.test.ts index 8e37e7b3..9088bcfc 100644 --- a/apps/api/tests/prod-schema-guard.test.ts +++ b/apps/api/tests/prod-schema-guard.test.ts @@ -66,7 +66,7 @@ describe("parseExpectedTableNames", () => { const snapshot = await Bun.file(new URL(snapshotFilename, metaDir)).text(); const tableNames = parseExpectedTableNames(snapshot); - expect(tableNames).toContain("bound_agent_call_idempotency_key"); + expect(tableNames).toContain("api_command"); expect(tableNames).toContain("usage_event_rollup_receipt"); }); }); diff --git a/apps/api/tests/public-thread-metadata.test.ts b/apps/api/tests/public-thread-metadata.test.ts index cbb8e24d..1477f87f 100644 --- a/apps/api/tests/public-thread-metadata.test.ts +++ b/apps/api/tests/public-thread-metadata.test.ts @@ -1,19 +1,18 @@ import { describe, expect, test } from "bun:test"; -import type { AppDeploymentId, AppDeploymentRunId, PersonalAccessTokenId } from "@mosoo/id"; +import type { PersonalAccessTokenId } from "@mosoo/id"; import { createPublicApiThreadMetadata, - isDeploymentCapabilityCreatedBy, parsePublicApiThreadMetadata, } from "../src/modules/public-api/public-thread-metadata"; describe("Public Thread metadata", () => { test("round-trips the canonical access-token audit metadata", () => { const metadata = createPublicApiThreadMetadata({ - createdBy: { - token_id: "01J00000000000000000000061" as PersonalAccessTokenId, - token_label: "production", + admission: { + tokenId: "01J00000000000000000000061" as PersonalAccessTokenId, + tokenLabel: "production", }, idempotencyKey: "idem-1", }); @@ -21,49 +20,5 @@ describe("Public Thread metadata", () => { expect(parsePublicApiThreadMetadata(JSON.stringify({ public_api: metadata }))).toEqual( metadata, ); - expect(isDeploymentCapabilityCreatedBy(metadata.created_by)).toBe(false); - }); - - test("round-trips deployment capability audit metadata", () => { - const metadata = createPublicApiThreadMetadata({ - createdBy: { - binding_env: "MOSOO_AGENT_URL", - binding_name: "Codex Pet", - deployment_id: "01J0000000000000000000000D" as AppDeploymentId, - deployment_run_id: "01J0000000000000000000000R" as AppDeploymentRunId, - kind: "deployment_capability", - }, - idempotencyKey: null, - }); - - expect(parsePublicApiThreadMetadata(JSON.stringify({ public_api: metadata }))).toEqual( - metadata, - ); - expect(isDeploymentCapabilityCreatedBy(metadata.created_by)).toBe(true); - }); - - test("rejects deployment capability metadata with unknown or malformed fields", () => { - const base = { - binding_env: "MOSOO_AGENT_URL", - binding_name: "Codex Pet", - deployment_id: "01J0000000000000000000000D", - deployment_run_id: "01J0000000000000000000000R", - kind: "deployment_capability", - }; - - for (const createdBy of [ - { ...base, token_id: "01J00000000000000000000061" }, - { ...base, deployment_id: "not-a-ulid" }, - { ...base, binding_env: "" }, - { kind: "deployment_capability" }, - ]) { - expect( - parsePublicApiThreadMetadata( - JSON.stringify({ - public_api: { created_by: createdBy, idempotency_key: null, source: "public_api" }, - }), - ), - ).toBeNull(); - } }); }); diff --git a/apps/api/tests/request-logging-path-redaction.test.ts b/apps/api/tests/request-logging-path-redaction.test.ts deleted file mode 100644 index c125c823..00000000 --- a/apps/api/tests/request-logging-path-redaction.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { redactRequestLogPath } from "../src/adapters/http/request-logging.middleware"; - -describe("request log path redaction", () => { - test("hides the bound capability token but keeps the route shape", () => { - expect(redactRequestLogPath("/api/v1/bound/eyJhbGciOi.signature")).toBe("/api/v1/bound/:token"); - expect(redactRequestLogPath("/api/v1/bound/eyJhbGciOi.signature/threads")).toBe( - "/api/v1/bound/:token/threads", - ); - expect( - redactRequestLogPath( - "/api/v1/bound/eyJhbGciOi.signature/files/01J0000000000000000000000J/content", - ), - ).toBe("/api/v1/bound/:token/files/01J0000000000000000000000J/content"); - }); - - test("leaves every other path untouched", () => { - for (const path of [ - "/api/v1/threads/01J00000000000000000000009", - "/api/v1/bound", - "/graphql", - ]) { - expect(redactRequestLogPath(path)).toBe(path); - } - }); -}); diff --git a/apps/api/tests/slack-channel-adapter-events.test.ts b/apps/api/tests/slack-channel-adapter-events.test.ts deleted file mode 100644 index b2f71664..00000000 --- a/apps/api/tests/slack-channel-adapter-events.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - normalizeSlackWorkTrigger, - parseSlackEventsEnvelope, -} from "../src/modules/channels/slack/slack-events"; -import { verifySlackSignature } from "../src/modules/channels/slack/slack-signing"; - -async function signSlackBody(input: { - body: string; - signingSecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.signingSecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(`v0:${input.timestamp}:${input.body}`), - ); - - return `v0=${[...new Uint8Array(signature)] - .map((value) => value.toString(16).padStart(2, "0")) - .join("")}`; -} - -async function buildSignedSlackHeaders(input: { - body: string; - signingSecret: string; - timestamp: string; -}): Promise { - return new Headers({ - "x-slack-request-timestamp": input.timestamp, - "x-slack-signature": await signSlackBody(input), - }); -} - -function requireEventCallback(body: string) { - const parsed = parseSlackEventsEnvelope(body); - expect(parsed.ok).toBe(true); - - if (!parsed.ok || parsed.envelope.type !== "event_callback") { - throw new Error("Expected Slack event_callback envelope."); - } - - return parsed.envelope; -} - -describe("Slack channel events", () => { - test("verifies signatures against the raw request body", async () => { - const body = JSON.stringify({ challenge: "ok", type: "url_verification" }); - const timestamp = "1779646500"; - const signingSecret = "slack-signing-secret"; - const headers = await buildSignedSlackHeaders({ body, signingSecret, timestamp }); - - await expect( - verifySlackSignature({ - body, - headers, - nowSeconds: Number(timestamp), - signingSecret, - }), - ).resolves.toEqual({ ok: true }); - - await expect( - verifySlackSignature({ - body, - headers, - nowSeconds: Number(timestamp), - signingSecret: "wrong-secret", - }), - ).resolves.toMatchObject({ code: "signature_mismatch", ok: false, status: 401 }); - }); - - test("normalizes app_mention and strips leading bot mentions", () => { - const envelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - text: "<@U-BOT> <@U-OTHER> summarize this", - ts: "1700000000.000100", - type: "app_mention", - user: "U-ALICE", - }, - event_id: "Ev1", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(envelope)).toEqual({ - botUserId: "U-BOT", - channelId: "C123", - enterpriseId: null, - eventId: "Ev1", - isEnterpriseInstall: false, - messageTs: "1700000000.000100", - requiresExistingSession: false, - teamId: "T123", - text: "summarize this", - threadTs: "1700000000.000100", - triggerType: "app_mention", - userId: "U-ALICE", - }); - }); - - test("normalizes direct messages as channel thread anchors", () => { - const envelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "D123", - channel_type: "im", - text: "help me", - thread_ts: "1700000000.000050", - ts: "1700000000.000100", - type: "message", - user: "U-ALICE", - }, - event_id: "Ev2", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(envelope)).toMatchObject({ - botUserId: "U-BOT", - channelId: "D123", - enterpriseId: null, - eventId: "Ev2", - isEnterpriseInstall: false, - requiresExistingSession: false, - text: "help me", - threadTs: "1700000000.000050", - triggerType: "dm_message", - userId: "U-ALICE", - }); - }); - - test("normalizes supported events without text so the adapter can write fallback copy", () => { - const appMentionEnvelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - text: "", - ts: "1700000000.000100", - type: "app_mention", - user: "U-ALICE", - }, - event_id: "Ev3", - team_id: "T123", - type: "event_callback", - }), - ); - const dmEnvelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "D123", - channel_type: "im", - ts: "1700000000.000200", - type: "message", - user: "U-ALICE", - }, - event_id: "Ev4", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(appMentionEnvelope)).toMatchObject({ - eventId: "Ev3", - requiresExistingSession: false, - text: "", - triggerType: "app_mention", - }); - expect(normalizeSlackWorkTrigger(dmEnvelope)).toMatchObject({ - eventId: "Ev4", - requiresExistingSession: false, - text: "", - triggerType: "dm_message", - }); - }); - - test("normalizes channel thread replies as existing-session-only triggers", () => { - const envelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - channel_type: "channel", - text: "yes, continue", - thread_ts: "1700000000.000100", - ts: "1700000000.000200", - type: "message", - user: "U-ALICE", - }, - event_id: "Ev-thread-reply", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(envelope)).toMatchObject({ - channelId: "C123", - eventId: "Ev-thread-reply", - requiresExistingSession: true, - text: "yes, continue", - threadTs: "1700000000.000100", - triggerType: "channel_thread_message", - userId: "U-ALICE", - }); - }); - - test("ignores channel thread message copies when the bot is explicitly mentioned", () => { - const envelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - channel_type: "channel", - text: "<@U-BOT> yes, continue", - thread_ts: "1700000000.000100", - ts: "1700000000.000200", - type: "message", - user: "U-ALICE", - }, - event_id: "Ev-thread-reply-message-copy", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(envelope)).toBeNull(); - }); - - test("ignores top-level channel messages without mentions", () => { - const envelope = requireEventCallback( - JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - channel_type: "channel", - text: "ambient channel chatter", - ts: "1700000000.000300", - type: "message", - user: "U-ALICE", - }, - event_id: "Ev-top-level-channel-message", - team_id: "T123", - type: "event_callback", - }), - ); - - expect(normalizeSlackWorkTrigger(envelope)).toBeNull(); - }); -}); diff --git a/apps/api/tests/slack-channel-adapter.test.ts b/apps/api/tests/slack-channel-adapter.test.ts deleted file mode 100644 index f04bc568..00000000 --- a/apps/api/tests/slack-channel-adapter.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { SlackWorkTrigger } from "../src/modules/channels/slack/slack-events"; -import { processSlackWorkTrigger } from "../src/modules/channels/slack/slack-first-party-adapter"; -import type { SlackSessionCommandClient } from "../src/modules/channels/slack/slack-first-party-adapter"; -import { SlackWebApiClient, SlackWebApiError } from "../src/modules/channels/slack/slack-web-api"; -import { readFetchUrl } from "./helpers/fetch-request-url"; - -function readJsonObjectBody(body: string): Record { - const parsed: unknown = JSON.parse(body); - - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed; - } - - throw new Error("Expected Slack request body to be a JSON object."); -} - -describe("Slack channel adapter", () => { - test("uses internal session commands and only calls Slack Web API", async () => { - const fetchUrls: string[] = []; - const fetchBodies: Record[] = []; - const finalDeliveryJobs: unknown[] = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url, init) => { - fetchUrls.push(readFetchUrl(url)); - if (typeof init?.body === "string") { - fetchBodies.push(readJsonObjectBody(init.body)); - } - return Response.json({ channel: "C123", ok: true, ts: "1700000000.000200" }); - }; - const trigger: SlackWorkTrigger = { - botUserId: "U-BOT", - channelId: "C123", - enterpriseId: null, - eventId: "Ev3", - isEnterpriseInstall: false, - messageTs: "1700000000.000100", - requiresExistingSession: false, - teamId: "T123", - text: "ship it", - threadTs: "1700000000.000100", - triggerType: "app_mention", - userId: "U-ALICE", - }; - const sessionClient: SlackSessionCommandClient = { - async createOrContinueSession(input) { - expect(input.clientRequestId).toBe("slack:event:Ev3"); - expect(input.trigger.requiresExistingSession).toBe(false); - expect(input.text).toContain("Slack thread: 1700000000.000100"); - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError() { - throw new Error("Binding error should not be marked for successful Slack delivery."); - }, - async retrieveSessionReply(sessionId) { - throw new Error(`Slack webhook path must not poll final replies for ${sessionId}.`); - }, - }; - - try { - await processSlackWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - sessionLinkBaseUrl: "https://mosoo.ai", - slackBotToken: "xoxb-token", - }, - finalDeliveryScheduler: { - async enqueue(job) { - finalDeliveryJobs.push(job); - }, - }, - sessionClient, - trigger, - }); - } finally { - globalThis.fetch = originalFetch; - } - - expect(fetchUrls).toEqual(["https://slack.com/api/chat.postMessage"]); - expect(fetchBodies[0]?.["text"]).toContain("Agent is working"); - expect(finalDeliveryJobs).toEqual([ - { - bindingId: "binding-1", - externalEventId: "slack:event:Ev3", - payload: { - channelId: "C123", - provider: "slack", - threadTs: "1700000000.000100", - workingMessage: { - channelId: "C123", - ts: "1700000000.000200", - }, - }, - provider: "slack", - runId: "run-1", - sessionId: "session-1", - }, - ]); - }); - - test("does not write Slack messages for duplicate event ids", async () => { - const fetchUrls: string[] = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - fetchUrls.push(readFetchUrl(url)); - return Response.json({ channel: "C123", ok: true, ts: "1700000000.000200" }); - }; - const sessionClient: SlackSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: true, runId: null, sessionId: "session-1" }; - }, - async markBindingError() { - throw new Error("Duplicate Slack events must not mark binding errors."); - }, - async retrieveSessionReply() { - throw new Error("Duplicate Slack events must not poll session replies."); - }, - }; - - try { - await processSlackWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - sessionLinkBaseUrl: "https://mosoo.ai", - slackBotToken: "xoxb-token", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Duplicate Slack events must not schedule final delivery."); - }, - }, - sessionClient, - trigger: { - botUserId: "U-BOT", - channelId: "C123", - enterpriseId: null, - eventId: "Ev-duplicate", - isEnterpriseInstall: false, - messageTs: "1700000000.000100", - requiresExistingSession: false, - teamId: "T123", - text: "ship it", - threadTs: "1700000000.000100", - triggerType: "app_mention", - userId: "U-ALICE", - }, - }); - } finally { - globalThis.fetch = originalFetch; - } - - expect(fetchUrls).toEqual([]); - }); - - test("marks the binding error when Slack writeback rejects the bot token", async () => { - const errorCodes: string[] = []; - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - globalThis.fetch = async () => Response.json({ error: "invalid_auth", ok: false }); - globalThis.reportError = () => {}; - const sessionClient: SlackSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - errorCodes.push(errorCode); - }, - async retrieveSessionReply() { - return { status: "completed", text: "done" }; - }, - }; - - try { - await processSlackWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - sessionLinkBaseUrl: "https://mosoo.ai", - slackBotToken: "xoxb-invalid", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Failed Slack working reply must not schedule final delivery."); - }, - }, - sessionClient, - trigger: { - botUserId: "U-BOT", - channelId: "C123", - enterpriseId: null, - eventId: "Ev-invalid-auth", - isEnterpriseInstall: false, - messageTs: "1700000000.000100", - requiresExistingSession: false, - teamId: "T123", - text: "ship it", - threadTs: "1700000000.000100", - triggerType: "app_mention", - userId: "U-ALICE", - }, - }); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - - expect(errorCodes).toContain("invalid_auth"); - }); - - test("maps malformed Slack Web API JSON to typed operation errors", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response("{", { - headers: { "content-type": "application/json" }, - status: 200, - }); - - try { - await expect( - new SlackWebApiClient("xoxb-token").postChatMessage({ - channelId: "C123", - text: "Agent is working...", - threadTs: "1700000000.000100", - }), - ).rejects.toEqual(new SlackWebApiError("chat.postMessage", "chat.postMessage_failed")); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not mark the binding error for channel-scoped Slack writeback failures", async () => { - const errorCodes: string[] = []; - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - globalThis.fetch = async () => Response.json({ error: "channel_not_found", ok: false }); - globalThis.reportError = () => {}; - const sessionClient: SlackSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - errorCodes.push(errorCode); - }, - async retrieveSessionReply() { - return { status: "completed", text: "done" }; - }, - }; - - try { - await processSlackWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - sessionLinkBaseUrl: "https://mosoo.ai", - slackBotToken: "xoxb-valid", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Failed Slack working reply must not schedule final delivery."); - }, - }, - sessionClient, - trigger: { - botUserId: "U-BOT", - channelId: "C-missing", - enterpriseId: null, - eventId: "Ev-channel-missing", - isEnterpriseInstall: false, - messageTs: "1700000000.000100", - requiresExistingSession: false, - teamId: "T123", - text: "ship it", - threadTs: "1700000000.000100", - triggerType: "app_mention", - userId: "U-ALICE", - }, - }); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - - expect(errorCodes).toEqual([]); - }); -}); diff --git a/apps/api/tests/slack-channel-events-route.test.ts b/apps/api/tests/slack-channel-events-route.test.ts deleted file mode 100644 index 9609b7d3..00000000 --- a/apps/api/tests/slack-channel-events-route.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import { agentsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; - -import { registerSlackEventsRoute } from "../src/adapters/http/routes/slack-events-route"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createSlackAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; -const SLACK_EVENTS_URL = buildAgentChannelWebhookUrl({ - origin: "https://api.example.com", - provider: "slack", -}); - -function createSlackRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - registerSlackEventsRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -async function signSlackBody(input: { - body: string; - signingSecret: string; - timestamp: string; -}): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(input.signingSecret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(`v0:${input.timestamp}:${input.body}`), - ); - - return `v0=${[...new Uint8Array(signature)] - .map((value) => value.toString(16).padStart(2, "0")) - .join("")}`; -} - -async function withSlackAuthTestMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://slack.com/api/auth.test") { - return Response.json({ - ok: true, - team: "Growth HQ", - team_id: "T123", - user: "mosoobot", - user_id: "U-BOT", - }); - } - - return originalFetch(url); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -describe("Slack channel events route", () => { - test("mounts url verification at /api/v1/channels/slack/events", async () => { - const app = createSlackRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const response = await app.request( - new Request(SLACK_EVENTS_URL, { - body: JSON.stringify({ challenge: "challenge-ok", type: "url_verification" }), - method: "POST", - }), - undefined, - createPublicHttpTestBindings(database), - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.text()).toBe("challenge-ok"); - }); - - test("verifies event_callback with the per-binding signing secret", async () => { - await withSlackAuthTestMock(async () => { - const app = createSlackRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const body = JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - text: "<@U-BOT> review this", - ts: "1700000000.000100", - type: "app_mention", - user: "U-ALICE", - }, - event_id: "Ev-route", - team_id: "T123", - type: "event_callback", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(SLACK_EVENTS_URL, { - body, - headers: { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": await signSlackBody({ - body, - signingSecret: "wrong-secret", - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ - code: "signature_mismatch", - ok: false, - }); - }); - }); - - test("acks malformed Slack retries with missing event_id", async () => { - const app = createSlackRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const response = await app.request( - new Request(SLACK_EVENTS_URL, { - body: JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - text: "<@U-BOT> review this", - ts: "1700000000.000100", - type: "app_mention", - user: "U-ALICE", - }, - team_id: "T123", - type: "event_callback", - }), - method: "POST", - }), - undefined, - createPublicHttpTestBindings(database), - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - ignored: true, - ok: true, - }); - }); - - test("acks unsupported Slack outer events", async () => { - const app = createSlackRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const response = await app.request( - new Request(SLACK_EVENTS_URL, { - body: JSON.stringify({ - minute_rate_limited: 1, - team_id: "T123", - type: "app_rate_limited", - }), - method: "POST", - }), - undefined, - createPublicHttpTestBindings(database), - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - ignored: true, - ok: true, - }); - }); - - test("acks and drops signed events when the bound agent is no longer published", async () => { - await withSlackAuthTestMock(async () => { - const app = createSlackRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - await database - .app() - .update(agentsTable) - .set({ status: "draft" }) - .where(eq(agentsTable.id, "01J00000000000000000000009")) - .run(); - const body = JSON.stringify({ - authorizations: [{ user_id: "U-BOT" }], - event: { - channel: "C123", - text: "<@U-BOT> review this", - ts: "1700000000.000100", - type: "app_mention", - user: "U-ALICE", - }, - event_id: "Ev-unpublished", - team_id: "T123", - type: "event_callback", - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await app.request( - new Request(SLACK_EVENTS_URL, { - body, - headers: { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": await signSlackBody({ - body, - signingSecret: "signing-secret", - timestamp, - }), - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - ignored: true, - ok: true, - }); - }); - }); -}); diff --git a/apps/api/tests/slack-channel-session-fixtures.ts b/apps/api/tests/slack-channel-session-fixtures.ts deleted file mode 100644 index 7eb64a3c..00000000 --- a/apps/api/tests/slack-channel-session-fixtures.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { desc, eq } from "drizzle-orm"; - -import type { SlackWorkTrigger } from "../src/modules/channels/slack/slack-events"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import type { createPublicHttpContractDatabase } from "./helpers/public-api-http-test-fixture"; - -export { OWNER_VIEWER, parseJsonRecord, readRecord } from "./channel-session-fixtures"; - -type SlackTriggerInput = { - eventId: string; - messageTs: string; - text: string; - triggerType?: SlackWorkTrigger["triggerType"]; -} & ( - | { - requiresExistingSession: true; - threadTs: string; - } - | { - requiresExistingSession?: false; - threadTs?: string; - } -); - -export function buildSlackTrigger(input: SlackTriggerInput): SlackWorkTrigger { - return { - botUserId: "U-BOT", - channelId: "C123", - enterpriseId: null, - eventId: input.eventId, - isEnterpriseInstall: false, - messageTs: input.messageTs, - requiresExistingSession: input.requiresExistingSession ?? false, - teamId: "T123", - text: input.text, - threadTs: input.threadTs ?? input.messageTs, - triggerType: input.triggerType ?? "app_mention", - userId: "U-ALICE", - }; -} - -export async function withChannelFetchMock( - operation: () => Promise, - authTestResponse: Record = { - ok: true, - team: "Growth HQ", - team_id: "T123", - user: "mosoobot", - user_id: "U-BOT", - }, -): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://slack.com/api/auth.test") { - return Response.json(authTestResponse); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -export async function markLatestSessionRunCompleted( - database: Awaited>, - sessionId: string, -): Promise { - const run = await database - .app() - .select({ id: sessionRunsTable.id }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.sessionId, sessionId)) - .orderBy(desc(sessionRunsTable.createdAt)) - .limit(1) - .get(); - - if (!run) { - throw new Error("Expected latest session run."); - } - - await database - .app() - .update(sessionRunsTable) - .set({ completedAt: Date.now(), status: "completed", updatedAt: Date.now() }) - .where(eq(sessionRunsTable.id, run.id)) - .run(); - await database - .app() - .update(sessionsTable) - .set({ lastRunId: run.id, status: "IDLE", updatedAt: Date.now() }) - .where(eq(sessionsTable.id, sessionId)) - .run(); -} diff --git a/apps/api/tests/slack-channel-session-threading.test.ts b/apps/api/tests/slack-channel-session-threading.test.ts deleted file mode 100644 index a127611d..00000000 --- a/apps/api/tests/slack-channel-session-threading.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - channelEventReceiptsTable, - channelThreadSessionsTable, - sessionMessagesTable, - sessionsTable, -} from "@mosoo/db"; -import { and, count, eq } from "drizzle-orm"; - -import { createSlackAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import { - createSlackChannelSessionClient, - resolveSlackChannelBindingContext, -} from "../src/modules/channels/application/slack-channel-session.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -import { - OWNER_VIEWER, - buildSlackTrigger, - markLatestSessionRunCompleted, - withChannelFetchMock, -} from "./slack-channel-session-fixtures"; - -describe("Slack channel session threading", () => { - test("honors strict thread-reply mention setting", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const threadTs = "1700000000.000800"; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - threadRepliesRequireMention: true, - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const client = createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }); - const first = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-strict-start", - text: "Start strict thread.", - trigger: buildSlackTrigger({ - eventId: "Ev-strict-start", - messageTs: threadTs, - text: "Start strict thread.", - }), - }); - await markLatestSessionRunCompleted(database, first.sessionId ?? ""); - - const threadFollowUp = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-strict-thread", - text: "Follow up without mention.", - trigger: buildSlackTrigger({ - eventId: "Ev-strict-thread", - messageTs: "1700000000.000900", - requiresExistingSession: true, - text: "Follow up without mention.", - threadTs, - triggerType: "channel_thread_message", - }), - }); - const messageCount = await database - .app() - .select({ value: count() }) - .from(sessionMessagesTable) - .where( - and( - eq(sessionMessagesTable.sessionId, first.sessionId), - eq(sessionMessagesTable.role, "user"), - ), - ) - .get(); - - expect(binding.threadRepliesRequireMention).toBe(true); - expect(threadFollowUp).toEqual({ - duplicate: false, - ignored: true, - runId: null, - sessionId: null, - }); - expect(messageCount?.value).toBe(1); - }); - }); - - test("ignores thread replies that do not have an existing channel session", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const result = await createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }).createOrContinueSession({ - clientRequestId: "slack:event:Ev-orphan-thread", - text: "Unmentioned orphan reply.", - trigger: buildSlackTrigger({ - eventId: "Ev-orphan-thread", - messageTs: "1700000000.000700", - requiresExistingSession: true, - text: "Unmentioned orphan reply.", - threadTs: "1700000000.000100", - triggerType: "channel_thread_message", - }), - }); - - const sessionCount = await database - .app() - .select({ value: count() }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.agentId, "01J00000000000000000000009"), - eq(sessionsTable.type, "api_channel"), - ), - ) - .get(); - const receiptCount = await database - .app() - .select({ value: count() }) - .from(channelEventReceiptsTable) - .get(); - - expect(result).toEqual({ duplicate: false, ignored: true, runId: null, sessionId: null }); - expect(sessionCount?.value).toBe(0); - expect(receiptCount?.value).toBe(0); - }); - }); - - test("backfills thread session mapping from legacy metadata", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const threadTs = "1700000000.000700"; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const client = createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }); - const first = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-legacy-first", - text: "Start a thread.", - trigger: buildSlackTrigger({ - eventId: "Ev-legacy-first", - messageTs: threadTs, - text: "Start a thread.", - }), - }); - - if (!first.sessionId) { - throw new Error("Expected Slack command to create a session."); - } - - await markLatestSessionRunCompleted(database, first.sessionId); - await database - .app() - .delete(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.bindingId)) - .run(); - - const reply = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-legacy-reply", - text: "Continue in the thread.", - trigger: buildSlackTrigger({ - eventId: "Ev-legacy-reply", - messageTs: "1700000000.000800", - requiresExistingSession: true, - text: "Continue in the thread.", - threadTs, - triggerType: "channel_thread_message", - }), - }); - - expect(reply).toMatchObject({ - duplicate: false, - ignored: false, - sessionId: first.sessionId, - }); - - const mapping = await database - .app() - .select({ - externalThreadId: channelThreadSessionsTable.externalThreadId, - sessionId: channelThreadSessionsTable.sessionId, - }) - .from(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.bindingId)) - .get(); - - expect(mapping).toEqual({ - externalThreadId: "C123:1700000000.000700", - sessionId: first.sessionId, - }); - }); - }); - - test("reserves event ids before creating channel session side effects", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const client = createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }); - const trigger = buildSlackTrigger({ - eventId: "Ev-concurrent", - messageTs: "1700000000.000300", - text: "Only run once.", - }); - - const results = await Promise.all([ - client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-concurrent", - text: "Only run once.", - trigger, - }), - client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-concurrent", - text: "Only run once.", - trigger, - }), - ]); - - expect(results.filter((result) => result.duplicate)).toHaveLength(1); - expect(results.filter((result) => !result.duplicate)).toHaveLength(1); - - const sessionCount = await database - .app() - .select({ value: count() }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.agentId, "01J00000000000000000000009"), - eq(sessionsTable.type, "api_channel"), - ), - ) - .get(); - const userMessageCount = await database - .app() - .select({ value: count() }) - .from(sessionMessagesTable) - .where(eq(sessionMessagesTable.role, "user")) - .get(); - const receiptCount = await database - .app() - .select({ value: count() }) - .from(channelEventReceiptsTable) - .get(); - - expect(sessionCount?.value).toBe(1); - expect(userMessageCount?.value).toBe(1); - expect(receiptCount?.value).toBe(1); - }); - }); -}); diff --git a/apps/api/tests/slack-channel-session.test.ts b/apps/api/tests/slack-channel-session.test.ts deleted file mode 100644 index b5d1263c..00000000 --- a/apps/api/tests/slack-channel-session.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - channelEventReceiptsTable, - channelThreadSessionsTable, - sessionMessagesTable, - sessionRunsTable, - sessionsTable, -} from "@mosoo/db"; -import { and, count, desc, eq } from "drizzle-orm"; - -import { - createSlackAgentChannelBinding, - deleteAgentChannelBinding, - listAgentChannelBindings, -} from "../src/modules/channels/application/agent-channel-binding.service"; -import { - createSlackChannelSessionClient, - resolveSlackChannelBindingContext, -} from "../src/modules/channels/application/slack-channel-session.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -import { - OWNER_VIEWER, - buildSlackTrigger, - markLatestSessionRunCompleted, - parseJsonRecord, - readRecord, - withChannelFetchMock, -} from "./slack-channel-session-fixtures"; - -describe("Slack channel sessions", () => { - test("creates api_channel session metadata, reuses thread, and deduplicates event id", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const threadTs = "1700000000.000100"; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - expect(binding).not.toBeNull(); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const client = createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }); - - const first = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-first", - text: "Review the launch plan.", - trigger: buildSlackTrigger({ - eventId: "Ev-first", - messageTs: threadTs, - text: "Review the launch plan.", - }), - }); - const sessionRow = await database - .app() - .select({ - participantAccountId: sessionsTable.participantAccountId, - creatorAccountId: sessionsTable.creatorAccountId, - metadataJson: sessionsTable.metadataJson, - type: sessionsTable.type, - }) - .from(sessionsTable) - .where(eq(sessionsTable.id, first.sessionId)) - .get(); - expect(sessionRow).toMatchObject({ - participantAccountId: null, - creatorAccountId: "01J00000000000000000000001", - type: "api_channel", - }); - const metadata = parseJsonRecord(sessionRow?.metadataJson ?? "{}"); - const triggeredBy = readRecord(metadata["triggered_by"], "triggered_by"); - expect(triggeredBy).toMatchObject({ - event_id: "Ev-first", - external_actor_id: "slack:U-ALICE", - external_message_id: "1700000000.000100", - external_thread_id: "C123:1700000000.000100", - external_workspace_id: "T123", - provider: "slack", - }); - expect(triggeredBy["binding_id"]).toBe(binding.bindingId); - expect(readRecord(triggeredBy["provider_metadata"], "provider_metadata")).toMatchObject({ - bot_handle: "mosoobot", - channel_id: "C123", - team_id: "T123", - workspace_name: "Growth HQ", - }); - const threadSessionRowsAfterFirst = await database - .app() - .select({ - externalThreadId: channelThreadSessionsTable.externalThreadId, - provider: channelThreadSessionsTable.provider, - sessionId: channelThreadSessionsTable.sessionId, - }) - .from(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.bindingId)) - .all(); - expect(threadSessionRowsAfterFirst).toEqual([ - { - externalThreadId: "C123:1700000000.000100", - provider: "slack", - sessionId: first.sessionId, - }, - ]); - - await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-first", - text: "Review the launch plan.", - trigger: buildSlackTrigger({ - eventId: "Ev-first", - messageTs: threadTs, - text: "Review the launch plan.", - }), - }); - const messageCountAfterDuplicate = await database - .app() - .select({ value: count() }) - .from(sessionMessagesTable) - .where( - and( - eq(sessionMessagesTable.sessionId, first.sessionId), - eq(sessionMessagesTable.role, "user"), - ), - ) - .get(); - expect(messageCountAfterDuplicate?.value).toBe(1); - - await expect( - listAgentChannelBindings(database, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - }), - ).resolves.toEqual([ - expect.objectContaining({ - activityLastTriggeredAt: expect.any(String), - activitySessionCount7d: 1, - id: binding.bindingId, - }), - ]); - - const run = await database - .app() - .select({ id: sessionRunsTable.id }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.sessionId, first.sessionId)) - .orderBy(desc(sessionRunsTable.createdAt)) - .limit(1) - .get(); - expect(run?.id).toBeString(); - - await database - .app() - .update(sessionRunsTable) - .set({ completedAt: Date.now(), status: "completed", updatedAt: Date.now() }) - .where(eq(sessionRunsTable.id, run?.id ?? "")) - .run(); - await database - .app() - .update(sessionsTable) - .set({ lastRunId: run?.id ?? null, status: "IDLE", updatedAt: Date.now() }) - .where(eq(sessionsTable.id, first.sessionId)) - .run(); - await database - .app() - .insert(sessionMessagesTable) - .values({ - contentText: "Finished from session.", - createdAt: Date.now(), - createdByAccountId: "01J00000000000000000000001", - id: "assistant-message-1", - planJson: null, - role: "assistant", - segmentsJson: null, - seq: 2, - sessionId: first.sessionId, - sessionRunId: run?.id ?? null, - }) - .run(); - await database - .app() - .update(sessionsTable) - .set({ messageSeqCursor: 2 }) - .where(eq(sessionsTable.id, first.sessionId)) - .run(); - if (!first.runId) { - throw new Error("Expected first Slack command to create a run."); - } - - await expect( - client.retrieveSessionReply(first.sessionId ?? "", first.runId), - ).resolves.toEqual({ - status: "completed", - text: "Finished from session.", - }); - - const followUp = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-follow-up", - text: "Now list risks.", - trigger: buildSlackTrigger({ - eventId: "Ev-follow-up", - messageTs: "1700000000.000200", - text: "Now list risks.", - threadTs, - }), - }); - expect(followUp.sessionId).toBe(first.sessionId); - await markLatestSessionRunCompleted(database, first.sessionId ?? ""); - const threadSessionCountAfterFollowUp = await database - .app() - .select({ value: count() }) - .from(channelThreadSessionsTable) - .where(eq(channelThreadSessionsTable.bindingId, binding.bindingId)) - .get(); - expect(threadSessionCountAfterFollowUp?.value).toBe(1); - - const threadFollowUp = await client.createOrContinueSession({ - clientRequestId: "slack:event:Ev-thread-follow-up", - text: "Continue without a mention.", - trigger: buildSlackTrigger({ - eventId: "Ev-thread-follow-up", - messageTs: "1700000000.000300", - requiresExistingSession: true, - text: "Continue without a mention.", - threadTs, - triggerType: "channel_thread_message", - }), - }); - expect(threadFollowUp).toMatchObject({ - duplicate: false, - ignored: false, - sessionId: first.sessionId, - }); - - const messageCountAfterFollowUp = await database - .app() - .select({ value: count() }) - .from(sessionMessagesTable) - .where( - and( - eq(sessionMessagesTable.sessionId, first.sessionId), - eq(sessionMessagesTable.role, "user"), - ), - ) - .get(); - expect(messageCountAfterFollowUp?.value).toBe(3); - }); - }); - - test("does not reuse Slack sessions across rebinding to another tenant", async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - let firstSessionId = ""; - - await withChannelFetchMock(async () => { - const firstBinding = await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-first-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "first-signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected first Slack binding context."); - } - - const first = await createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }).createOrContinueSession({ - clientRequestId: "slack:event:Ev-first-tenant", - text: "First tenant.", - trigger: buildSlackTrigger({ - eventId: "Ev-first-tenant", - messageTs: "1700000000.000400", - text: "First tenant.", - }), - }); - firstSessionId = first.sessionId ?? ""; - - await deleteAgentChannelBinding(bindings, OWNER_VIEWER, { - bindingId: firstBinding.id, - appId: PUBLIC_API_TEST_IDS.app, - }); - }); - - await withChannelFetchMock( - async () => { - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-second-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "second-signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T456", - }); - - if (!binding) { - throw new Error("Expected second Slack binding context."); - } - - const second = await createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }).createOrContinueSession({ - clientRequestId: "slack:event:Ev-second-tenant", - text: "Second tenant.", - trigger: { - ...buildSlackTrigger({ - eventId: "Ev-second-tenant", - messageTs: "1700000000.000500", - text: "Second tenant.", - }), - teamId: "T456", - }, - }); - - expect(second.sessionId).toBeString(); - expect(second.sessionId).not.toBe(firstSessionId); - }, - { - ok: true, - team: "Enterprise HQ", - team_id: "T456", - user: "mosoobot", - user_id: "U-BOT", - }, - ); - - const sessionCount = await database - .app() - .select({ value: count() }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.agentId, "01J00000000000000000000009"), - eq(sessionsTable.type, "api_channel"), - ), - ) - .get(); - - expect(sessionCount?.value).toBe(2); - }); - - test("reclaims stale incomplete Slack event receipts", async () => { - await withChannelFetchMock(async () => { - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - await createSlackAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "xoxb-secret-token", - appId: PUBLIC_API_TEST_IDS.app, - signingSecret: "signing-secret", - }); - const binding = await resolveSlackChannelBindingContext(bindings, { - externalBotId: "U-BOT", - externalTenantId: "T123", - }); - - if (!binding) { - throw new Error("Expected Slack binding context."); - } - - const staleTimestamp = Date.now() - 10 * 60 * 1000; - await database - .app() - .insert(channelEventReceiptsTable) - .values({ - bindingId: binding.bindingId, - createdAt: staleTimestamp, - expiresAt: Date.now() + 60_000, - externalEventId: "slack:event:Ev-stale", - externalTenantId: "T123", - id: "stale-receipt", - provider: "slack", - sessionId: null, - updatedAt: staleTimestamp, - }) - .run(); - - const result = await createSlackChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "https://api.example.com/api/v1/channels/slack/events", - }).createOrContinueSession({ - clientRequestId: "slack:event:Ev-stale", - text: "Recover stale receipt.", - trigger: buildSlackTrigger({ - eventId: "Ev-stale", - messageTs: "1700000000.000600", - text: "Recover stale receipt.", - }), - }); - - const receipt = await database - .app() - .select({ - id: channelEventReceiptsTable.id, - sessionId: channelEventReceiptsTable.sessionId, - }) - .from(channelEventReceiptsTable) - .where(eq(channelEventReceiptsTable.externalEventId, "slack:event:Ev-stale")) - .get(); - - expect(result.duplicate).toBe(false); - expect(result.sessionId).toBeString(); - expect(receipt?.id).not.toBe("stale-receipt"); - expect(receipt?.sessionId).toBe(result.sessionId); - }); - }); -}); diff --git a/apps/api/tests/telegram-channel-adapter.test.ts b/apps/api/tests/telegram-channel-adapter.test.ts deleted file mode 100644 index 3d70c69c..00000000 --- a/apps/api/tests/telegram-channel-adapter.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ChannelSessionCommandClient } from "../src/modules/channels/application/channel-session.service"; -import { - normalizeTelegramWorkTrigger, - parseTelegramUpdateEnvelope, -} from "../src/modules/channels/telegram/telegram-events"; -import { processTelegramWorkTrigger } from "../src/modules/channels/telegram/telegram-first-party-adapter"; -import { verifyTelegramWebhookSecret } from "../src/modules/channels/telegram/telegram-signing"; -import { - TelegramWebApiClient, - TelegramWebApiError, -} from "../src/modules/channels/telegram/telegram-web-api"; -import { readFetchUrl } from "./helpers/fetch-request-url"; - -function readJsonRequestBody(init: RequestInit | undefined): unknown { - if (typeof init?.body !== "string") { - throw new Error("Expected mocked Telegram request body to be a JSON string."); - } - - return JSON.parse(init.body); -} - -describe("Telegram channel adapter", () => { - test("verifies Telegram webhook secret token in constant-shape result", () => { - expect( - verifyTelegramWebhookSecret({ - headers: new Headers({ - "x-telegram-bot-api-secret-token": "expected-secret", - }), - webhookSecret: "expected-secret", - }), - ).toEqual({ ok: true }); - - expect( - verifyTelegramWebhookSecret({ - headers: new Headers({ - "x-telegram-bot-api-secret-token": "wrong-secret", - }), - webhookSecret: "expected-secret", - }), - ).toMatchObject({ code: "secret_mismatch", ok: false, status: 401 }); - }); - - test("normalizes message updates into chat and topic thread keys", () => { - const parsed = parseTelegramUpdateEnvelope( - JSON.stringify({ - message: { - chat: { id: -100123, title: "Launch", type: "supergroup" }, - from: { first_name: "Ada", id: 42, is_bot: false, username: "ada" }, - message_id: 77, - message_thread_id: 12, - text: "/ask review the launch plan", - }, - update_id: 9001, - }), - ); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - throw new Error("Expected Telegram update parse success."); - } - - expect(normalizeTelegramWorkTrigger(parsed.envelope)).toEqual({ - chatId: "-100123", - chatTitle: "Launch", - chatType: "supergroup", - eventId: "telegram:update:9001", - externalActorId: "telegram:user:42", - externalMessageId: "-100123:77", - externalThreadId: "-100123:12", - messageId: 77, - messageThreadId: 12, - text: "review the launch plan", - userDisplayName: "Ada", - userId: "42", - username: "ada", - }); - }); - - test("sends Telegram replies with optional message_thread_id", async () => { - const originalFetch = globalThis.fetch; - const requestBodies: unknown[] = []; - globalThis.fetch = async (url, init) => { - requestBodies.push(readJsonRequestBody(init)); - if (readFetchUrl(url) === "https://api.telegram.org/bottest-token/sendMessage") { - return Response.json({ - ok: true, - result: { chat: { id: -100123 }, message_id: 88 }, - }); - } - - return originalFetch(url, init); - }; - - try { - await expect( - new TelegramWebApiClient("test-token").sendMessage({ - chatId: "-100123", - messageThreadId: 12, - text: "Agent is working...", - }), - ).resolves.toEqual({ - chatId: "-100123", - messageId: 88, - }); - expect(requestBodies).toEqual([ - { - chat_id: "-100123", - message_thread_id: 12, - text: "Agent is working...", - }, - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("maps Telegram API failures to typed errors", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - Response.json({ description: "Forbidden: bot was blocked", ok: false }); - - try { - await expect( - new TelegramWebApiClient("test-token").sendMessage({ - chatId: "42", - messageThreadId: null, - text: "hello", - }), - ).rejects.toEqual(new TelegramWebApiError("sendMessage", "Forbidden: bot was blocked")); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("writes Telegram working and final replies through sendMessage", async () => { - const originalFetch = globalThis.fetch; - const requestBodies: unknown[] = []; - const finalDeliveryJobs: unknown[] = []; - let capturedExternalWorkspaceId: unknown; - globalThis.fetch = async (_url, init) => { - requestBodies.push(readJsonRequestBody(init)); - return Response.json({ - ok: true, - result: { chat: { id: 42 }, message_id: requestBodies.length }, - }); - }; - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession(command) { - capturedExternalWorkspaceId = command.trigger.externalWorkspaceId; - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError() {}, - async retrieveSessionReply() { - throw new Error("Telegram webhook path must not poll final replies."); - }, - }; - - try { - await processTelegramWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - botToken: "test-token", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue(job) { - finalDeliveryJobs.push(job); - }, - }, - sessionClient, - trigger: { - chatId: "42", - chatTitle: null, - chatType: "private", - eventId: "telegram:update:1", - externalActorId: "telegram:user:42", - externalMessageId: "42:77", - externalThreadId: "42:main", - messageId: 77, - messageThreadId: null, - text: "review this", - userDisplayName: "Ada", - userId: "42", - username: "ada", - }, - }); - - expect(capturedExternalWorkspaceId).toBe("42"); - expect(requestBodies).toEqual([ - { - chat_id: "42", - text: "mosoo session created: https://mosoo.ai/agent/01J00000000000000000000009?tab=consume&sessionId=session-1. Agent is working...", - }, - ]); - expect(finalDeliveryJobs).toEqual([ - { - bindingId: "binding-1", - externalEventId: "telegram:update:1", - payload: { - chatId: "42", - messageThreadId: null, - provider: "telegram", - }, - provider: "telegram", - runId: "run-1", - sessionId: "session-1", - }, - ]); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not mark Telegram bindings errored for chat-scoped send failures", async () => { - const originalFetch = globalThis.fetch; - const originalReportError = globalThis.reportError; - const markedErrors: string[] = []; - globalThis.reportError = () => {}; - globalThis.fetch = async () => - Response.json({ - description: "Forbidden: bot was blocked by the user", - ok: false, - }); - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - markedErrors.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Telegram webhook path must not poll final replies."); - }, - }; - - try { - await processTelegramWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - botToken: "test-token", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Final delivery should not be scheduled when the working reply fails."); - }, - }, - sessionClient, - trigger: { - chatId: "42", - chatTitle: null, - chatType: "private", - eventId: "telegram:update:blocked-chat", - externalActorId: "telegram:user:42", - externalMessageId: "42:77", - externalThreadId: "42:main", - messageId: 77, - messageThreadId: null, - text: "review this", - userDisplayName: "Ada", - userId: "42", - username: "ada", - }, - }); - - expect(markedErrors).toEqual([]); - } finally { - globalThis.fetch = originalFetch; - globalThis.reportError = originalReportError; - } - }); - - test("marks Telegram bindings errored for token-scoped send failures", async () => { - const originalFetch = globalThis.fetch; - const markedErrors: string[] = []; - globalThis.fetch = async () => - Response.json({ - description: "Unauthorized", - ok: false, - }); - const sessionClient: ChannelSessionCommandClient = { - async createOrContinueSession() { - return { duplicate: false, runId: "run-1", sessionId: "session-1" }; - }, - async markBindingError(errorCode) { - markedErrors.push(errorCode); - }, - async retrieveSessionReply() { - throw new Error("Telegram webhook path must not poll final replies."); - }, - }; - - try { - await processTelegramWorkTrigger({ - config: { - agentId: "01J00000000000000000000009", - bindingId: "binding-1", - botToken: "test-token", - sessionLinkBaseUrl: "https://mosoo.ai", - }, - finalDeliveryScheduler: { - async enqueue() { - throw new Error("Final delivery should not be scheduled when the working reply fails."); - }, - }, - sessionClient, - trigger: { - chatId: "42", - chatTitle: null, - chatType: "private", - eventId: "telegram:update:unauthorized", - externalActorId: "telegram:user:42", - externalMessageId: "42:77", - externalThreadId: "42:main", - messageId: 77, - messageThreadId: null, - text: "review this", - userDisplayName: "Ada", - userId: "42", - username: "ada", - }, - }); - - expect(markedErrors).toEqual(["Unauthorized"]); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); diff --git a/apps/api/tests/telegram-channel-events-route.test.ts b/apps/api/tests/telegram-channel-events-route.test.ts deleted file mode 100644 index 40f70e70..00000000 --- a/apps/api/tests/telegram-channel-events-route.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import { Hono } from "hono"; - -import { registerTelegramEventsRoute } from "../src/adapters/http/routes/telegram-events-route"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import { createTelegramAgentChannelBinding } from "../src/modules/channels/application/agent-channel-binding.service"; -import type { ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; - -const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -function createTelegramRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - registerTelegramEventsRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function createTelegramEventsUrl(bindingId: string): string { - return buildAgentChannelWebhookUrl({ - bindingId, - origin: "https://api.example.com", - provider: "telegram", - }); -} - -async function withTelegramFetchMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (url) => { - if (readFetchUrl(url) === "https://api.telegram.org/bottelegram-token/getMe") { - return Response.json({ - ok: true, - result: { - first_name: "mosoo Telegram", - id: 9001, - is_bot: true, - username: "mosoo_telegram_bot", - }, - }); - } - - return Response.json({ ok: true, result: { message_id: 1 } }); - }; - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -describe("Telegram channel events route", () => { - test("rejects signed Telegram updates when the secret token mismatches", async () => { - await withTelegramFetchMock(async () => { - const app = createTelegramRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createTelegramAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - const response = await app.request( - new Request(createTelegramEventsUrl(binding.id), { - body: JSON.stringify({ - message: { - chat: { id: 42, type: "private" }, - from: { first_name: "Ada", id: 42, is_bot: false }, - message_id: 77, - text: "review this", - }, - update_id: 1, - }), - headers: { - "x-telegram-bot-api-secret-token": "wrong-secret", - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ - code: "secret_mismatch", - ok: false, - }); - }); - }); - - test("accepts signed Telegram message updates for a Agent API Endpoint binding", async () => { - await withTelegramFetchMock(async () => { - const app = createTelegramRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createTelegramAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - const response = await app.request( - new Request(createTelegramEventsUrl(binding.id), { - body: JSON.stringify({ - message: { - chat: { id: 42, type: "private" }, - from: { first_name: "Ada", id: 42, is_bot: false }, - message_id: 77, - text: "review this", - }, - update_id: 2, - }), - headers: { - "x-telegram-bot-api-secret-token": "telegram-webhook-secret", - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - accepted: true, - adapter: "telegram", - ok: true, - }); - }); - }); - - test("acknowledges unsupported Telegram update shapes without retrying", async () => { - await withTelegramFetchMock(async () => { - const app = createTelegramRouteTestApp(); - const database = await createPublicHttpContractDatabase(); - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const binding = await createTelegramAgentChannelBinding(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - botToken: "telegram-token", - appId: PUBLIC_API_TEST_IDS.app, - webhookSecret: "telegram-webhook-secret", - }); - const response = await app.request( - new Request(createTelegramEventsUrl(binding.id), { - body: JSON.stringify({ - callback_query: { - id: "callback-1", - }, - update_id: 3, - }), - headers: { - "x-telegram-bot-api-secret-token": "telegram-webhook-secret", - }, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ignored: true, ok: true }); - }); - }); -}); diff --git a/apps/api/tests/wechat-channel-connection-fixtures.ts b/apps/api/tests/wechat-channel-connection-fixtures.ts deleted file mode 100644 index 4c8bfeea..00000000 --- a/apps/api/tests/wechat-channel-connection-fixtures.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { ChannelFinalDeliveryMessage } from "../src/modules/channels/application/channel-final-delivery-message"; -import { parseWeChatIlinkPollEnvelope } from "../src/modules/channels/wechat/wechat-events"; -import type { WeChatIlinkPollEnvelope } from "../src/modules/channels/wechat/wechat-events"; -import type { - WeChatPollingOwnerContextTokenRecord, - WeChatPollingOwnerCursorRecord, - WeChatPollingOwnerRuntimeStateRecord, - WeChatPollingOwnerStorageKey, - WeChatPollingOwnerStore, -} from "../src/modules/channels/wechat/wechat-polling-owner"; -import type { WeChatQrPairingSnapshot } from "../src/modules/channels/wechat/wechat-runtime"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { readFetchUrl } from "./helpers/fetch-request-url"; -import type { ChannelFinalDeliveryQueueStub } from "./helpers/public-api-http-test-fixture"; -import { - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - nowMsForTest, -} from "./helpers/public-api-http-test-fixture"; - -export const OWNER_VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: "01J00000000000000000000001", - imageUrl: null, - name: "Owner", -}; - -export function createConfirmedWeChatQrSnapshot( - overrides: Partial = {}, -): WeChatQrPairingSnapshot { - return { - accountId: "account-1", - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "bot-secret", - expiresAtMs: 1779646800000, - ilinkBotId: "bot-1", - ilinkUserId: "account-1", - lastErrorCode: null, - qrCodeImageSrc: "base64-qr", - qrToken: "qr-token", - status: "confirmed", - ...overrides, - }; -} - -export function parseSuccessfulWeChatPoll(body: unknown): WeChatIlinkPollEnvelope { - const parsed = parseWeChatIlinkPollEnvelope(JSON.stringify(body)); - - if (!parsed.ok) { - throw new Error(`Expected WeChat poll parse success, got ${parsed.code}.`); - } - - return parsed.envelope; -} - -export async function createWeChatTestBindings(): Promise { - const database = await createPublicHttpContractDatabase(); - return createPublicHttpTestBindings(database) as ApiBindings; -} - -export function readChannelFinalDeliveryQueueStub( - bindings: ApiBindings, -): ChannelFinalDeliveryQueueStub { - return bindings.CHANNEL_FINAL_DELIVERY_QUEUE as ChannelFinalDeliveryQueueStub; -} - -export function takeQueuedChannelFinalDeliveryMessageBody( - bindings: ApiBindings, - jobId: string, -): ChannelFinalDeliveryMessage { - const queue = readChannelFinalDeliveryQueueStub(bindings); - const match = queue.sent.find((entry) => entry.body.jobId === jobId); - - if (!match) { - throw new Error(`Expected queued message for ${jobId}.`); - } - - return match.body; -} - -export class MemoryWeChatPollingOwnerStore implements WeChatPollingOwnerStore { - readonly contextTokens: WeChatPollingOwnerContextTokenRecord[] = []; - readonly runtimeStates: WeChatPollingOwnerRuntimeStateRecord[] = []; - cursor: string | null = null; - - readCursor(_input: WeChatPollingOwnerStorageKey): Promise { - return Promise.resolve(this.cursor); - } - - writeContextToken(input: WeChatPollingOwnerContextTokenRecord): Promise { - this.contextTokens.push(input); - return Promise.resolve(); - } - - writeCursor(input: WeChatPollingOwnerCursorRecord): Promise { - this.cursor = input.cursor; - return Promise.resolve(); - } - - writeRuntimeState(input: WeChatPollingOwnerRuntimeStateRecord): Promise { - this.runtimeStates.push(input); - return Promise.resolve(); - } -} - -export interface WeChatSendRequest { - body: string | null; - headers: Headers; - url: string; -} - -export function installWeChatSendFetch(sendRequests: WeChatSendRequest[]): () => void { - const originalFetch = globalThis.fetch; - - globalThis.fetch = async (url, init) => { - const requestUrl = readFetchUrl(url); - - if (requestUrl === "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage") { - sendRequests.push({ - body: typeof init?.body === "string" ? init.body : null, - headers: new Headers(init?.headers), - url: requestUrl, - }); - - return Response.json({ ret: 0 }); - } - - return Response.json({ - data: [{ id: "gpt-5.4" }], - }); - }; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -export async function insertCompletedWeChatAssistantReply(input: { - bindings: ApiBindings; - messageId: string; - runId: string; - seq: number; - sessionId: string; - text: string; -}): Promise { - await input.bindings.DB.prepare( - "update session_run set completed_at = ?, status = 'completed', updated_at = ? where id = ?", - ) - .bind(nowMsForTest(), nowMsForTest(), input.runId) - .run(); - await input.bindings.DB.prepare( - [ - "insert into session_message", - "(id, session_id, session_run_id, seq, role, content_text, segments_json, plan_json, created_by_account_id, created_at)", - "values (?, ?, ?, ?, 'assistant', ?, null, null, '01J00000000000000000000001', ?)", - ].join(" "), - ) - .bind(input.messageId, input.sessionId, input.runId, input.seq, input.text, nowMsForTest()) - .run(); - await input.bindings.DB.prepare( - "update session set last_run_id = ?, status = 'IDLE', updated_at = ? where id = ?", - ) - .bind(input.runId, nowMsForTest(), input.sessionId) - .run(); -} - -export function createWeChatDmMessage(input: { - contextToken?: string; - fromUserId?: string; - messageId?: number | string; - text?: string; -}) { - return { - context_token: input.contextToken ?? "ctx-secret", - from_user_id: input.fromUserId ?? "peer-1", - item_list: [{ text_item: { text: input.text ?? "hello mosoo" }, type: 1 }], - message_id: input.messageId ?? 123, - message_state: 2, - message_type: 1, - to_user_id: "bot-1", - }; -} diff --git a/apps/api/tests/wechat-channel-connection-maintenance.test.ts b/apps/api/tests/wechat-channel-connection-maintenance.test.ts deleted file mode 100644 index 5d8063fb..00000000 --- a/apps/api/tests/wechat-channel-connection-maintenance.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - pollWeChatChannelAccountOnce, - runWeChatPollingOwnerMaintenance, -} from "../src/modules/channels/application/wechat-polling-owner-maintenance.service"; -import { persistConfirmedWeChatQrPairing } from "../src/modules/channels/wechat/wechat-runtime-store"; -import { - createTestExecutionContext, - nowMsForTest, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -import { - OWNER_VIEWER, - createConfirmedWeChatQrSnapshot, - createWeChatDmMessage, - createWeChatTestBindings, - installWeChatSendFetch, -} from "./wechat-channel-connection-fixtures"; -import type { WeChatSendRequest } from "./wechat-channel-connection-fixtures"; - -describe("WeChat channel polling maintenance", () => { - test("scheduled maintenance polls active WeChat accounts through the channel session spine", async () => { - const sendRequests: WeChatSendRequest[] = []; - const restoreFetch = installWeChatSendFetch(sendRequests); - - try { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - let nowMs = nowMsForTest(); - - const result = await runWeChatPollingOwnerMaintenance(bindings, new Date(nowMsForTest()), { - clientFactory: ({ credentials }) => { - expect(credentials).toMatchObject({ - botToken: "bot-secret", - ilinkUserId: "account-1", - }); - - return { - getUpdates: async ({ cursor }) => { - expect(cursor).toBe(""); - - return JSON.stringify({ - get_updates_buf: "cursor-next", - msgs: [ - createWeChatDmMessage({ - contextToken: "ctx-secret", - messageId: 123, - text: "hello from wechat", - }), - ], - ret: 0, - }); - }, - }; - }, - executionContext: createTestExecutionContext(), - nowMs: () => { - nowMs += 100; - return nowMs; - }, - }); - - expect(result).toEqual({ - failed: 0, - polled: 1, - skipped: 0, - total: 1, - }); - - const session = await bindings.DB.prepare( - "select attributed_user_id, metadata_json, type from session where agent_id = ?", - ) - .bind("01J00000000000000000000009") - .first<{ attributed_user_id: string | null; metadata_json: string; type: string }>(); - expect(session).toMatchObject({ - attributed_user_id: null, - type: "api_channel", - }); - expect(JSON.parse(session?.metadata_json ?? "{}")).toMatchObject({ - triggered_by: { - binding_id: account.id, - external_actor_id: "wechat:user:peer-1", - external_thread_id: "wechat:dm:peer-1", - provider: "wechat", - provider_metadata: { - chatType: "dm", - peerId: "peer-1", - }, - }, - }); - - const finalDeliveryJob = await bindings.DB.prepare( - "select payload_json, provider, status from channel_final_delivery_job where provider = 'wechat'", - ).first<{ payload_json: string; provider: string; status: string }>(); - expect(finalDeliveryJob).toMatchObject({ - provider: "wechat", - status: "dispatched", - }); - expect(JSON.parse(finalDeliveryJob?.payload_json ?? "{}")).toMatchObject({ - peerId: "peer-1", - provider: "wechat", - }); - - const runtimeState = await bindings.DB.prepare( - [ - "select lease_owner_id, runtime_state_json, status", - "from channel_runtime_state", - "where provider = 'wechat' and binding_id = ?", - ].join(" "), - ) - .bind(account.id) - .first<{ lease_owner_id: string | null; runtime_state_json: string; status: string }>(); - expect(runtimeState).toMatchObject({ - lease_owner_id: null, - status: "running", - }); - expect(runtimeState?.runtime_state_json).toContain("cursor-next"); - expect(runtimeState?.runtime_state_json).not.toContain("ctx-secret"); - - const accountState = await bindings.DB.prepare( - "select cursor, status from wechat_channel_account where id = ?", - ) - .bind(account.id) - .first<{ cursor: string; status: string }>(); - expect(accountState).toEqual({ - cursor: "cursor-next", - status: "running", - }); - - expect(sendRequests).toHaveLength(1); - expect(sendRequests[0]?.headers.get("Authorization")).toBe("Bearer bot-secret"); - expect(JSON.parse(sendRequests[0]?.body ?? "{}")).toMatchObject({ - msg: { - context_token: "ctx-secret", - item_list: [ - { - text_item: { - text: expect.stringContaining("Agent is working"), - }, - type: 1, - }, - ], - to_user_id: "peer-1", - }, - }); - } finally { - restoreFetch(); - } - }); - - test("scheduled maintenance retries WeChat accounts stuck in failed status", async () => { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - - // Failed rows must remain poll candidates after malformed provider responses. - await bindings.DB.prepare( - "update wechat_channel_account set status = 'failed', last_error_code = 'missing_response', updated_at = ? where id = ?", - ) - .bind(nowMsForTest(), account.id) - .run(); - - let nowMs = nowMsForTest(); - const result = await runWeChatPollingOwnerMaintenance(bindings, new Date(nowMsForTest()), { - clientFactory: () => ({ - getUpdates: async ({ cursor }) => { - expect(cursor).toBe(""); - return JSON.stringify({ - errcode: 0, - errmsg: "", - get_updates_buf: "cursor-after-recovery", - longpolling_timeout_ms: 35000, - msgs: [], - ret: 0, - }); - }, - }), - executionContext: createTestExecutionContext(), - nowMs: () => { - nowMs += 100; - return nowMs; - }, - }); - - expect(result).toEqual({ - failed: 0, - polled: 1, - skipped: 0, - total: 1, - }); - - const recoveredAccount = await bindings.DB.prepare( - "select cursor, status from wechat_channel_account where id = ?", - ) - .bind(account.id) - .first<{ cursor: string; status: string }>(); - expect(recoveredAccount).toEqual({ - cursor: "cursor-after-recovery", - status: "running", - }); - }); - - test("scheduled WeChat polling owner rejects overlapping poll attempts for the same account", async () => { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - const nowMs = nowMsForTest(); - let resolveStarted: (() => void) | null = null; - let resolveUpdates: ((body: string) => void) | null = null; - const getUpdatesStarted = new Promise((resolve) => { - resolveStarted = resolve; - }); - const getUpdatesBody = new Promise((resolve) => { - resolveUpdates = resolve; - }); - - const firstPoll = pollWeChatChannelAccountOnce(bindings, { - accountId: account.id, - clientFactory: () => ({ - getUpdates: () => { - resolveStarted?.(); - return getUpdatesBody; - }, - }), - executionContext: createTestExecutionContext(), - nowMs: () => nowMs, - }); - - await getUpdatesStarted; - - const leasedRuntimeState = await bindings.DB.prepare( - "select lease_owner_id, status from channel_runtime_state where provider = 'wechat' and binding_id = ?", - ) - .bind(account.id) - .first<{ lease_owner_id: string | null; status: string }>(); - expect(leasedRuntimeState?.lease_owner_id).toBeString(); - expect(leasedRuntimeState?.status).toBe("starting"); - - await expect( - pollWeChatChannelAccountOnce(bindings, { - accountId: account.id, - clientFactory: () => { - throw new Error("Second poll must not create a WeChat polling client."); - }, - executionContext: createTestExecutionContext(), - nowMs: () => nowMs, - }), - ).resolves.toEqual({ code: "lease_unavailable" }); - - resolveUpdates?.( - JSON.stringify({ - get_updates_buf: "cursor-next", - msgs: [], - ret: 0, - }), - ); - - await expect(firstPoll).resolves.toMatchObject({ - code: "polled", - pollResult: { - nextCursor: "cursor-next", - status: "running", - }, - }); - - const completedRuntimeState = await bindings.DB.prepare( - "select lease_owner_id, status from channel_runtime_state where provider = 'wechat' and binding_id = ?", - ) - .bind(account.id) - .first<{ lease_owner_id: string | null; status: string }>(); - expect(completedRuntimeState).toEqual({ - lease_owner_id: null, - status: "running", - }); - }); -}); diff --git a/apps/api/tests/wechat-channel-connection-persistence.test.ts b/apps/api/tests/wechat-channel-connection-persistence.test.ts deleted file mode 100644 index 47e85094..00000000 --- a/apps/api/tests/wechat-channel-connection-persistence.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { WeChatIlinkWorkTrigger } from "../src/modules/channels/wechat/wechat-events"; -import { WeChatPollingRuntimeOwner } from "../src/modules/channels/wechat/wechat-polling-owner"; -import { - sendWeChatStoredContextReply, - WeChatReplyError, -} from "../src/modules/channels/wechat/wechat-reply.service"; -import { createWeChatContextTokenStoreKey } from "../src/modules/channels/wechat/wechat-runtime"; -import { - createWeChatPollingOwnerDatabaseStore, - persistConfirmedWeChatQrPairing, - readWeChatChannelAccountWithCredentials, - readWeChatContextTokenForPeer, -} from "../src/modules/channels/wechat/wechat-runtime-store"; -import { nowMsForTest, PUBLIC_API_TEST_IDS } from "./helpers/public-api-http-test-fixture"; -import { - OWNER_VIEWER, - createConfirmedWeChatQrSnapshot, - createWeChatDmMessage, - createWeChatTestBindings, -} from "./wechat-channel-connection-fixtures"; - -describe("WeChat channel runtime persistence", () => { - test("persists confirmed QR credentials as an encrypted channel binding account runtime", async () => { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot({ - baseUrl: "https://ilinkai.weixin.qq.com/", - }), - }); - - expect(account).toMatchObject({ - agentId: "01J00000000000000000000009", - baseUrl: "https://ilinkai.weixin.qq.com", - cursor: null, - externalAccountId: "account-1", - externalBotId: "bot-1", - ownerAccountId: "01J00000000000000000000001", - status: "idle", - }); - - const withCredentials = await readWeChatChannelAccountWithCredentials(bindings, { - accountId: account.id, - }); - expect(withCredentials?.credentials).toEqual({ - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "bot-secret", - ilinkBotId: "bot-1", - ilinkUserId: "account-1", - }); - - expect(JSON.stringify(withCredentials)).not.toContain("wrapped_dek"); - }); - - test("rejects untrusted persisted QR base URLs before storing credentials", async () => { - const bindings = await createWeChatTestBindings(); - - await expect( - persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot({ - baseUrl: "https://untrusted.example", - }), - }), - ).rejects.toThrow(); - - expect( - await readWeChatChannelAccountWithCredentials(bindings, { - accountId: "01J00000000000000000000009", - }), - ).toBeNull(); - }); - - test("clears stale context-token secrets when QR pairing is rebound", async () => { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot({ botToken: "bot-secret-1" }), - }); - const store = createWeChatPollingOwnerDatabaseStore(bindings); - - await store.writeContextToken({ - accountId: "account-1", - bindingId: account.id, - contextTokenKey: createWeChatContextTokenStoreKey({ - accountId: "account-1", - bindingId: account.id, - peerId: "peer-1", - }), - contextTokenValue: "ctx-secret-1", - peerId: "peer-1", - toUserId: "peer-1", - updatedAtMs: 1779646500000, - }); - - await expect( - readWeChatContextTokenForPeer(bindings, { - accountId: account.id, - peerId: "peer-1", - }), - ).resolves.toMatchObject({ - contextToken: "ctx-secret-1", - }); - - const rebound = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot({ - accountId: "account-2", - botToken: "bot-secret-2", - ilinkBotId: "bot-2", - ilinkUserId: "account-2", - }), - }); - - expect(rebound).toMatchObject({ - externalAccountId: "account-2", - externalBotId: "bot-2", - id: account.id, - status: "idle", - }); - - await expect( - readWeChatContextTokenForPeer(bindings, { - accountId: account.id, - peerId: "peer-1", - }), - ).resolves.toBeNull(); - }); - - test("database polling store persists cursor/runtime/context token and stored-token replies", async () => { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - const store = createWeChatPollingOwnerDatabaseStore(bindings); - const triggers: WeChatIlinkWorkTrigger[] = []; - let nowMs = 2000; - const owner = new WeChatPollingRuntimeOwner({ - accountId: account.externalAccountId, - bindingId: account.id, - botId: account.externalBotId, - client: { - getUpdates: async () => - JSON.stringify({ - get_updates_buf: "cursor-next", - msgs: [createWeChatDmMessage({ contextToken: "ctx-secret", messageId: 123 })], - ret: 0, - }), - }, - nowMs: () => { - nowMs += 100; - return nowMs; - }, - onTrigger: async (trigger) => { - triggers.push(trigger); - }, - store, - }); - - await expect(owner.pollOnce()).resolves.toMatchObject({ - droppedMessageCount: 0, - nextCursor: "cursor-next", - processedMessageCount: 1, - status: "running", - }); - expect(triggers).toHaveLength(1); - - const runtimeRow = await bindings.DB.prepare( - "select cursor, runtime_state_json, status from wechat_channel_account where id = ?", - ) - .bind(account.id) - .first<{ cursor: string; runtime_state_json: string; status: string }>(); - expect(runtimeRow).toMatchObject({ - cursor: "cursor-next", - status: "running", - }); - expect(runtimeRow?.runtime_state_json).toContain("cursor-next"); - expect(runtimeRow?.runtime_state_json).not.toContain("ctx-secret"); - - const sendRequests: Array<{ body: string | null; headers: Headers; url: string }> = []; - const fetchImpl: typeof fetch = async (request, init) => { - const url = - request instanceof Request ? request.url : request instanceof URL ? request.href : request; - sendRequests.push({ - body: typeof init?.body === "string" ? init.body : null, - headers: new Headers(init?.headers), - url, - }); - return Response.json({ ret: 0 }); - }; - - await sendWeChatStoredContextReply(bindings, { - accountId: account.id, - clientId: "reply-client-1", - fetchImpl, - peerId: "peer-1", - text: "reply from mosoo", - }); - - expect(sendRequests).toHaveLength(1); - expect(sendRequests[0]?.url).toBe("https://ilinkai.weixin.qq.com/ilink/bot/sendmessage"); - expect(sendRequests[0]?.headers.get("Authorization")).toBe("Bearer bot-secret"); - expect(JSON.parse(sendRequests[0]?.body ?? "{}")).toMatchObject({ - msg: { - client_id: "reply-client-1", - context_token: "ctx-secret", - item_list: [{ text_item: { text: "reply from mosoo" }, type: 1 }], - to_user_id: "peer-1", - }, - }); - - await bindings.DB.prepare("update wechat_channel_account set status = 'stopped' where id = ?") - .bind(account.id) - .run(); - try { - await sendWeChatStoredContextReply(bindings, { - accountId: account.id, - clientId: "reply-client-2", - fetchImpl, - peerId: "peer-1", - text: "reply after stop", - }); - throw new Error("Expected stopped WeChat account reply to fail."); - } catch (error) { - if (!(error instanceof WeChatReplyError)) { - throw error; - } - - expect(error.code).toBe("account_not_running"); - } - expect(sendRequests).toHaveLength(1); - }); -}); diff --git a/apps/api/tests/wechat-channel-connection.test.ts b/apps/api/tests/wechat-channel-connection.test.ts deleted file mode 100644 index 539e6d13..00000000 --- a/apps/api/tests/wechat-channel-connection.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - createWeChatProviderMetadata, - normalizeWeChatIlinkWorkTrigger, - summarizeWeChatPollRuntime, -} from "../src/modules/channels/wechat/wechat-events"; -import { applyWeChatQrStatusResponse } from "../src/modules/channels/wechat/wechat-runtime"; -import { parseSuccessfulWeChatPoll } from "./wechat-channel-connection-fixtures"; - -describe("WeChat channel connection scaffold", () => { - test("normalizes finished DM text messages and separates context-token routing state", () => { - const envelope = parseSuccessfulWeChatPoll({ - get_updates_buf: "cursor-next", - msgs: [ - { - client_id: "client-1", - context_token: "ctx-secret", - create_time_ms: 1779646500000, - from_user_id: "peer-1", - item_list: [{ text_item: { text: "hello mosoo" }, type: 1 }], - message_id: 123, - message_state: 2, - message_type: 1, - to_user_id: "bot-1", - }, - ], - ret: 0, - }); - - expect(summarizeWeChatPollRuntime(envelope)).toEqual({ - nextCursor: "cursor-next", - reason: null, - status: "ok", - }); - - const trigger = normalizeWeChatIlinkWorkTrigger(envelope.messages[0], { - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - }); - - expect(trigger).toEqual({ - eventId: "wechat:message:123", - externalActorId: "wechat:user:peer-1", - externalMessageId: "peer-1:123", - externalThreadId: "wechat:dm:peer-1", - messageId: "123", - peerId: "peer-1", - replyRoute: { - contextTokenKey: expect.any(String), - contextTokenValue: "ctx-secret", - toUserId: "peer-1", - }, - text: "hello mosoo", - }); - - if (!trigger) { - throw new Error("Expected WeChat trigger."); - } - - expect(createWeChatProviderMetadata(trigger)).toEqual({ - chatType: "dm", - peerId: "peer-1", - }); - expect(JSON.stringify(createWeChatProviderMetadata(trigger))).not.toContain("ctx-secret"); - }); - - test("rejects bot, unfinished, ambiguous, and group-shaped messages", () => { - const baseMessage = parseSuccessfulWeChatPoll({ - get_updates_buf: "cursor-next", - msgs: [ - { - context_token: "ctx-secret", - from_user_id: "peer-1", - item_list: [{ text_item: { text: "hello" }, type: 1 }], - message_id: 123, - message_state: 2, - message_type: 1, - to_user_id: "bot-1", - }, - ], - ret: 0, - }).messages[0]; - - expect( - normalizeWeChatIlinkWorkTrigger( - { - ...baseMessage, - messageType: 2, - }, - { accountId: "account-1", bindingId: "binding-1", botId: "bot-1" }, - ), - ).toBeNull(); - expect( - normalizeWeChatIlinkWorkTrigger( - { - ...baseMessage, - messageState: 1, - }, - { accountId: "account-1", bindingId: "binding-1", botId: "bot-1" }, - ), - ).toBeNull(); - expect( - normalizeWeChatIlinkWorkTrigger( - { - ...baseMessage, - toUserId: "other-account", - }, - { accountId: "account-1", bindingId: "binding-1", botId: "bot-1" }, - ), - ).toBeNull(); - expect( - normalizeWeChatIlinkWorkTrigger( - { - ...baseMessage, - roomId: "room-1", - }, - { accountId: "account-1", bindingId: "binding-1", botId: "bot-1" }, - ), - ).toBeNull(); - }); - - test("maps iLink session expiry to relogin-required runtime state", () => { - const envelope = parseSuccessfulWeChatPoll({ - errcode: -14, - errmsg: "session expired", - get_updates_buf: "stale-cursor", - msgs: [], - ret: 0, - }); - - expect(summarizeWeChatPollRuntime(envelope)).toEqual({ - nextCursor: null, - reason: "session_expired", - status: "relogin_required", - }); - - expect( - summarizeWeChatPollRuntime( - parseSuccessfulWeChatPoll({ - errcode: -14, - errmsg: "session expired", - get_updates_buf: "stale-cursor", - ret: 0, - }), - ), - ).toMatchObject({ - reason: "session_expired", - status: "relogin_required", - }); - }); - - test("models QR pairing as an account-runtime state machine", () => { - const initial = { - accountId: null, - baseUrl: null, - botToken: null, - expiresAtMs: 1779646800000, - ilinkBotId: null, - ilinkUserId: null, - lastErrorCode: null, - qrCodeImageSrc: "base64-qr", - qrToken: "qr-token", - status: "qr_pending" as const, - }; - - expect(applyWeChatQrStatusResponse(initial, { status: "scaned" })).toMatchObject({ - status: "scanned", - }); - expect(applyWeChatQrStatusResponse(initial, { status: "expired" })).toMatchObject({ - lastErrorCode: "qr_expired", - status: "expired", - }); - expect(applyWeChatQrStatusResponse(initial, { status: "confirmed" })).toMatchObject({ - lastErrorCode: "confirmed_missing_credentials", - status: "failed", - }); - expect( - applyWeChatQrStatusResponse(initial, { - baseurl: "https://ilinkai.weixin.qq.com/", - bot_token: "bot-secret", - ilink_bot_id: "bot-1", - ilink_user_id: "account-1", - status: "confirmed", - }), - ).toMatchObject({ - accountId: "account-1", - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "bot-secret", - ilinkBotId: "bot-1", - ilinkUserId: "account-1", - status: "confirmed", - }); - expect( - applyWeChatQrStatusResponse(initial, { - baseurl: "https://untrusted.example", - bot_token: "bot-secret", - ilink_bot_id: "bot-1", - ilink_user_id: "account-1", - status: "confirmed", - }), - ).toMatchObject({ - lastErrorCode: "confirmed_untrusted_base_url", - status: "failed", - }); - }); -}); diff --git a/apps/api/tests/wechat-channel-final-delivery.test.ts b/apps/api/tests/wechat-channel-final-delivery.test.ts deleted file mode 100644 index 3541a428..00000000 --- a/apps/api/tests/wechat-channel-final-delivery.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ChannelFinalDeliveryMessage } from "../src/modules/channels/application/channel-final-delivery-message"; -import { - enqueueChannelFinalDeliveryJob, - processChannelFinalDeliveryMessage, -} from "../src/modules/channels/application/channel-final-delivery.service"; -import { - createChannelSessionClient, - resolveAgentChannelBindingContextById, -} from "../src/modules/channels/application/channel-session.service"; -import { createWeChatContextTokenStoreKey } from "../src/modules/channels/wechat/wechat-runtime"; -import { - createWeChatPollingOwnerDatabaseStore, - persistConfirmedWeChatQrPairing, -} from "../src/modules/channels/wechat/wechat-runtime-store"; -import { - createRecordedQueueMessage, - createTestExecutionContext, - nowMsForTest, - PUBLIC_API_TEST_IDS, -} from "./helpers/public-api-http-test-fixture"; -import { - OWNER_VIEWER, - createConfirmedWeChatQrSnapshot, - createWeChatTestBindings, - installWeChatSendFetch, - insertCompletedWeChatAssistantReply, - readChannelFinalDeliveryQueueStub, - takeQueuedChannelFinalDeliveryMessageBody, -} from "./wechat-channel-connection-fixtures"; -import type { WeChatSendRequest } from "./wechat-channel-connection-fixtures"; - -describe("WeChat channel final delivery", () => { - test("durable final delivery sends WeChat replies through the stored context token", async () => { - const sendRequests: WeChatSendRequest[] = []; - const restoreFetch = installWeChatSendFetch(sendRequests); - - try { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - const store = createWeChatPollingOwnerDatabaseStore(bindings); - - await store.writeContextToken({ - accountId: "account-1", - bindingId: account.id, - contextTokenKey: createWeChatContextTokenStoreKey({ - accountId: "account-1", - bindingId: account.id, - peerId: "peer-1", - }), - contextTokenValue: "ctx-final-secret", - peerId: "peer-1", - toUserId: "peer-1", - updatedAtMs: nowMsForTest(), - }); - await bindings.DB.prepare( - "update wechat_channel_account set status = 'running', updated_at = ? where id = ?", - ) - .bind(nowMsForTest(), account.id) - .run(); - - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: account.id, - provider: "wechat", - }); - - if (!binding) { - throw new Error("Expected WeChat binding context."); - } - - const sessionCommand = await createChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "scheduled://wechat-polling-owner", - }).createOrContinueSession({ - clientRequestId: "wechat:message:final", - text: "Review this WeChat message", - trigger: { - eventId: "wechat:message:final", - externalActorId: "wechat:user:peer-1", - externalMessageId: "peer-1:final", - externalThreadId: "wechat:dm:peer-1", - providerMetadata: { - chatType: "dm", - peerId: "peer-1", - }, - requiresExistingSession: false, - }, - }); - - if (!sessionCommand.sessionId || !sessionCommand.runId) { - throw new Error("Expected WeChat session command to create a session and run."); - } - - await insertCompletedWeChatAssistantReply({ - bindings, - messageId: "wechat-final-assistant-message", - runId: sessionCommand.runId, - seq: 2, - sessionId: sessionCommand.sessionId, - text: "Final answer from mosoo", - }); - const jobId = await enqueueChannelFinalDeliveryJob( - bindings, - { - bindingId: account.id, - externalEventId: "wechat:message:final", - payload: { - peerId: "peer-1", - provider: "wechat", - }, - provider: "wechat", - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }, - nowMsForTest(), - ); - - if (!jobId) { - throw new Error("Expected WeChat final delivery job to be queued."); - } - - const queued = takeQueuedChannelFinalDeliveryMessageBody(bindings, jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await bindings.DB.prepare( - "select status from channel_final_delivery_job where external_event_id = ?", - ) - .bind("wechat:message:final") - .first<{ - status: string; - }>(); - expect(job).toEqual({ - status: "delivered", - }); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(sendRequests).toHaveLength(1); - expect(sendRequests[0]?.headers.get("Authorization")).toBe("Bearer bot-secret"); - expect(JSON.parse(sendRequests[0]?.body ?? "{}")).toMatchObject({ - msg: { - context_token: "ctx-final-secret", - item_list: [ - { - text_item: { - text: `mosoo session ${sessionCommand.sessionId}\n\nFinal answer from mosoo`, - }, - type: 1, - }, - ], - to_user_id: "peer-1", - }, - }); - } finally { - restoreFetch(); - } - }); - - test("durable final delivery records typed WeChat reply errors without tokenless fallback", async () => { - const sendRequests: WeChatSendRequest[] = []; - const restoreFetch = installWeChatSendFetch(sendRequests); - - try { - const bindings = await createWeChatTestBindings(); - const account = await persistConfirmedWeChatQrPairing(bindings, OWNER_VIEWER, { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - snapshot: createConfirmedWeChatQrSnapshot(), - }); - await bindings.DB.prepare( - "update wechat_channel_account set status = 'running', updated_at = ? where id = ?", - ) - .bind(nowMsForTest(), account.id) - .run(); - - const binding = await resolveAgentChannelBindingContextById(bindings, { - bindingId: account.id, - provider: "wechat", - }); - - if (!binding) { - throw new Error("Expected WeChat binding context."); - } - - const sessionCommand = await createChannelSessionClient({ - binding, - bindings, - executionContext: createTestExecutionContext(), - requestUrl: "scheduled://wechat-polling-owner", - }).createOrContinueSession({ - clientRequestId: "wechat:message:missing-context", - text: "Review this WeChat message", - trigger: { - eventId: "wechat:message:missing-context", - externalActorId: "wechat:user:peer-1", - externalMessageId: "peer-1:missing-context", - externalThreadId: "wechat:dm:peer-1", - providerMetadata: { - chatType: "dm", - peerId: "peer-1", - }, - requiresExistingSession: false, - }, - }); - - if (!sessionCommand.sessionId || !sessionCommand.runId) { - throw new Error("Expected WeChat session command to create a session and run."); - } - - await insertCompletedWeChatAssistantReply({ - bindings, - messageId: "wechat-missing-context-assistant-message", - runId: sessionCommand.runId, - seq: 2, - sessionId: sessionCommand.sessionId, - text: "Final answer without a stored context token", - }); - const jobId = await enqueueChannelFinalDeliveryJob( - bindings, - { - bindingId: account.id, - externalEventId: "wechat:message:missing-context", - payload: { - peerId: "peer-1", - provider: "wechat", - }, - provider: "wechat", - runId: sessionCommand.runId, - sessionId: sessionCommand.sessionId, - }, - nowMsForTest(), - ); - - if (!jobId) { - throw new Error("Expected WeChat final delivery job to be queued."); - } - - const queued = takeQueuedChannelFinalDeliveryMessageBody(bindings, jobId); - const recorded = createRecordedQueueMessage({ body: queued }); - await processChannelFinalDeliveryMessage(bindings, recorded.message, {}, nowMsForTest); - - const job = await bindings.DB.prepare( - [ - "select attempt_count, last_error_code, status", - "from channel_final_delivery_job where external_event_id = ?", - ].join(" "), - ) - .bind("wechat:message:missing-context") - .first<{ - attempt_count: number; - last_error_code: string | null; - status: string; - }>(); - - expect(job).toMatchObject({ - attempt_count: 1, - last_error_code: "context_token_not_found", - status: "dispatched", - }); - const queue = readChannelFinalDeliveryQueueStub(bindings); - expect(recorded.recorded).toEqual([{ type: "ack" }]); - expect(queue.sent.at(-1)).toEqual({ - body: { jobId }, - contentType: "json", - delaySeconds: 60, - id: "queued-2", - }); - expect(sendRequests).toHaveLength(0); - } finally { - restoreFetch(); - } - }); -}); diff --git a/apps/api/tests/wechat-polling-owner.test.ts b/apps/api/tests/wechat-polling-owner.test.ts deleted file mode 100644 index 5bce255d..00000000 --- a/apps/api/tests/wechat-polling-owner.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { summarizeChannelConnectionOwnerHealth } from "../src/modules/channels/application/channel-connection-health"; -import type { WeChatIlinkWorkTrigger } from "../src/modules/channels/wechat/wechat-events"; -import { - WeChatIlinkApiError, - WeChatIlinkClient, - WeChatIlinkHttpError, -} from "../src/modules/channels/wechat/wechat-ilink-client"; -import { WeChatPollingRuntimeOwner } from "../src/modules/channels/wechat/wechat-polling-owner"; -import { - MemoryWeChatPollingOwnerStore, - createWeChatDmMessage, -} from "./wechat-channel-connection-fixtures"; - -describe("WeChat polling owner", () => { - test("summarizes a leased long-poll runtime owner without provider-specific fields", () => { - const snapshot = { - key: { - accountId: "account-1", - bindingId: "binding-1", - provider: "wechat", - }, - lastErrorCode: null, - lastHeartbeatAtMs: 1000, - lastInboundAtMs: 1200, - lastPollAtMs: 1300, - leaseExpiresAtMs: 10_000, - leaseOwnerId: "01J00000000000000000000001", - status: "running" as const, - statusChangedAtMs: 900, - }; - - expect( - summarizeChannelConnectionOwnerHealth(snapshot, { - nowMs: 2000, - staleAfterMs: 5000, - }), - ).toEqual({ - reason: null, - stale: false, - status: "running", - }); - expect( - summarizeChannelConnectionOwnerHealth(snapshot, { - nowMs: 10_001, - staleAfterMs: 5000, - }), - ).toEqual({ - reason: "lease_expired", - stale: true, - status: "stale", - }); - }); - - test("calls iLink QR, poll, and sendmessage APIs without tokenless reply fallback", async () => { - const requests: Array<{ - body: string | null; - headers: Headers; - method: string; - url: string; - }> = []; - const fetchImpl: typeof fetch = async (request, init) => { - const url = - request instanceof Request ? request.url : request instanceof URL ? request.href : request; - const headers = new Headers(init?.headers); - const method = init?.method ?? "GET"; - const body = typeof init?.body === "string" ? init.body : null; - - requests.push({ body, headers, method, url }); - - if (url.includes("get_bot_qrcode")) { - return Response.json({ - qrcode: "qr-token", - qrcode_img_content: "https://qr.example/scan", - }); - } - - if (url.includes("get_qrcode_status")) { - return Response.json({ - baseurl: "https://ilinkai.weixin.qq.com", - bot_token: "bot-secret", - ilink_bot_id: "bot-1", - ilink_user_id: "account-1", - status: "confirmed", - }); - } - - if (url.includes("getupdates")) { - return Response.json({ - get_updates_buf: "cursor-next", - msgs: [], - ret: 0, - }); - } - - if (url.includes("sendmessage")) { - return Response.json({ ret: 0 }); - } - - return Response.json({ errmsg: "unexpected", ret: 1 }); - }; - const client = new WeChatIlinkClient({ - baseUrl: "https://ilinkai.weixin.qq.com", - botToken: "bot-secret", - fetchImpl, - randomUin: () => "uin-1", - }); - expect(() => new WeChatIlinkClient({ baseUrl: "https://untrusted.example" })).toThrow(); - - await expect(client.getBotQr()).resolves.toEqual({ - qrCodeImageContent: "https://qr.example/scan", - qrToken: "qr-token", - }); - await expect(client.getQrStatus({ qrToken: "qr-token" })).resolves.toMatchObject({ - bot_token: "bot-secret", - ilink_bot_id: "bot-1", - ilink_user_id: "account-1", - status: "confirmed", - }); - await expect(client.getUpdates({ cursor: "cursor-1", timeoutMs: 1000 })).resolves.toContain( - "cursor-next", - ); - await client.sendText({ - clientId: "client-1", - contextToken: "ctx-secret", - text: "reply text", - toUserId: "peer-1", - }); - await expect( - client.sendText({ - clientId: "client-2", - contextToken: " ", - text: "reply text", - toUserId: "peer-1", - }), - ).rejects.toThrow(); - - const sendRequest = requests.find((request) => request.url.includes("sendmessage")); - expect(sendRequest?.method).toBe("POST"); - expect(sendRequest?.headers.get("Authorization")).toBe("Bearer bot-secret"); - expect(sendRequest?.headers.get("X-WECHAT-UIN")).toBe("uin-1"); - expect(sendRequest?.body).toBeString(); - expect(JSON.parse(sendRequest?.body ?? "{}")).toMatchObject({ - msg: { - client_id: "client-1", - context_token: "ctx-secret", - item_list: [{ text_item: { text: "reply text" }, type: 1 }], - message_type: 2, - to_user_id: "peer-1", - }, - }); - expect(JSON.stringify(JSON.parse(sendRequest?.body ?? "{}"))).not.toContain("client-2"); - }); - - test("polling owner persists context tokens and dispatches normalized DM triggers", async () => { - const store = new MemoryWeChatPollingOwnerStore(); - const triggers: WeChatIlinkWorkTrigger[] = []; - let nowMs = 1000; - const owner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async ({ cursor, timeoutMs }) => { - expect(cursor).toBe(""); - expect(timeoutMs).toBeNumber(); - return JSON.stringify({ - get_updates_buf: "cursor-next", - msgs: [ - createWeChatDmMessage({ messageId: 123 }), - { - ...createWeChatDmMessage({ messageId: 124 }), - room_id: "room-1", - }, - ], - ret: 0, - }); - }, - }, - nowMs: () => { - nowMs += 100; - return nowMs; - }, - onTrigger: async (trigger) => { - triggers.push(trigger); - }, - store, - }); - - await expect(owner.pollOnce()).resolves.toEqual({ - droppedMessageCount: 1, - nextCursor: "cursor-next", - processedMessageCount: 1, - runtimeSummary: { - nextCursor: "cursor-next", - reason: null, - status: "ok", - }, - status: "running", - }); - - expect(store.cursor).toBe("cursor-next"); - expect(store.contextTokens).toEqual([ - expect.objectContaining({ - accountId: "account-1", - bindingId: "binding-1", - contextTokenKey: expect.any(String), - contextTokenValue: "ctx-secret", - peerId: "peer-1", - toUserId: "peer-1", - }), - ]); - expect(triggers).toHaveLength(1); - expect(store.runtimeStates.at(-1)).toMatchObject({ - accountId: "account-1", - bindingId: "binding-1", - runtimeState: { - lastProcessedMessageId: "123", - nextCursor: "cursor-next", - pollTimeoutMs: expect.any(Number), - }, - snapshot: { - key: { - accountId: "account-1", - bindingId: "binding-1", - provider: "wechat", - }, - lastErrorCode: null, - status: "running", - }, - }); - expect(store.runtimeStates.at(-1)?.runtimeStateJson).not.toContain("ctx-secret"); - }); - - test("polling owner maps session expiry to relogin-required without advancing cursor", async () => { - const store = new MemoryWeChatPollingOwnerStore(); - store.cursor = "cursor-old"; - const owner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async () => - JSON.stringify({ - errcode: -14, - errmsg: "session expired", - get_updates_buf: "cursor-new", - msgs: [createWeChatDmMessage({ messageId: 123 })], - ret: 0, - }), - }, - onTrigger: async () => { - throw new Error("should not dispatch when relogin is required"); - }, - store, - }); - - await expect(owner.pollOnce()).resolves.toMatchObject({ - nextCursor: null, - processedMessageCount: 0, - runtimeSummary: { - reason: "session_expired", - status: "relogin_required", - }, - status: "relogin_required", - }); - expect(store.cursor).toBe("cursor-old"); - expect(store.contextTokens).toHaveLength(0); - expect(store.runtimeStates.at(-1)?.snapshot).toMatchObject({ - lastErrorCode: "session_expired", - status: "relogin_required", - }); - }); - - test("polling owner treats malformed message payloads as failed without advancing cursor", async () => { - const store = new MemoryWeChatPollingOwnerStore(); - store.cursor = "cursor-old"; - const owner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async () => - JSON.stringify({ - get_updates_buf: "cursor-new", - msgs: [ - { - context_token: "ctx-secret", - from_user_id: "peer-1", - item_list: [{ text_item: { text: "hello" }, type: 1 }], - message_id: 123, - message_type: 1, - to_user_id: "bot-1", - }, - ], - ret: 0, - }), - }, - onTrigger: async () => { - throw new Error("should not dispatch malformed provider payloads"); - }, - store, - }); - - await expect(owner.pollOnce()).resolves.toMatchObject({ - nextCursor: "cursor-old", - processedMessageCount: 0, - status: "failed", - }); - expect(store.cursor).toBe("cursor-old"); - expect(store.contextTokens).toHaveLength(0); - expect(store.runtimeStates.at(-1)?.snapshot).toMatchObject({ - lastErrorCode: "invalid_messages", - status: "failed", - }); - }); - - test("polling owner classifies credential and transient poll failures separately", async () => { - const credentialStore = new MemoryWeChatPollingOwnerStore(); - credentialStore.cursor = "cursor-old"; - const credentialOwner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async () => { - throw new WeChatIlinkApiError({ - code: "missing_bot_token", - endpoint: "ilink/bot/getupdates", - message: "missing token", - }); - }, - }, - onTrigger: async () => { - throw new Error("should not dispatch without credentials"); - }, - store: credentialStore, - }); - - await expect(credentialOwner.pollOnce()).resolves.toMatchObject({ - nextCursor: null, - status: "relogin_required", - }); - expect(credentialStore.cursor).toBe("cursor-old"); - expect(credentialStore.runtimeStates.at(-1)?.snapshot).toMatchObject({ - lastErrorCode: "missing_bot_token", - status: "relogin_required", - }); - - const transientStore = new MemoryWeChatPollingOwnerStore(); - transientStore.cursor = "cursor-old"; - const transientOwner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async () => { - throw new WeChatIlinkHttpError({ - bodyPreview: "temporary provider failure", - endpoint: "ilink/bot/getupdates", - status: 503, - }); - }, - }, - onTrigger: async () => { - throw new Error("should not dispatch during transient poll failures"); - }, - store: transientStore, - }); - - await expect(transientOwner.pollOnce()).resolves.toMatchObject({ - nextCursor: "cursor-old", - status: "reconnecting", - }); - expect(transientStore.cursor).toBe("cursor-old"); - expect(transientStore.runtimeStates.at(-1)?.snapshot).toMatchObject({ - lastErrorCode: "http_503", - status: "reconnecting", - }); - }); - - test("polling owner does not advance cursor when trigger dispatch fails", async () => { - const store = new MemoryWeChatPollingOwnerStore(); - const owner = new WeChatPollingRuntimeOwner({ - accountId: "account-1", - bindingId: "binding-1", - botId: "bot-1", - client: { - getUpdates: async () => - JSON.stringify({ - get_updates_buf: "cursor-next", - msgs: [createWeChatDmMessage({ messageId: 123 })], - ret: 0, - }), - }, - onTrigger: async () => { - throw new Error("dispatch failed"); - }, - store, - }); - - await expect(owner.pollOnce()).rejects.toThrow(); - expect(store.cursor).toBeNull(); - expect(store.contextTokens).toHaveLength(1); - expect(store.runtimeStates.at(-1)?.snapshot).toMatchObject({ - lastErrorCode: "trigger_dispatch_failed", - status: "failed", - }); - }); -}); diff --git a/apps/api/tests/workspace-api-key.test.ts b/apps/api/tests/workspace-api-key.test.ts index e22f8b27..b9617500 100644 --- a/apps/api/tests/workspace-api-key.test.ts +++ b/apps/api/tests/workspace-api-key.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { PUBLIC_API_PREFIX } from "@mosoo/contracts/public-api"; -import type { WorkspaceApiKeyId } from "@mosoo/id"; import { createHttpApp } from "../src/adapters/http/create-http-app"; import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; @@ -73,7 +72,7 @@ describe("Workspace API keys", () => { }); await revokeWorkspaceApiKey(database, OWNER, { - keyId: created.key.id as WorkspaceApiKeyId, + keyId: created.key.id, workspaceId: PUBLIC_API_TEST_IDS.app, }); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index 56dc2598..137bc036 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -22,7 +22,6 @@ FILE_BUCKET_NAME = "mosoo-file" BACKUP_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "true" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps.localhost" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "development" @@ -54,10 +53,6 @@ class_name = "DriverConnection" name = "Session" class_name = "Session" -[[durable_objects.bindings]] -name = "ChannelConnection" -class_name = "ChannelConnection" - [[durable_objects.bindings]] name = "Sandbox" class_name = "Sandbox" @@ -94,10 +89,6 @@ queue = "api-command" binding = "ENVIRONMENT_ARTIFACT_BUILD_QUEUE" queue = "environment-artifact-build" -[[queues.producers]] -binding = "CHANNEL_FINAL_DELIVERY_QUEUE" -queue = "channel-final-delivery" - [[queues.consumers]] queue = "api-command" max_batch_size = 10 @@ -119,13 +110,6 @@ max_retries = 5 max_concurrency = 5 dead_letter_queue = "api-command-dlq" -[[queues.consumers]] -queue = "channel-final-delivery" -max_batch_size = 10 -max_batch_timeout = 5 -max_retries = 5 -dead_letter_queue = "channel-final-delivery-dlq" - [env.stage] name = "mosoo-api-stage" workers_dev = true @@ -151,7 +135,6 @@ FILE_BUCKET_NAME = "mosoo-stage-file" BACKUP_BUCKET_NAME = "mosoo-stage-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-stage-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "false" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps-stage.mosoo.ai" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "production" @@ -182,10 +165,6 @@ class_name = "DriverConnection" name = "Session" class_name = "Session" -[[env.stage.durable_objects.bindings]] -name = "ChannelConnection" -class_name = "ChannelConnection" - [[env.stage.durable_objects.bindings]] name = "Sandbox" class_name = "Sandbox" @@ -222,10 +201,6 @@ queue = "mosoo-stage-api-command" binding = "ENVIRONMENT_ARTIFACT_BUILD_QUEUE" queue = "mosoo-stage-environment-artifact-build" -[[env.stage.queues.producers]] -binding = "CHANNEL_FINAL_DELIVERY_QUEUE" -queue = "mosoo-stage-channel-final-delivery" - [[env.stage.queues.consumers]] queue = "mosoo-stage-api-command" max_batch_size = 10 @@ -247,13 +222,6 @@ max_retries = 5 max_concurrency = 5 dead_letter_queue = "mosoo-stage-api-command-dlq" -[[env.stage.queues.consumers]] -queue = "mosoo-stage-channel-final-delivery" -max_batch_size = 10 -max_batch_timeout = 5 -max_retries = 5 -dead_letter_queue = "mosoo-stage-channel-final-delivery-dlq" - [env.prod] name = "mosoo-api-prod" workers_dev = false @@ -283,7 +251,6 @@ FILE_BUCKET_NAME = "mosoo-file" BACKUP_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "false" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps.mosoo.ai" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "production" @@ -322,10 +289,6 @@ class_name = "DriverConnection" name = "Session" class_name = "Session" -[[env.prod.durable_objects.bindings]] -name = "ChannelConnection" -class_name = "ChannelConnection" - [[env.prod.durable_objects.bindings]] name = "Sandbox" class_name = "Sandbox" @@ -363,10 +326,6 @@ queue = "api-command" binding = "ENVIRONMENT_ARTIFACT_BUILD_QUEUE" queue = "environment-artifact-build" -[[env.prod.queues.producers]] -binding = "CHANNEL_FINAL_DELIVERY_QUEUE" -queue = "channel-final-delivery" - [[env.prod.queues.consumers]] queue = "api-command" max_batch_size = 10 @@ -391,13 +350,6 @@ max_retries = 5 max_concurrency = 5 dead_letter_queue = "api-command-dlq" -[[env.prod.queues.consumers]] -queue = "channel-final-delivery" -max_batch_size = 10 -max_batch_timeout = 5 -max_retries = 5 -dead_letter_queue = "channel-final-delivery-dlq" - [[migrations]] tag = "v1" new_sqlite_classes = ["DriverConnection", "Sandbox", "Session", "ChannelConnection"] diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index 438049df..caca9832 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -144,10 +144,10 @@ function AppSwitcher({ ); } -function NewAgentCta({ collapsed, disabled }: { collapsed: boolean; disabled: boolean }) { +function NewRunCta({ collapsed, disabled }: { collapsed: boolean; disabled: boolean }) { const { t } = useTranslation(); const className = cn("mb-4", collapsed ? "mx-auto size-9 p-0" : "w-full justify-center"); - const label = t("agent.create"); + const label = t("harnessMarketplace.newRun"); if (disabled) { return ( @@ -160,7 +160,7 @@ function NewAgentCta({ collapsed, disabled }: { collapsed: boolean; disabled: bo const cta = ( ))} - {canUseTerminal && ( - - )}
@@ -249,11 +219,6 @@ export function AgentDetailPage() { const basePath = globalThis.location.pathname.startsWith("/demo") ? "/demo/agent" : "/agent"; const runtime = useMemo(() => (agent ? getRuntimeInfo(agent.runtime) : null), [agent]); const canManageAgentAccess = detailQuery.data?.viewerRole === "owner"; - const canUseTerminal = canShowAgentDebugMenuItem({ - agentKind: agent?.kind ?? null, - itemId: "terminal", - viewerRole: detailQuery.data?.viewerRole ?? null, - }); const urlMode = toDetailMode(searchParams.get("tab") ?? searchParams.get("mode")); const handleSelectMode = useCallback( @@ -293,8 +258,7 @@ export function AgentDetailPage() { // via `?tab=consume` (e.g. the post-publish success modal's "Open Chat" CTA), // and the Preview tab offers an in-context test chat. const defaultMode: DetailMode = "preview"; - const requestedMode = selectedMode ?? urlMode ?? defaultMode; - const mode = requestedMode === "terminal" && !canUseTerminal ? defaultMode : requestedMode; + const mode = selectedMode ?? urlMode ?? defaultMode; if (!isTruthy(agentId)) { return ( @@ -369,7 +333,6 @@ export function AgentDetailPage() {
{ @@ -400,11 +363,6 @@ export function AgentDetailPage() { )} - {mode === "terminal" && ( - }> - - - )}
{showSettings ? ( diff --git a/apps/web/src/routes/agent/components/agent-kind-section.tsx b/apps/web/src/routes/agent/components/agent-kind-section.tsx deleted file mode 100644 index d5fd65fc..00000000 --- a/apps/web/src/routes/agent/components/agent-kind-section.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { ReactElement } from "react"; -import { useCallback, useState } from "react"; -import { useNavigate } from "react-router-dom"; - -import { createAgentFork } from "@/domains/agent/api/agent-client"; -import { agentKeys } from "@/domains/agent/query/agent-queries"; -import { toAgentId, toAppId } from "@/routes/typed-id"; - -import type { Agent, AgentKind } from "../agent.types"; -import { KindForkDialog } from "../lifecycle/kind-fork-dialog"; -import { KindSelector } from "./kind-selector"; - -// Encapsulates the Agent Type selector, comparison panel, lock hint, and Fork -// dialog. Mounted at the top of the Preview configuration panel. -// Lock state starts at first publish and stays locked after unpublish because -// the live version snapshot remains attached to the original agent. -export function AgentKindSection({ - agent, - onKindChange, -}: { - agent: Agent; - onKindChange?: ((kind: AgentKind) => void) | undefined; -}): ReactElement { - const locked = agent.status === "published" || agent.liveVersion !== null; - const canFork = agent.role === "owner"; - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const forkMutation = useMutation({ - mutationFn: createAgentFork, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); - }, - }); - const [forkDialog, setForkDialog] = useState<{ target: AgentKind } | null>(null); - - const handleKindChange = useCallback( - (next: AgentKind) => { - onKindChange?.(next); - }, - [onKindChange], - ); - - const handleLockedCardClick = useCallback( - (target: AgentKind) => { - if (!canFork) { - return; - } - setForkDialog({ target }); - }, - [canFork], - ); - - const handleClickFork = useCallback(() => { - setForkDialog({ target: agent.kind === "pet" ? "cattle" : "pet" }); - }, [agent.kind]); - - const handleForkCancel = useCallback(() => setForkDialog(null), []); - const handleForkConfirm = useCallback(async () => { - if (forkDialog === null) { - return; - } - - const result = await forkMutation.mutateAsync({ - agentId: toAgentId(agent.id), - kind: forkDialog.target, - appId: toAppId(agent.appId), - }); - - setForkDialog(null); - void navigate( - globalThis.location.pathname.startsWith("/demo") - ? `/demo/agent/${result.agent.id}` - : `/agent/${result.agent.id}`, - ); - }, [agent.appId, agent.id, forkDialog, forkMutation, navigate]); - - return ( - <> - - {forkDialog ? ( - void handleForkConfirm()} - /> - ) : null} - - ); -} diff --git a/apps/web/src/routes/agent/components/agent-session-panel-header.tsx b/apps/web/src/routes/agent/components/agent-session-panel-header.tsx index 01d1ebde..b05e18ec 100644 --- a/apps/web/src/routes/agent/components/agent-session-panel-header.tsx +++ b/apps/web/src/routes/agent/components/agent-session-panel-header.tsx @@ -1,4 +1,4 @@ -import { Plus, RotateCcw } from "lucide-react"; +import { Plus } from "lucide-react"; import type React from "react"; import { useTranslation } from "@/shared/i18n"; @@ -6,7 +6,6 @@ import { cn } from "@/shared/lib/class-names"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; -import type { SessionControlMode } from "./agent-session-panel-rules"; import { sessionIndicatorClassName } from "./agent-session-panel-status"; import type { SessionPill } from "./agent-session-panel-status"; @@ -32,7 +31,6 @@ export function AgentSessionPanelHeader({ onSessionControlClick, pill, reconnectingSubtitle, - sessionControlMode, sending, sessionCount, tone, @@ -42,15 +40,11 @@ export function AgentSessionPanelHeader({ onSessionControlClick: () => Promise; pill: SessionPill; reconnectingSubtitle: string | null; - sessionControlMode: SessionControlMode; sending: boolean; sessionCount: number; tone: "preview" | "consume"; }) { const { t } = useTranslation(); - const SessionControlIcon = sessionControlMode === "reset" ? RotateCcw : Plus; - const sessionControlLabel = - sessionControlMode === "reset" ? t("agent.resetChat") : t("agent.newSession"); return (
@@ -72,7 +66,7 @@ export function AgentSessionPanelHeader({ {activeTitle} ) : null}
- {sessionControlMode === "new_session" && sessionCount > 0 ? ( + {sessionCount > 0 ? ( {t("agent.sessionCount", { count: String(sessionCount) })} @@ -84,8 +78,8 @@ export function AgentSessionPanelHeader({ size="xs" variant="ghost" > - - {sessionControlLabel} + + {t("agent.newSession")}
); diff --git a/apps/web/src/routes/agent/components/agent-session-panel-model-types.ts b/apps/web/src/routes/agent/components/agent-session-panel-model-types.ts index fd4de199..b47e3ab0 100644 --- a/apps/web/src/routes/agent/components/agent-session-panel-model-types.ts +++ b/apps/web/src/routes/agent/components/agent-session-panel-model-types.ts @@ -46,7 +46,6 @@ export interface AgentSessionPanelModel { ensureActiveSession: () => Promise; fileInputRef: RefObject; handleKeyDown: (event: KeyboardEvent, options?: SendOptions) => Promise; - handleResetSession: () => Promise; handleSend: (options?: SendOptions) => Promise; handleStartNewSession: () => Promise; input: string; diff --git a/apps/web/src/routes/agent/components/agent-session-panel-rules.ts b/apps/web/src/routes/agent/components/agent-session-panel-rules.ts index f4829df2..cb0af880 100644 --- a/apps/web/src/routes/agent/components/agent-session-panel-rules.ts +++ b/apps/web/src/routes/agent/components/agent-session-panel-rules.ts @@ -2,9 +2,6 @@ import type { SessionLiveState } from "@mosoo/ag-ui-session"; import type { AgentReadiness } from "@mosoo/contracts/agent"; import type { SessionSummary, SessionType } from "@mosoo/contracts/session"; -export type AgentSessionPanelTone = "consume" | "preview"; -export type SessionControlMode = "new_session" | "reset"; - export interface SessionConfigurationFreshnessInput { activeSession: SessionSummary | null; activeSessionRevision: string | null; @@ -118,7 +115,7 @@ export interface SpeculativeSessionCreateInput { } // Speculatively creating a session on typing is preview-only: preview sessions -// are reset-scoped and cheap to abandon, while consume ("ui") sessions are +// are cheap to abandon, while consume ("ui") sessions are // user-visible history and must not be created before a real send. export function shouldSpeculativelyCreateSessionOnTyping( input: SpeculativeSessionCreateInput, @@ -133,10 +130,6 @@ export function shouldSpeculativelyCreateSessionOnTyping( ); } -export function getSessionControlMode(tone: AgentSessionPanelTone): SessionControlMode { - return tone === "preview" ? "reset" : "new_session"; -} - export function createSessionAutoTitle(typedText: string): string { return typedText.length > 30 ? `${typedText.slice(0, 27)}...` : typedText; } diff --git a/apps/web/src/routes/agent/components/agent-session-panel.tsx b/apps/web/src/routes/agent/components/agent-session-panel.tsx index 7825d674..776a40a9 100644 --- a/apps/web/src/routes/agent/components/agent-session-panel.tsx +++ b/apps/web/src/routes/agent/components/agent-session-panel.tsx @@ -25,7 +25,6 @@ import { Button } from "@/shared/ui/button"; import { isTruthy } from "../../../shared/lib/truthiness"; import { AgentReadinessBlockersBanner } from "./agent-readiness-blockers-banner"; import { AgentSessionPanelHeader } from "./agent-session-panel-header"; -import { getSessionControlMode } from "./agent-session-panel-rules"; import { deriveSessionPill, readinessBlockSummary, @@ -63,8 +62,6 @@ export function AgentSessionPanel({ }); const activeTitle = model.activeSession?.title ?? null; const pill = deriveSessionPill(model); - const sessionControlMode = getSessionControlMode(tone); - const previewResetMode = sessionControlMode === "reset"; const stopped = pill === "Stopped"; const setupBlocked = pill === "Setup required"; const setupSummary = readinessBlockSummary(model.readiness, t) ?? model.readinessBlockMessage; @@ -103,23 +100,7 @@ export function AgentSessionPanel({ }, ]; }); - const sessionLoadErrorMessage = previewResetMode - ? t("agent.failedToLoadPreviewChat") - : t("agent.failedToLoadSessions"); - const configurationRefreshMessage = previewResetMode - ? t("agent.resetChatToTestConfig") - : t("agent.startNewSessionToTestConfig"); - const configurationRefreshActionLabel = previewResetMode - ? t("agent.resetChat") - : t("agent.startNewSession"); - const stoppedActionLabel = previewResetMode ? t("agent.resetChat") : t("agent.newSession"); - const handleResetPreviewSession = async (): Promise => { - resourceDraft.clearActiveMentions(); - await model.handleResetSession(); - }; - const handleSessionControlClick = previewResetMode - ? handleResetPreviewSession - : model.handleStartNewSession; + const handleSessionControlClick = model.handleStartNewSession; const handleUploadFiles = async (files: File[]): Promise => { if (files.length === 0) { @@ -200,7 +181,6 @@ export function AgentSessionPanel({ onSessionControlClick={handleSessionControlClick} pill={pill} reconnectingSubtitle={reconnectingSubtitle} - sessionControlMode={sessionControlMode} sending={model.sending} sessionCount={model.sessionCount} tone={tone} @@ -208,7 +188,7 @@ export function AgentSessionPanel({ {isTruthy(model.sessionLoadError) ? (
- {sessionLoadErrorMessage} + {t("agent.failedToLoadSessions")}
) : null} @@ -216,14 +196,14 @@ export function AgentSessionPanel({
- {configurationRefreshMessage} + {t("agent.startNewSessionToTestConfig")}
@@ -259,7 +239,7 @@ export function AgentSessionPanel({ size="sm" variant="outline" > - {stoppedActionLabel} + {t("agent.newSession")}
diff --git a/apps/web/src/routes/agent/components/channel-webhook-origin.ts b/apps/web/src/routes/agent/components/channel-webhook-origin.ts deleted file mode 100644 index 0f65860f..00000000 --- a/apps/web/src/routes/agent/components/channel-webhook-origin.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MOSOO_CONSOLE_ORIGIN } from "@mosoo/contracts/origin"; - -function readViteOriginOverride(): string | null { - const value = import.meta.env.VITE_CHANNEL_WEBHOOK_ORIGIN; - - if (value === undefined) { - return null; - } - - const trimmed = value.trim(); - return trimmed.length === 0 ? null : trimmed; -} - -/** - * Origin used when rendering channel webhook URLs (Slack request_url, Lark / - * Telegram webhook callback, etc.) for operators to paste into the channel - * provider. Production browses the same hostname that receives webhooks, so - * `globalThis.location.origin` already returns the correct value. Local dev - * is reached over a Cloudflare Tunnel — Slack et al. cannot dial - * `http://localhost:5173` — so `VITE_CHANNEL_WEBHOOK_ORIGIN` can override the - * browse origin with the tunnel hostname. - */ -export function resolveChannelWebhookOrigin(): string { - const override = readViteOriginOverride(); - - if (override !== null) { - return override; - } - - if (typeof globalThis.location === "undefined") { - return MOSOO_CONSOLE_ORIGIN; - } - - return globalThis.location.origin; -} diff --git a/apps/web/src/routes/agent/components/channels-config-dialog.tsx b/apps/web/src/routes/agent/components/channels-config-dialog.tsx deleted file mode 100644 index ebab28ba..00000000 --- a/apps/web/src/routes/agent/components/channels-config-dialog.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; - -import { deleteAgentChannelBinding } from "@/domains/agent/api/agent-client"; -import { agentKeys, useAgentChannelBindingsQuery } from "@/domains/agent/query/agent-queries"; -import { toChannelBindingId, toAppId } from "@/routes/typed-id"; -import { Dialog, DialogContent } from "@/shared/ui/dialog"; - -import type { Agent } from "../agent.types"; -import { AgentSettingsChannelsView } from "./settings-dialog-channels-view"; -import type { ChannelId } from "./settings-dialog-model"; - -/** - * Standalone Channels configuration dialog — same provider list / detail body - * as the Settings modal flow, but lives in its own Dialog instance so it can be - * opened from anywhere outside the Settings modal (e.g. the Preview - * configuration panel). - * - * No "Back to Settings" arrow: closing the dialog is the only way out. - */ -export function ChannelsConfigDialog({ - agent, - initialChannelId, - onOpenChange, - open, -}: { - agent: Agent; - initialChannelId: ChannelId; - onOpenChange: (open: boolean) => void; - open: boolean; -}) { - const queryClient = useQueryClient(); - const [selectedChannelId, setSelectedChannelId] = useState(initialChannelId); - - const channelBindingsQuery = useAgentChannelBindingsQuery(agent.appId, agent.id); - const deleteChannelBindingMutation = useMutation({ - mutationFn: deleteAgentChannelBinding, - onSuccess: async () => - queryClient.invalidateQueries({ - queryKey: agentKeys.channelBindings(agent.appId, agent.id), - }), - }); - - const canManageChannels = agent.role === "owner"; - const isPublished = agent.status === "published"; - const pendingRemoveBindingId = deleteChannelBindingMutation.isPending - ? deleteChannelBindingMutation.variables.bindingId - : null; - - return ( - - - - deleteChannelBindingMutation.mutateAsync({ - bindingId: toChannelBindingId(bindingId), - appId: toAppId(agent.appId), - }) - } - onSelectChannel={(channelId) => { - setSelectedChannelId(channelId); - }} - pendingRemoveBindingId={pendingRemoveBindingId} - selectedChannelId={selectedChannelId} - /> - - - ); -} diff --git a/apps/web/src/routes/agent/components/channels-field.tsx b/apps/web/src/routes/agent/components/channels-field.tsx deleted file mode 100644 index 1a2dbe23..00000000 --- a/apps/web/src/routes/agent/components/channels-field.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useState } from "react"; - -import { useAgentChannelBindingsQuery } from "@/domains/agent/query/agent-queries"; - -import type { Agent } from "../agent.types"; -import { ChannelsConfigDialog } from "./channels-config-dialog"; -import { ChannelsListWidget } from "./channels-list-widget"; -import type { ChannelId } from "./settings-dialog-model"; - -/** - * Channels field for the Preview configuration panel. - * Renders the same list widget as the Settings modal Distribution block, - * and opens the LobeHub-style list/detail config in a standalone dialog - * when a row is clicked. - */ -export function AgentChannelsField({ agent }: { agent: Agent }) { - const [activeChannelId, setActiveChannelId] = useState(null); - const channelBindingsQuery = useAgentChannelBindingsQuery(agent.appId, agent.id); - const canManageChannels = agent.role === "owner"; - const isPublished = agent.status === "published"; - - return ( - <> - { - setActiveChannelId(channelId); - }} - /> - {activeChannelId !== null ? ( - { - if (!open) { - setActiveChannelId(null); - } - }} - open={true} - /> - ) : null} - - ); -} diff --git a/apps/web/src/routes/agent/components/channels-list-widget.tsx b/apps/web/src/routes/agent/components/channels-list-widget.tsx deleted file mode 100644 index 0f88a754..00000000 --- a/apps/web/src/routes/agent/components/channels-list-widget.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { ChevronRight, CircleCheck } from "lucide-react"; - -import type { AgentChannelBindingFieldsFragment } from "@/gql/graphql"; -import { useTranslation } from "@/shared/i18n"; -import { ChannelBrandIcon } from "@/shared/ui/channel-brand-icon"; - -import { DISTRIBUTION_CHANNELS } from "./settings-dialog-model"; -import type { ChannelId } from "./settings-dialog-model"; - -/** - * Compact list of channel provider rows. - * Used by: - * - Settings modal Distribution block - * - Preview configuration panel Channels section - * - * Click anywhere on a row → `onOpenChannelView(channelId)`. The parent decides - * whether that opens the in-modal channels view or a standalone dialog. - */ -export function ChannelsListWidget({ - canManageChannels, - channelBindings, - isPublished, - onOpenChannelView, -}: { - canManageChannels: boolean; - channelBindings: AgentChannelBindingFieldsFragment[]; - isPublished: boolean; - onOpenChannelView: (channelId: ChannelId) => void; -}) { - const { t } = useTranslation(); - return ( -
    - {DISTRIBUTION_CHANNELS.map((channel) => { - const connected = - channel.enabled && channelBindings.some((binding) => binding.provider === channel.id); - const statusLabel = !channel.enabled - ? t("agent.soon") - : connected - ? t("agent.connected") - : !isPublished - ? t("agent.publishFirstBadge") - : !canManageChannels - ? t("agent.view") - : t("agent.notConnected"); - - return ( -
  • - -
  • - ); - })} -
- ); -} diff --git a/apps/web/src/routes/agent/components/create-agent-launcher.tsx b/apps/web/src/routes/agent/components/create-agent-launcher.tsx index f0277b37..1643eaff 100644 --- a/apps/web/src/routes/agent/components/create-agent-launcher.tsx +++ b/apps/web/src/routes/agent/components/create-agent-launcher.tsx @@ -101,7 +101,7 @@ function CreateAgentLauncherBody({ try { const createdAgent = await createAgentMutation.mutateAsync({ - kind: "pet", + kind: "cattle", model: runtimeConfig.model, name: trimmedName, appId, diff --git a/apps/web/src/routes/agent/components/editor/environment-picker-policy.ts b/apps/web/src/routes/agent/components/editor/environment-picker-policy.ts deleted file mode 100644 index 2c0099c2..00000000 --- a/apps/web/src/routes/agent/components/editor/environment-picker-policy.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AgentKind } from "@mosoo/contracts/agent"; -import type { EnvironmentNetworkPolicy } from "@mosoo/contracts/environment"; - -export const ASSISTANT_LIMITED_ENVIRONMENT_REASON = "agentEditor.assistantLimitedReason"; - -export function getEnvironmentSelectionBlockReason( - input: { - kind: AgentKind; - networkPolicy: EnvironmentNetworkPolicy; - }, - t: (key: string) => string = (key) => key, -): string | null { - return input.kind === "pet" && input.networkPolicy === "limited" - ? t(ASSISTANT_LIMITED_ENVIRONMENT_REASON) - : null; -} diff --git a/apps/web/src/routes/agent/components/editor/environment-picker.tsx b/apps/web/src/routes/agent/components/editor/environment-picker.tsx index 536f1a75..df300f04 100644 --- a/apps/web/src/routes/agent/components/editor/environment-picker.tsx +++ b/apps/web/src/routes/agent/components/editor/environment-picker.tsx @@ -11,20 +11,14 @@ import { useTranslation } from "@/shared/i18n"; import { cn } from "@/shared/lib/class-names"; import { Label } from "@/shared/ui/label"; -import { - ASSISTANT_LIMITED_ENVIRONMENT_REASON, - getEnvironmentSelectionBlockReason, -} from "./environment-picker-policy"; import { describeEnvironment } from "./environment-summary"; import type { AgentEditorModel } from "./use-model"; function EnvironmentOption({ - disabled, environment, selected, onSelect, }: { - disabled: boolean; environment: EnvironmentSummary; selected: boolean; onSelect: () => void; @@ -35,9 +29,7 @@ function EnvironmentOption({ className={cn( "flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left transition-colors", selected ? "bg-ink-100 text-fg-1" : "hover:bg-accent/50", - disabled ? "cursor-not-allowed opacity-55" : null, )} - disabled={disabled} onClick={onSelect} type="button" > @@ -57,11 +49,6 @@ function EnvironmentOption({
{describeEnvironment(environment, t)}
- {disabled ? ( -
- {t(ASSISTANT_LIMITED_ENVIRONMENT_REASON)} -
- ) : null} ); @@ -79,7 +66,6 @@ export function EnvironmentPicker({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [selectionNotice, setSelectionNotice] = useState(null); const activeAppId = appId !== null && appId !== "" ? appId : null; const environmentsQuery = useAppEnvironmentsQuery(activeAppId); const environments = environmentsQuery.data ?? []; @@ -92,17 +78,6 @@ export function EnvironmentPicker({ ? (environments.find((environment) => environment.isDefault) ?? null) : (environments.find((environment) => environment.id === explicitEnvironmentId) ?? null); const selectedEnvironmentMissing = explicitEnvironmentId !== null && selectedEnvironment === null; - const selectedEnvironmentUnsupported = - selectedEnvironment === null - ? false - : getEnvironmentSelectionBlockReason( - { - kind: model.draft.kind, - networkPolicy: selectedEnvironment.networkPolicy, - }, - t, - ) !== null; - return (
- - {showChannels ? ( -
- {t("agent.channels")} - -
- ) : null} ); } diff --git a/apps/web/src/routes/agent/components/editor/form-view.tsx b/apps/web/src/routes/agent/components/editor/form-view.tsx index 8dfde736..e7875248 100644 --- a/apps/web/src/routes/agent/components/editor/form-view.tsx +++ b/apps/web/src/routes/agent/components/editor/form-view.tsx @@ -28,7 +28,6 @@ export interface AgentFormViewProps { highlightedSections?: ReadonlySet | null; model: AgentEditorModel; readOnly?: boolean; - showChannels?: boolean; } interface AgentFormViewBodyProps { @@ -37,7 +36,6 @@ interface AgentFormViewBodyProps { highlightedSections: ReadonlySet | null; model: AgentEditorModel; readOnly: boolean; - showChannels: boolean; } // Focus and highlight props support external section navigation. @@ -47,7 +45,6 @@ export function AgentFormView({ highlightedSections = null, model, readOnly = false, - showChannels = false, }: AgentFormViewProps): ReactElement { return ( ); } @@ -100,7 +96,6 @@ function AgentFormBody({ highlightedSections, model, readOnly, - showChannels, }: AgentFormViewBodyProps): ReactElement { const { activeRings, setSectionRef } = useSectionNavigation({ focusSection, @@ -142,12 +137,7 @@ function AgentFormBody({ setSectionRef("environment", node); }} > - + ); diff --git a/apps/web/src/routes/agent/components/editor/use-model.ts b/apps/web/src/routes/agent/components/editor/use-model.ts index 9e0dc9e9..2c687718 100644 --- a/apps/web/src/routes/agent/components/editor/use-model.ts +++ b/apps/web/src/routes/agent/components/editor/use-model.ts @@ -7,22 +7,11 @@ import { normalizeRuntimeAdvancedSettings } from "@mosoo/runtime-catalog"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { - recreateSandbox, - restartDriver, - updateAgentConfig, -} from "@/domains/agent/api/agent-client"; +import { updateAgentConfig } from "@/domains/agent/api/agent-client"; import { agentKeys } from "@/domains/agent/query/agent-queries"; -import { - toAgentId, - toAgentDeploymentVersionId, - toEnvironmentId, - toMcpServerId, - toAppId, - toSkillId, -} from "@/routes/typed-id"; +import { toAgentId, toEnvironmentId, toMcpServerId, toAppId, toSkillId } from "@/routes/typed-id"; -import type { Agent, AgentKind, McpServer, RuntimeId, SkillInfo } from "../../agent.types"; +import type { Agent, McpServer, RuntimeId, SkillInfo } from "../../agent.types"; import { createEditorSaveSnapshot, createInitialDraft, @@ -36,20 +25,6 @@ import type { AgentFormSectionId } from "./section-ids"; export type { AgentEditorDraft } from "./draft"; -function toRuntimeOperationTargetVersion(agent: { - liveVersion: { id: string; versionNumber: number } | null; - status: string; -}) { - if (agent.status !== "published" || agent.liveVersion === null) { - return null; - } - - return { - id: toAgentDeploymentVersionId(agent.liveVersion.id), - versionNumber: agent.liveVersion.versionNumber, - }; -} - export interface AgentEditorModel { draft: AgentEditorDraft; changePlan: AgentConfigChangePlan; @@ -64,7 +39,6 @@ export interface AgentEditorModel { setBuiltInTools(tools: AgentBuiltInToolConfig[]): void; setDescription(description: string): void; setEnvironmentId(environmentId: string | null): void; - setKind(kind: AgentKind): void; setMcpServers(servers: McpServer[]): void; setModel(model: string): void; setModelSelection(selection: { model: string; provider: string }): void; @@ -112,33 +86,13 @@ export function useAgentEditorModel({ ]); }, }); - const restartDriverMutation = useMutation({ - mutationFn: restartDriver, - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: agentKeys.detail(variables.appId, variables.agentId), - }); - }, - }); - const recreateSandboxMutation = useMutation({ - mutationFn: recreateSandbox, - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: agentKeys.detail(variables.appId, variables.agentId), - }); - }, - }); - const dirty = createEditorSaveSnapshot(draft) !== savedSnapshot; const changePlan = classifyAgentConfigChanges({ agentStatus: agent.status, current: toAgentConfigChangeSnapshot(draft), saved: toAgentConfigChangeSnapshot(savedDraft), }); - const saving = - configMutation.isPending || - restartDriverMutation.isPending || - recreateSandboxMutation.isPending; + const saving = configMutation.isPending; function updateDraft(transform: (current: AgentEditorDraft) => AgentEditorDraft) { setDraft((current) => transform(current)); @@ -147,7 +101,7 @@ export function useAgentEditorModel({ async function persistDraft( draftToSave: AgentEditorDraft, - options: { runRuntimeOperations: boolean }, + _options: { runRuntimeOperations: boolean }, ): Promise<{ error: string | null; ok: boolean }> { if (readOnly) { return { error: null, ok: false }; @@ -190,7 +144,7 @@ export function useAgentEditorModel({ setSaveError(null); try { - const savedAgent = await configMutation.mutateAsync({ + await configMutation.mutateAsync({ agentId: typedAgentId, builtInTools: normalizeAgentBuiltInTools(draftToSave.builtInTools), description: draftToSave.description.trim() || null, @@ -213,31 +167,6 @@ export function useAgentEditorModel({ skill.state === "tombstone" ? [] : [toSkillId(skill.id)], ), }); - const targetVersion = toRuntimeOperationTargetVersion(savedAgent); - - if (options.runRuntimeOperations && draftChangePlan.requiresRuntimeOperation) { - if (draftChangePlan.action === "recreate-preserving-state") { - await recreateSandboxMutation.mutateAsync({ - affectedFields: draftChangePlan.fieldLabels, - agentId: typedAgentId, - applyActionKind: "recreate-preserving-state", - appId: typedAppId, - targetVersion, - }); - } else if ( - draftChangePlan.action === "patch-and-restart" || - draftChangePlan.action === "restart-process" - ) { - await restartDriverMutation.mutateAsync({ - affectedFields: draftChangePlan.fieldLabels, - agentId: typedAgentId, - applyActionKind: draftChangePlan.action, - appId: typedAppId, - targetVersion, - }); - } - } - setSavedDraft(draftToSave); setSavedSnapshot(createEditorSaveSnapshot(draftToSave)); return { error: null, ok: true }; @@ -289,12 +218,6 @@ export function useAgentEditorModel({ setEnvironmentId(environmentId) { updateDraft((current) => withEnvironmentId(current, environmentId)); }, - setKind(kind) { - updateDraft((current) => ({ - ...current, - kind, - })); - }, setMcpServers(servers) { updateDraft((current) => ({ ...current, diff --git a/apps/web/src/routes/agent/components/kind-selector.tsx b/apps/web/src/routes/agent/components/kind-selector.tsx deleted file mode 100644 index e922e9cd..00000000 --- a/apps/web/src/routes/agent/components/kind-selector.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { - listAgentKindRuntimeComparisonRows, - listAgentKindRuntimePolicies, -} from "@mosoo/contracts/agent"; -import type { AgentKind } from "@mosoo/contracts/agent"; -import { ChevronDown, Bot, Zap, Lock, Sparkles, Layers, AlertTriangle, Target } from "lucide-react"; -import { useState } from "react"; - -import { useTranslation } from "@/shared/i18n"; -import { cn } from "@/shared/lib/class-names"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; - -const CARDS = listAgentKindRuntimePolicies(); -const COMPARE_ROWS = listAgentKindRuntimeComparisonRows(); - -const CARD_ICONS = { - cattle: Zap, - pet: Bot, -} as const satisfies Record; - -const COMPARE_ICONS = { - best_for: Target, - cross_session_memory: Sparkles, - failure_pattern: AlertTriangle, - scaling: Layers, - switch_cost: Lock, -} as const satisfies Record<(typeof COMPARE_ROWS)[number]["id"], typeof Sparkles>; - -const CARD_COPY_KEYS = { - cattle: { - description: "agent.kindTaskDescription", - examples: "agent.kindTaskExamples", - label: "agent.taskAgent", - tagline: "agent.kindTaskTagline", - }, - pet: { - description: "agent.kindAssistantDescription", - examples: "agent.kindAssistantExamples", - label: "agent.assistantAgent", - tagline: "agent.kindAssistantTagline", - }, -} as const satisfies Record< - AgentKind, - Record<"description" | "examples" | "label" | "tagline", string> ->; - -const COMPARISON_COPY_KEYS = { - best_for: { - cattle: "agent.kindComparisonBestForTask", - label: "agent.kindComparisonBestFor", - pet: "agent.kindComparisonBestForAssistant", - }, - cross_session_memory: { - cattle: "agent.kindComparisonMemoryTask", - label: "agent.kindComparisonMemory", - pet: "agent.kindComparisonMemoryAssistant", - }, - failure_pattern: { - cattle: "agent.kindComparisonFailureTask", - label: "agent.kindComparisonFailure", - pet: "agent.kindComparisonFailureAssistant", - }, - scaling: { - cattle: "agent.kindComparisonScalingTask", - label: "agent.kindComparisonScaling", - pet: "agent.kindComparisonScalingAssistant", - }, - switch_cost: { - cattle: "agent.kindComparisonSwitchCostTask", - label: "agent.kindComparisonSwitchCost", - pet: "agent.kindComparisonSwitchCostAssistant", - }, -} as const satisfies Record< - (typeof COMPARE_ROWS)[number]["id"], - Record ->; - -export function KindSelector({ - value, - locked, - canFork = false, - onChange, - onFork, - onLockedCardClick, -}: { - value: AgentKind; - locked: boolean; - canFork?: boolean; - onChange: (kind: AgentKind) => void; - onFork?: () => void; - onLockedCardClick?: (target: AgentKind) => void; -}) { - const { t } = useTranslation(); - const [compareOpen, setCompareOpen] = useState(false); - - return ( -
-
-
-

{t("agent.agentType")}

-

- {locked ? ( - <> - {t("agent.typeLocked")}{" "} - {canFork && onFork ? ( - - ) : ( - t("agent.contactOwnerToFork") - )} - - ) : ( - t("agent.chooseAgentType") - )} -

-
- -
- {CARDS.map((card) => { - const Icon = CARD_ICONS[card.kind]; - const copyKeys = CARD_COPY_KEYS[card.kind]; - const selected = value === card.kind; - const isLockedAlternative = locked && !selected; - - return ( - - - - - -
{t(copyKeys.label)}
-
- {t(copyKeys.tagline)} -
-

{t(copyKeys.description)}

-

{t(copyKeys.examples)}

- {isLockedAlternative ? ( -

- {t("agent.forkToSwitchType")} -

- ) : null} -
-
- ); - })} -
-
- - - - {compareOpen ? ( -
- - - - - - - - - - {COMPARE_ROWS.map((row) => { - const rowId = row.id as keyof typeof COMPARISON_COPY_KEYS; - const Icon = COMPARE_ICONS[rowId]; - const copyKeys = COMPARISON_COPY_KEYS[rowId]; - return ( - - - - - - ); - })} - -
{t("agent.dimension")} - {t("agent.assistantAgent")} - {t("agent.taskAgent")}
-
- - {t(copyKeys.label)} -
-
{t(copyKeys.pet)}{t(copyKeys.cattle)}
-
- ) : null} -
- ); -} diff --git a/apps/web/src/routes/agent/components/preview-mode.tsx b/apps/web/src/routes/agent/components/preview-mode.tsx index 24756563..2efdb0e0 100644 --- a/apps/web/src/routes/agent/components/preview-mode.tsx +++ b/apps/web/src/routes/agent/components/preview-mode.tsx @@ -13,12 +13,9 @@ import type { LifecycleActionKind } from "../lifecycle/live-config-action-dialog import { PendingChangesBanner } from "../lifecycle/pending-changes-banner"; import { PublishMenu } from "../lifecycle/publish-menu"; import { PublishSuccessModal } from "../lifecycle/publish-success-modal"; -import { AgentKindSection } from "./agent-kind-section"; -import { ChannelsConfigDialog } from "./channels-config-dialog"; import { AgentFormView } from "./editor/form-view"; import { useAgentEditorAutoSave } from "./editor/use-auto-save"; import { useAgentEditorModel } from "./editor/use-model"; -import type { ChannelId } from "./settings-dialog-model"; const AgentSessionPanel = lazy(async () => { const mod = await import("./agent-session-panel"); @@ -35,7 +32,6 @@ interface PublishStatusMessage { interface PreviewModeState { apiAccessDialogOpen: boolean; appliedKind: AppliedToastKind | null; - channelsDialogOpen: boolean; discardCounter: number; showAppliedToast: boolean; showSuccessModal: boolean; @@ -46,14 +42,11 @@ type PreviewModeAction = | { type: "discarded" } | { type: "setApiAccessDialogOpen"; open: boolean } | { type: "setAppliedToast"; open: boolean } - | { type: "setChannelsDialogOpen"; open: boolean } | { type: "setSuccessModalOpen"; open: boolean }; -const DEFAULT_CHANNEL_ID: ChannelId = "slack"; const PREVIEW_MODE_INITIAL_STATE: PreviewModeState = { apiAccessDialogOpen: false, appliedKind: null, - channelsDialogOpen: false, discardCounter: 0, showAppliedToast: false, showSuccessModal: false, @@ -102,8 +95,6 @@ function previewModeReducer(state: PreviewModeState, action: PreviewModeAction): return { ...state, apiAccessDialogOpen: action.open }; case "setAppliedToast": return { ...state, showAppliedToast: action.open }; - case "setChannelsDialogOpen": - return { ...state, channelsDialogOpen: action.open }; case "setSuccessModalOpen": return { ...state, showSuccessModal: action.open }; } @@ -116,14 +107,8 @@ export function PreviewMode({ agent, headerActionTarget }: PreviewModeProps): Re const model = useAgentEditorModel({ agent }); useAgentEditorAutoSave(model); const [state, dispatch] = useReducer(previewModeReducer, PREVIEW_MODE_INITIAL_STATE); - const { - apiAccessDialogOpen, - appliedKind, - channelsDialogOpen, - discardCounter, - showAppliedToast, - showSuccessModal, - } = state; + const { apiAccessDialogOpen, appliedKind, discardCounter, showAppliedToast, showSuccessModal } = + state; useEffect(() => { let timer: ReturnType | null = null; @@ -189,9 +174,6 @@ export function PreviewMode({ agent, headerActionTarget }: PreviewModeProps): Re onApiAccessClick={() => { dispatch({ open: true, type: "setApiAccessDialogOpen" }); }} - onChannelClick={() => { - dispatch({ open: true, type: "setChannelsDialogOpen" }); - }} onPublish={() => { publishMutation.mutate(); }} @@ -241,7 +223,6 @@ export function PreviewMode({ agent, headerActionTarget }: PreviewModeProps): Re data-agent-editor-scroll >
-
@@ -264,17 +245,6 @@ export function PreviewMode({ agent, headerActionTarget }: PreviewModeProps): Re open={apiAccessDialogOpen} /> ) : null} - {channelsDialogOpen ? ( - { - dispatch({ open, type: "setChannelsDialogOpen" }); - }} - open={channelsDialogOpen} - /> - ) : null} - { @@ -303,9 +273,6 @@ function appliedToastText(kind: AppliedToastKind): string { case "fork-agent": { return "New Agent forked with the new runtime"; } - case "reset-agent-state": { - return "agent-state cleared"; - } default: { return unreachableCase(kind, "Unsupported applied toast kind."); } diff --git a/apps/web/src/routes/agent/components/settings-dialog-channel-agent.ts b/apps/web/src/routes/agent/components/settings-dialog-channel-agent.ts deleted file mode 100644 index 0000a313..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-channel-agent.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AgentStatus } from "@mosoo/contracts/agent"; - -export interface ChannelInlineSetupAgent { - id: string; - name: string; - appId: string; - status: AgentStatus; -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-channels-view.tsx b/apps/web/src/routes/agent/components/settings-dialog-channels-view.tsx deleted file mode 100644 index 3e82dc99..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-channels-view.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; -import { - Activity, - ArrowLeft, - Check, - CircleCheck, - Copy, - Inbox, - Plug, - Trash2, - TriangleAlert, -} from "lucide-react"; -import { useState } from "react"; - -import type { AgentChannelBindingFieldsFragment } from "@/gql/graphql"; -import { toChannelBindingId } from "@/routes/typed-id"; -import { getCurrentLocale, useTranslation } from "@/shared/i18n"; -import { cn } from "@/shared/lib/class-names"; -import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { ChannelBrandIcon } from "@/shared/ui/channel-brand-icon"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; - -import { resolveChannelWebhookOrigin } from "./channel-webhook-origin"; -import type { ChannelInlineSetupAgent } from "./settings-dialog-channel-agent"; -import { DiscordChannelInlineSetup } from "./settings-dialog-discord-setup"; -import { LarkChannelInlineSetup } from "./settings-dialog-lark-setup"; -import { DISTRIBUTION_CHANNELS } from "./settings-dialog-model"; -import type { ChannelId } from "./settings-dialog-model"; -import { SlackChannelInlineSetup } from "./settings-dialog-slack-setup"; -import { TelegramChannelInlineSetup } from "./settings-dialog-telegram-setup"; -import { WeChatChannelInlineSetup } from "./settings-dialog-wechat-setup"; - -function readMetadataString( - binding: AgentChannelBindingFieldsFragment, - key: string, -): string | null { - const value = binding.displayMetadata[key]; - const trimmed = typeof value === "string" ? value.trim() : ""; - - return trimmed.length > 0 ? trimmed : null; -} - -function formatTimestamp(value: string): string { - return new Intl.DateTimeFormat(getCurrentLocale(), { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(value)); -} - -function getOperatorWebhookUrl(channelId: ChannelId, bindingId: string): string | null { - const origin = resolveChannelWebhookOrigin(); - - switch (channelId) { - case "slack": - return buildAgentChannelWebhookUrl({ origin, provider: channelId }); - case "lark": - case "telegram": - return buildAgentChannelWebhookUrl({ - bindingId: toChannelBindingId(bindingId), - origin, - provider: channelId, - }); - case "discord": - case "wechat": - return null; - default: { - const exhaustiveChannelId: never = channelId; - return exhaustiveChannelId; - } - } -} - -function getDiscordBindingErrorCopy( - errorCode: string, - t: (key: string, variables?: Record) => string, -): string | null { - if (errorCode === "discord_gateway_disallowed_intents") { - return t("agent.discordIntentError"); - } - - if (errorCode === "discord_gateway_authentication_failed") { - return t("agent.discordTokenError"); - } - - return null; -} - -function getTelegramBotDeepLink(botUsername: string | null): string | null { - const normalized = botUsername?.trim().replace(/^@/u, "") ?? ""; - - return normalized ? `https://t.me/${encodeURIComponent(normalized)}` : null; -} - -interface ConnectionSummary { - detailHref?: string | null; - detailIconLabel: string; - detailTitle: string; - detailValue: string | null; -} - -function getConnectionSummary( - binding: AgentChannelBindingFieldsFragment, - channelId: ChannelId, - t: (key: string, variables?: Record) => string, -): ConnectionSummary { - if (channelId === "slack") { - return { - detailIconLabel: t("agent.workspace"), - detailTitle: - readMetadataString(binding, "workspace_name") ?? - binding.externalTenantId ?? - t("agent.slackWorkspace"), - detailValue: readMetadataString(binding, "bot_handle") ?? binding.externalBotId, - }; - } - - if (channelId === "lark") { - const domain = readMetadataString(binding, "domain"); - const domainLabel = domain === "lark" ? "Lark" : "Feishu"; - - return { - detailIconLabel: t("agent.app"), - detailTitle: readMetadataString(binding, "app_name") ?? binding.externalTenantId, - detailValue: `${domainLabel} / ${readMetadataString(binding, "bot_open_id") ?? binding.externalBotId}`, - }; - } - - if (channelId === "telegram") { - const username = readMetadataString(binding, "bot_username"); - return { - detailHref: getTelegramBotDeepLink(username), - detailIconLabel: t("agent.bot"), - detailTitle: username - ? `@${username}` - : (readMetadataString(binding, "bot_first_name") ?? t("agent.telegramBot")), - detailValue: binding.externalBotId, - }; - } - - return { - detailIconLabel: t("agent.channel"), - detailTitle: binding.externalTenantId, - detailValue: binding.externalBotId, - }; -} - -function ChannelConnectionPanel({ - binding, - canManageChannels, - channelId, - channelLabel, - onRemove, - pendingRemove, -}: { - binding: AgentChannelBindingFieldsFragment; - canManageChannels: boolean; - channelId: ChannelId; - channelLabel: string; - onRemove: () => void; - pendingRemove: boolean; -}) { - const { t } = useTranslation(); - const [copiedWebhook, setCopiedWebhook] = useState(false); - const summary = getConnectionSummary(binding, channelId, t); - const statusTone = - binding.status === "active" - ? "border-green-200 bg-green-50 text-green-800" - : "border-amber/30 bg-amber-bg text-amber-fg"; - const webhookUrl = getOperatorWebhookUrl(channelId, binding.id); - - async function handleCopyWebhook() { - if (!webhookUrl) { - return; - } - - await navigator.clipboard.writeText(webhookUrl); - setCopiedWebhook(true); - globalThis.setTimeout(() => { - setCopiedWebhook(false); - }, 1500); - } - - return ( -
-
-
{t("agent.connection")}
- - {binding.status === "active" ? ( - - ) : ( - - )} - {binding.status === "active" ? t("agent.active") : t("common.error")} - -
-
-
-
- - {summary.detailIconLabel} -
-
- {summary.detailHref ? ( - - {summary.detailTitle} - - ) : ( - summary.detailTitle - )} -
-
- {summary.detailValue} -
-
- -
-
- - {t("agent.activity")} -
-
- {binding.activityLastTriggeredAt - ? formatTimestamp(binding.activityLastTriggeredAt) - : t("agent.noChannelSessions")} -
-
- {t("agent.sessionsInLast7Days", { count: String(binding.activitySessionCount7d) })} -
-
-
- {webhookUrl ? ( -
-
- {t("agent.webhookUrl")} -
-
- - {webhookUrl} - - -
-
- ) : null} - {binding.status === "error" && binding.lastErrorCode ? ( -
- {channelId === "discord" - ? (getDiscordBindingErrorCopy(binding.lastErrorCode, t) ?? - t("agent.deliveryFailedRecover", { - channel: channelLabel, - code: binding.lastErrorCode, - })) - : t("agent.deliveryFailedRecover", { - channel: channelLabel, - code: binding.lastErrorCode, - })} -
- ) : null} - {canManageChannels ? ( -
- -
- ) : null} -
- ); -} - -function ComingSoonPanel({ channelLabel }: { channelLabel: string }) { - const { t } = useTranslation(); - return ( -
- -
- {t("agent.supportComing", { channel: channelLabel })} -
-

- {t("agent.supportComingDescription", { channel: channelLabel })} -

-
- ); -} - -export function AgentSettingsChannelsView({ - agent, - canManageChannels, - channelBindings, - channelBindingsLoading, - isPublished, - onBackToSettings, - onRemoveChannelBinding, - onSelectChannel, - pendingRemoveBindingId, - selectedChannelId, -}: { - agent: ChannelInlineSetupAgent; - canManageChannels: boolean; - channelBindings: AgentChannelBindingFieldsFragment[]; - channelBindingsLoading: boolean; - isPublished: boolean; - onBackToSettings?: () => void; - onRemoveChannelBinding: (bindingId: string) => Promise | void; - onSelectChannel: (channelId: ChannelId) => void; - pendingRemoveBindingId: string | null; - selectedChannelId: ChannelId; -}) { - const { t } = useTranslation(); - const selectedChannel = DISTRIBUTION_CHANNELS.find((channel) => channel.id === selectedChannelId); - const selectedBinding = - channelBindings.find((binding) => binding.provider === selectedChannelId) ?? null; - const [confirmRemoveBinding, setConfirmRemoveBinding] = - useState(null); - - if (!selectedChannel) { - return null; - } - - return ( - <> - -
- {onBackToSettings ? ( - - ) : null} - {t("agent.channels")} -
- {t("agent.connectChannels", { name: agent.name })} -
- -
- - -
-
- -
{selectedChannel.label}
- {selectedBinding ? ( - {t("agent.connected")} - ) : !selectedChannel.enabled ? ( - {t("agent.soon")} - ) : null} -
- - {!selectedChannel.enabled ? ( - - ) : channelBindingsLoading ? ( -
{t("common.loading")}
- ) : selectedBinding ? ( - { - setConfirmRemoveBinding(selectedBinding); - }} - pendingRemove={pendingRemoveBindingId === selectedBinding.id} - /> - ) : !isPublished ? ( -
- {t("agent.publishBeforeConnectChannel", { channel: selectedChannel.label })} -
- ) : !canManageChannels ? ( -
- {t("agent.onlyOwnerCanConnectChannels")} -
- ) : selectedChannel.id === "slack" ? ( - - ) : selectedChannel.id === "lark" ? ( - - ) : selectedChannel.id === "discord" ? ( - - ) : selectedChannel.id === "telegram" ? ( - - ) : selectedChannel.id === "wechat" ? ( - - ) : ( - - )} -
-
- - { - if (pendingRemoveBindingId !== null) { - return; - } - - if (!nextOpen) { - setConfirmRemoveBinding(null); - } - }} - > - - - {t("agent.removeChannelBinding")} - {t("agent.channelEventsDropped")} - -
-

{t("agent.removeChannelBindingDescription")}

-

{t("agent.channelSourceMetadata")}

-

{t("agent.recoverBinding")}

-
- - - - -
-
- - ); -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-danger-zone.tsx b/apps/web/src/routes/agent/components/settings-dialog-danger-zone.tsx index 2d2968f9..f2c22d38 100644 --- a/apps/web/src/routes/agent/components/settings-dialog-danger-zone.tsx +++ b/apps/web/src/routes/agent/components/settings-dialog-danger-zone.tsx @@ -1,52 +1,22 @@ -import { agentKindSupportsResetState } from "@mosoo/contracts/agent"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { LockKeyhole, PowerOff, RotateCcw, Trash2, XCircle } from "lucide-react"; +import { PowerOff, Trash2 } from "lucide-react"; import { useState } from "react"; -import { resetAgentState, unpublishAgent } from "@/domains/agent/api/agent-client"; +import { deleteAgent, unpublishAgent } from "@/domains/agent/api/agent-client"; import { agentKeys } from "@/domains/agent/query/agent-queries"; -import { toAgentDeploymentVersionId, toAgentId, toAppId } from "@/routes/typed-id"; +import { toAgentId, toAppId } from "@/routes/typed-id"; import { useTranslation } from "@/shared/i18n"; import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; import type { Agent } from "../agent.types"; -function toRuntimeOperationTargetVersion(agent: Agent) { - if (agent.status !== "published" || agent.liveVersion === null) { - return null; - } - - return { - id: toAgentDeploymentVersionId(agent.liveVersion.id), - versionNumber: agent.liveVersion.versionNumber, - }; -} - export function AgentSettingsDangerZone({ agent }: { agent: Agent }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [confirmDelete, setConfirmDelete] = useState(false); - const [confirmResetState, setConfirmResetState] = useState(false); const [confirmUnpublish, setConfirmUnpublish] = useState(false); - const [resetConfirmValue, setResetConfirmValue] = useState(""); const typedAgentId = toAgentId(agent.id); const typedAppId = toAppId(agent.appId); - const resetAgentStateMutation = useMutation({ - mutationFn: resetAgentState, - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: agentKeys.detail(variables.appId, variables.agentId), - }); - }, - }); const unpublishMutation = useMutation({ mutationFn: async () => unpublishAgent(typedAppId, typedAgentId), onSuccess: async () => { @@ -57,250 +27,98 @@ export function AgentSettingsDangerZone({ agent }: { agent: Agent }) { ]); }, }); - const showResetAgentState = agentKindSupportsResetState(agent.kind); - const showUnpublish = agent.status === "published"; - - async function handleResetAgentState() { - await resetAgentStateMutation.mutateAsync({ - agentId: typedAgentId, - appId: typedAppId, - targetVersion: toRuntimeOperationTargetVersion(agent), - }); - setResetConfirmValue(""); - setConfirmResetState(false); - } - - function handleResetDialogOpenChange(nextOpen: boolean): void { - setConfirmResetState(nextOpen); - - if (!nextOpen) { - setResetConfirmValue(""); - } - } + const deleteMutation = useMutation({ + mutationFn: async () => deleteAgent({ agentId: typedAgentId, appId: typedAppId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: agentKeys.lists() }); + globalThis.location.assign("/agent"); + }, + }); return ( - <> -
-

{t("agent.dangerZone")}

- -
- {showUnpublish ? ( - confirmUnpublish ? ( -
-

- {t("agent.unpublishPrompt", { name: agent.name })} -

-

{t("agent.unpublishDescription")}

- {unpublishMutation.error ? ( -
- {unpublishMutation.error instanceof Error - ? unpublishMutation.error.message - : t("agent.unpublishFailed")} -
- ) : null} -
- - -
-
- ) : ( -
-
-
- {t("agent.unpublishThisAgent")} -
-

- {t("agent.unpublishThisAgentDescription")} -

-
- -
- ) - ) : null} - - {showResetAgentState ? ( -
-
-
- {t("agent.resetAgentState")} -
-

- {t("agent.resetAgentStateDescription")} -

-
- -
- ) : null} - - {confirmDelete ? ( +
+

{t("agent.dangerZone")}

+
+ {agent.status === "published" ? ( + confirmUnpublish ? (

- {t("agent.deleteAgentPermanently", { name: agent.name })} + {t("agent.unpublishPrompt", { name: agent.name })}

+

{t("agent.unpublishDescription")}

+ -
) : (
-
+
- {t("agent.deleteThisAgent")} + {t("agent.unpublishThisAgent")}

- {t("agent.deleteThisAgentDescription")} + {t("agent.unpublishThisAgentDescription")}

-
- )} -
- - {resetAgentStateMutation.error ? ( -
- {resetAgentStateMutation.error instanceof Error - ? resetAgentStateMutation.error.message - : t("agent.resetAgentStateFailed")} -
+ ) ) : null} -
- - - - {t("agent.resetAgentStatePrompt", { name: agent.name })} - {t("agent.resetAgentStateDialogDescription")} - -
-
-
- - - {t("agent.agentStateWillBeCleared")} -
-
-
-
{t("agent.whatWillBeCleared")}
-
    -
  • {t("agent.loginState")}
  • -
  • {t("agent.cache")}
  • -
  • {t("agent.longTermMemory")}
  • -
  • {t("agent.sessionRuntimeDirs")}
  • -
  • {t("agent.nativeRuntimeResume")}
  • -
-
-
-
- {t("agent.whatWillBePreserved")} -
-
    -
  • {t("agent.agentProfile")}
  • -
  • {t("agent.sessionFiles")}
  • -
  • {t("agent.pastSessions")}
  • -
  • {t("agent.costHistory")}
  • -
-
-
-
-
- - { - setResetConfirmValue(event.target.value); - }} - value={resetConfirmValue} - /> -
+ {confirmDelete ? ( +
+

+ {t("agent.deleteAgentPermanently", { name: agent.name })} +

+ {deleteMutation.error instanceof Error ? ( +

{deleteMutation.error.message}

+ ) : null}
-
- -
- + ) : ( +
+
+
+ {t("agent.deleteThisAgent")} +
+

+ {t("agent.deleteThisAgentDescription")} +

+
+ +
+ )} +
+
); } diff --git a/apps/web/src/routes/agent/components/settings-dialog-discord-setup.tsx b/apps/web/src/routes/agent/components/settings-dialog-discord-setup.tsx deleted file mode 100644 index 065a6edc..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-discord-setup.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { Loader2 } from "lucide-react"; -import { useState } from "react"; -import type { FormEvent } from "react"; - -import { createDiscordAgentChannelBinding } from "@/domains/agent/api/agent-client"; -import { useInvalidateAgentChannelBindings } from "@/domains/agent/query/agent-queries"; -import { toAgentId, toAppId } from "@/routes/typed-id"; -import { useTranslation } from "@/shared/i18n"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Label } from "@/shared/ui/label"; - -import type { ChannelInlineSetupAgent } from "./settings-dialog-channel-agent"; - -const DISCORD_DEVELOPER_PORTAL_URL = "https://discord.com/developers/applications"; - -export function DiscordChannelInlineSetup({ - agent, - onSuccess, -}: { - agent: ChannelInlineSetupAgent; - onSuccess?: () => void; -}) { - const { t } = useTranslation(); - const invalidateChannelBindings = useInvalidateAgentChannelBindings(agent.appId, agent.id); - const [applicationId, setApplicationId] = useState(""); - const [botToken, setBotToken] = useState(""); - const [relaySecret, setRelaySecret] = useState(""); - - const mutation = useMutation({ - mutationFn: createDiscordAgentChannelBinding, - onSuccess: async () => { - await invalidateChannelBindings(); - onSuccess?.(); - }, - }); - - const canSubmit = - agent.status === "published" && - applicationId.trim().length > 0 && - botToken.trim().length > 0 && - relaySecret.trim().length > 0 && - !mutation.isPending; - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - - if (!canSubmit) { - return; - } - - mutation.mutate({ - agentId: toAgentId(agent.id), - applicationId: applicationId.trim(), - botToken: botToken.trim(), - appId: toAppId(agent.appId), - relaySecret: relaySecret.trim(), - }); - } - - return ( -
-
-
- {t("agent.discordIntentsPrerequisite")}{" "} - - {t("agent.openDiscordDeveloperPortal")} - - . -
-
-
- - { - setApplicationId(event.target.value); - }} - value={applicationId} - /> -
-
- - { - setBotToken(event.target.value); - }} - type="password" - value={botToken} - /> -
-
- - { - setRelaySecret(event.target.value); - }} - type="password" - value={relaySecret} - /> -
-
- -
- {t("agent.discordSetupCaveat")} -
- - {agent.status !== "published" ? ( -
- {t("agent.publishBeforeConnectingDiscord")} -
- ) : null} - {mutation.error ? ( -
- {mutation.error instanceof Error - ? mutation.error.message - : t("agent.discordSetupFailed")} -
- ) : null} - -
- -
-
-
- ); -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-lark-setup.tsx b/apps/web/src/routes/agent/components/settings-dialog-lark-setup.tsx deleted file mode 100644 index 7949b4dd..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-lark-setup.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import type { AgentId, AppId } from "@mosoo/contracts/id"; -import { useMutation } from "@tanstack/react-query"; -import { ExternalLink, Loader2, RefreshCw } from "lucide-react"; -import { QRCodeSVG } from "qrcode.react"; -import { useEffect, useReducer } from "react"; -import type { Dispatch, FormEvent } from "react"; - -import { - createLarkAgentChannelBinding, - pollLarkAgentChannelRegistration, - startLarkAgentChannelRegistration, -} from "@/domains/agent/api/agent-client"; -import { useInvalidateAgentChannelBindings } from "@/domains/agent/query/agent-queries"; -import type { - LarkAgentChannelRegistrationFieldsFragment, - LarkConnectionMode, - LarkDomain, - PollLarkAgentChannelRegistrationInput, -} from "@/gql/graphql"; -import { toAgentId, toAppId } from "@/routes/typed-id"; -import { useTranslation } from "@/shared/i18n"; -import { cn } from "@/shared/lib/class-names"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Label } from "@/shared/ui/label"; - -import type { ChannelInlineSetupAgent } from "./settings-dialog-channel-agent"; - -const LARK_DOMAIN_OPTIONS: { label: string; value: LarkDomain }[] = [ - { label: "Feishu", value: "feishu" }, - { label: "Lark", value: "lark" }, -]; - -const LARK_REGISTRATION_POLL_INTERVAL_MS = 3_000; - -const LARK_OPEN_PLATFORM_ORIGIN: Record = { - feishu: "https://open.feishu.cn", - lark: "https://open.larksuite.com", -}; - -function getLarkDomainLabel(domain: LarkDomain): string { - return domain === "feishu" ? "Feishu" : "Lark"; -} - -function getLarkEventConfigUrl(domain: LarkDomain, larkAppId: string): string | null { - const trimmed = larkAppId.trim(); - if (trimmed.length === 0) { - return null; - } - return `${LARK_OPEN_PLATFORM_ORIGIN[domain]}/app/${encodeURIComponent(trimmed)}/event-subscriptions/event-config`; -} - -function getLarkRegistrationStatusCopy( - status: LarkAgentChannelRegistrationFieldsFragment["status"] | null, - t: (key: string) => string, - connectionMode: LarkConnectionMode = "webhook", -): string { - switch (status) { - case "access_denied": - return t("agent.larkRegistrationDenied"); - case "confirmed": - return connectionMode === "websocket" - ? t("agent.larkRegistrationConfirmedWebsocket") - : t("agent.larkRegistrationConfirmedWebhook"); - case "expired": - return t("agent.larkRegistrationExpired"); - case "failed": - return t("agent.larkRegistrationFailed"); - case "qr_pending": - return t("agent.larkRegistrationQrPending"); - case "slow_down": - return t("agent.larkRegistrationSlowDown"); - case null: - return connectionMode === "websocket" - ? t("agent.larkRegistrationReadyWebsocket") - : t("agent.larkRegistrationReadyWebhook"); - } -} - -function isTerminalRegistrationStatus( - status: LarkAgentChannelRegistrationFieldsFragment["status"], -): boolean { - return ( - status === "access_denied" || - status === "confirmed" || - status === "expired" || - status === "failed" - ); -} - -function mergeRegistration( - current: LarkAgentChannelRegistrationFieldsFragment | null, - next: LarkAgentChannelRegistrationFieldsFragment, -): LarkAgentChannelRegistrationFieldsFragment { - return { - ...next, - deviceCode: next.deviceCode ?? current?.deviceCode ?? null, - expireIn: next.expireIn ?? current?.expireIn ?? null, - interval: next.interval ?? current?.interval ?? null, - qrUrl: next.qrUrl ?? current?.qrUrl ?? null, - userCode: next.userCode ?? current?.userCode ?? null, - }; -} - -function useLarkRegistrationPolling({ - agentId, - deviceCode, - domain, - poll, - appId, - shouldPoll, - status, -}: { - agentId: AgentId; - deviceCode: string | null; - domain: LarkDomain; - poll: (input: PollLarkAgentChannelRegistrationInput) => void; - appId: AppId; - shouldPoll: boolean; - status: LarkAgentChannelRegistrationFieldsFragment["status"] | null; -}) { - useEffect(() => { - if (!shouldPoll || deviceCode === null) { - return; - } - - const delayMs = - status === "slow_down" - ? LARK_REGISTRATION_POLL_INTERVAL_MS * 2 - : LARK_REGISTRATION_POLL_INTERVAL_MS; - const timeoutId = globalThis.setTimeout(() => { - poll({ agentId, deviceCode, domain, appId }); - }, delayMs); - - return () => { - globalThis.clearTimeout(timeoutId); - }; - }, [agentId, deviceCode, domain, poll, appId, shouldPoll, status]); -} - -interface LarkChannelInlineSetupState { - larkAppId: string; - appSecret: string; - connectionMode: LarkConnectionMode; - domain: LarkDomain; - encryptKey: string; - registration: LarkAgentChannelRegistrationFieldsFragment | null; - verificationToken: string; -} - -type LarkChannelInlineSetupAction = - | { type: "changeLarkAppId"; larkAppId: string } - | { type: "changeAppSecret"; appSecret: string } - | { type: "changeConnectionMode"; connectionMode: LarkConnectionMode } - | { type: "changeDomain"; domain: LarkDomain } - | { type: "changeEncryptKey"; encryptKey: string } - | { type: "changeVerificationToken"; verificationToken: string } - | { type: "registrationPolled"; registration: LarkAgentChannelRegistrationFieldsFragment } - | { type: "registrationStarted"; registration: LarkAgentChannelRegistrationFieldsFragment }; - -type LarkChannelInlineSetupDispatch = Dispatch; - -const LARK_CHANNEL_INLINE_SETUP_INITIAL_STATE: LarkChannelInlineSetupState = { - larkAppId: "", - appSecret: "", - connectionMode: "webhook", - domain: "feishu", - encryptKey: "", - registration: null, - verificationToken: "", -}; - -function larkChannelInlineSetupReducer( - state: LarkChannelInlineSetupState, - action: LarkChannelInlineSetupAction, -): LarkChannelInlineSetupState { - switch (action.type) { - case "changeLarkAppId": - return { ...state, larkAppId: action.larkAppId }; - case "changeAppSecret": - return { ...state, appSecret: action.appSecret }; - case "changeConnectionMode": - return { ...state, connectionMode: action.connectionMode }; - case "changeDomain": - return { ...state, domain: action.domain }; - case "changeEncryptKey": - return { ...state, encryptKey: action.encryptKey }; - case "changeVerificationToken": - return { ...state, verificationToken: action.verificationToken }; - case "registrationPolled": { - const nextState = { - ...state, - domain: action.registration.domain, - registration: mergeRegistration(state.registration, action.registration), - }; - - if ( - action.registration.status === "confirmed" && - action.registration.appId && - action.registration.appSecret - ) { - return { - ...nextState, - larkAppId: action.registration.appId, - appSecret: action.registration.appSecret, - }; - } - - return nextState; - } - case "registrationStarted": - return { - ...state, - domain: action.registration.domain, - registration: action.registration, - }; - } -} - -export function LarkChannelInlineSetup({ - agent, - onSuccess, -}: { - agent: ChannelInlineSetupAgent; - onSuccess?: () => void; -}) { - const invalidateChannelBindings = useInvalidateAgentChannelBindings(agent.appId, agent.id); - const [state, dispatch] = useReducer( - larkChannelInlineSetupReducer, - LARK_CHANNEL_INLINE_SETUP_INITIAL_STATE, - ); - const { - larkAppId, - appSecret, - connectionMode, - domain, - encryptKey, - registration, - verificationToken, - } = state; - const typedAgentId = toAgentId(agent.id); - const typedAppId = toAppId(agent.appId); - - const registrationStartMutation = useMutation({ - mutationFn: startLarkAgentChannelRegistration, - onSuccess: async (result) => { - dispatch({ registration: result, type: "registrationStarted" }); - await invalidateChannelBindings(); - }, - }); - const registrationPollMutation = useMutation({ - mutationFn: pollLarkAgentChannelRegistration, - onSuccess: async (result) => { - dispatch({ registration: result, type: "registrationPolled" }); - await invalidateChannelBindings(); - }, - }); - const saveMutation = useMutation({ - mutationFn: createLarkAgentChannelBinding, - onSuccess: async () => { - await invalidateChannelBindings(); - onSuccess?.(); - }, - }); - - const canSubmit = - agent.status === "published" && - larkAppId.trim().length > 0 && - appSecret.trim().length > 0 && - encryptKey.trim().length > 0 && - verificationToken.trim().length > 0 && - !saveMutation.isPending; - - const registrationDeviceCode = registration?.deviceCode ?? null; - const registrationDomain = registration?.domain ?? domain; - const shouldPollRegistration = - agent.status === "published" && - registration !== null && - registrationDeviceCode !== null && - !isTerminalRegistrationStatus(registration.status) && - !registrationStartMutation.isPending && - !registrationPollMutation.isPending; - const pollRegistration = registrationPollMutation.mutate; - - useLarkRegistrationPolling({ - agentId: typedAgentId, - deviceCode: registrationDeviceCode, - domain: registrationDomain, - poll: pollRegistration, - appId: typedAppId, - shouldPoll: shouldPollRegistration, - status: registration?.status ?? null, - }); - - function handleStartRegistration() { - registrationStartMutation.mutate({ - agentId: typedAgentId, - domain, - appId: typedAppId, - }); - } - - function handlePollRegistration() { - if (!registrationDeviceCode || registrationPollMutation.isPending) { - return; - } - - registrationPollMutation.mutate({ - agentId: typedAgentId, - deviceCode: registrationDeviceCode, - domain: registrationDomain, - appId: typedAppId, - }); - } - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - - if (!canSubmit) { - return; - } - - saveMutation.mutate({ - agentId: typedAgentId, - larkAppId: larkAppId.trim(), - appSecret: appSecret.trim(), - connectionMode, - domain, - encryptKey: connectionMode === "webhook" ? encryptKey.trim() : null, - appId: typedAppId, - verificationToken: connectionMode === "webhook" ? verificationToken.trim() : null, - }); - } - - const registrationStatus = registration?.status ?? null; - const registrationError = - registrationStartMutation.error ?? - registrationPollMutation.error ?? - (registration?.lastErrorCode ? new Error(registration.lastErrorCode) : null); - const eventConfigUrl = getLarkEventConfigUrl(domain, larkAppId); - const domainLabel = getLarkDomainLabel(domain); - - return ( -
- - - - - ); -} - -function LarkRegistrationSection({ - agentStatus, - connectionMode, - onPoll, - onStart, - pollPending, - registration, - registrationDeviceCode, - registrationError, - registrationStatus, - startPending, -}: { - agentStatus: ChannelInlineSetupAgent["status"]; - connectionMode: LarkConnectionMode; - onPoll: () => void; - onStart: () => void; - pollPending: boolean; - registration: LarkAgentChannelRegistrationFieldsFragment | null; - registrationDeviceCode: string | null; - registrationError: Error | null; - registrationStatus: LarkAgentChannelRegistrationFieldsFragment["status"] | null; - startPending: boolean; -}) { - const { t } = useTranslation(); - return ( -
-
-
-
-
- {t("agent.larkScanToCreate")} -
-
- {t("agent.larkScanToCreateDescription")}{" "} - {connectionMode === "websocket" - ? t("agent.larkWebsocketSetupDescription") - : t("agent.larkWebhookSetupDescription")} -
-
- -
- -
- {getLarkRegistrationStatusCopy(registrationStatus, t, connectionMode)} -
- - {registration?.qrUrl ? ( -
-
- -
-
- - - {registration.userCode ? ( - - {registration.userCode} - - ) : null} -
-
- ) : null} - - {registrationError ? ( -
- {registrationError.message} -
- ) : null} -
-
- ); -} - -function LarkConfigurationSection({ - agentStatus, - canSubmit, - dispatch, - domainLabel, - eventConfigUrl, - saveError, - savePending, - state, -}: { - agentStatus: ChannelInlineSetupAgent["status"]; - canSubmit: boolean; - dispatch: LarkChannelInlineSetupDispatch; - domainLabel: string; - eventConfigUrl: string | null; - saveError: Error | null; - savePending: boolean; - state: LarkChannelInlineSetupState; -}) { - const { t } = useTranslation(); - const { larkAppId, appSecret, connectionMode, domain, encryptKey, verificationToken } = state; - - return ( -
-
-
{t("agent.configuration")}
- -
- -
- {([{ label: "Webhook", value: "webhook" }] as const).map((option) => ( - - ))} -
-
- {t("agent.larkWebhookModeDescription")} {t("agent.larkModeValuesIndependent")} -
-
- -
- -
- {LARK_DOMAIN_OPTIONS.map((option) => ( - - ))} -
-
- -
- - { - dispatch({ larkAppId: event.target.value, type: "changeLarkAppId" }); - }} - value={larkAppId} - /> -
- -
- - { - dispatch({ appSecret: event.target.value, type: "changeAppSecret" }); - }} - type="password" - value={appSecret} - /> -
- - {connectionMode === "webhook" ? ( - - ) : null} -
- - {agentStatus !== "published" ? ( -
- {t("agent.publishBeforeConnectingFeishu")} -
- ) : null} - {saveError ? ( -
- {saveError.message} -
- ) : null} - -
- -
-
- ); -} - -function LarkWebhookFields({ - dispatch, - domainLabel, - encryptKey, - eventConfigUrl, - verificationToken, -}: { - dispatch: LarkChannelInlineSetupDispatch; - domainLabel: string; - encryptKey: string; - eventConfigUrl: string | null; - verificationToken: string; -}) { - const { t } = useTranslation(); - - return ( - <> - {eventConfigUrl ? ( -
- -
- {t("agent.copyLarkWebhookCredentials")} -
-
- ) : null} -
- - { - dispatch({ - type: "changeVerificationToken", - verificationToken: event.target.value, - }); - }} - type="password" - value={verificationToken} - /> -
-
- - { - dispatch({ encryptKey: event.target.value, type: "changeEncryptKey" }); - }} - type="password" - value={encryptKey} - /> -
- - ); -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-model.ts b/apps/web/src/routes/agent/components/settings-dialog-model.ts deleted file mode 100644 index f31a3300..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-model.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AgentChannelBindingProvider } from "@mosoo/contracts/channel"; - -export type ChannelId = AgentChannelBindingProvider; - -export const DISTRIBUTION_CHANNELS: { - enabled: boolean; - id: ChannelId; - label: string; -}[] = [ - { enabled: true, id: "slack", label: "Slack" }, - { enabled: true, id: "lark", label: "Feishu" }, - { enabled: true, id: "discord", label: "Discord" }, - { enabled: true, id: "telegram", label: "Telegram" }, - { enabled: true, id: "wechat", label: "WeChat" }, -]; - -export function downloadTextFile(fileName: string, contentType: string, content: string) { - const blob = new Blob([content], { type: contentType }); - downloadBlob(fileName, blob); -} - -function downloadBlob(fileName: string, blob: Blob) { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = fileName; - anchor.click(); - URL.revokeObjectURL(url); -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-slack-manifest.ts b/apps/web/src/routes/agent/components/settings-dialog-slack-manifest.ts deleted file mode 100644 index d5935884..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-slack-manifest.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { buildAgentChannelWebhookUrl } from "@mosoo/contracts/channel"; - -import { resolveChannelWebhookOrigin } from "./channel-webhook-origin"; - -export const SLACK_APP_LEVEL_TOKEN_LABEL = "App-Level Token (optional)"; -export const SLACK_THREAD_REPLY_MENTION_LABEL = "Require mentions in thread replies"; -export const SLACK_MANIFEST_HELP_URL = - "https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests"; - -function getRequestUrl(): string { - return buildAgentChannelWebhookUrl({ origin: resolveChannelWebhookOrigin(), provider: "slack" }); -} - -function toYamlQuotedScalar(value: string): string { - return JSON.stringify(value); -} - -export function buildSlackManifest(agentName: string): string { - return [ - "display_information:", - ` name: ${toYamlQuotedScalar(agentName)}`, - "features:", - " bot_user:", - " display_name: mosoobot", - " always_online: true", - "oauth_config:", - " scopes:", - " bot:", - " - app_mentions:read", - " - channels:history", - " - chat:write", - " - files:read", - " - groups:history", - " - im:history", - " - im:read", - " - im:write", - " - users:read", - "settings:", - " event_subscriptions:", - ` request_url: ${getRequestUrl()}`, - " bot_events:", - " - app_mention", - " - message.channels", - " - message.im", - " org_deploy_enabled: false", - " socket_mode_enabled: false", - " token_rotation_enabled: false", - ].join("\n"); -} diff --git a/apps/web/src/routes/agent/components/settings-dialog-slack-setup.tsx b/apps/web/src/routes/agent/components/settings-dialog-slack-setup.tsx deleted file mode 100644 index cc6daf32..00000000 --- a/apps/web/src/routes/agent/components/settings-dialog-slack-setup.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { Check, ChevronDown, Copy, ExternalLink, Loader2 } from "lucide-react"; -import { useMemo, useReducer } from "react"; -import type { FormEvent } from "react"; - -import { createSlackAgentChannelBinding } from "@/domains/agent/api/agent-client"; -import { useInvalidateAgentChannelBindings } from "@/domains/agent/query/agent-queries"; -import { toAgentId, toAppId } from "@/routes/typed-id"; -import { useTranslation } from "@/shared/i18n"; -import { cn } from "@/shared/lib/class-names"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Label } from "@/shared/ui/label"; -import { Switch } from "@/shared/ui/switch"; -import { Textarea } from "@/shared/ui/textarea"; - -import type { ChannelInlineSetupAgent } from "./settings-dialog-channel-agent"; -import { SLACK_MANIFEST_HELP_URL, buildSlackManifest } from "./settings-dialog-slack-manifest"; - -function SlackManifestHelpLink() { - const { t } = useTranslation(); - return ( - - {t("agent.slackManifestHelp")} - - - ); -} - -interface SlackChannelInlineSetupState { - appLevelToken: string; - botToken: string; - copied: boolean; - manifestOpen: boolean; - signingSecret: string; - threadRepliesRequireMention: boolean; -} - -type SlackChannelInlineSetupAction = - | { type: "changeAppLevelToken"; value: string } - | { type: "changeBotToken"; value: string } - | { type: "changeSigningSecret"; value: string } - | { type: "setCopied"; copied: boolean } - | { type: "setThreadRepliesRequireMention"; value: boolean } - | { type: "toggleManifest" }; - -const SLACK_CHANNEL_INLINE_SETUP_INITIAL_STATE: SlackChannelInlineSetupState = { - appLevelToken: "", - botToken: "", - copied: false, - manifestOpen: true, - signingSecret: "", - threadRepliesRequireMention: false, -}; - -function slackChannelInlineSetupReducer( - state: SlackChannelInlineSetupState, - action: SlackChannelInlineSetupAction, -): SlackChannelInlineSetupState { - switch (action.type) { - case "changeAppLevelToken": - return { ...state, appLevelToken: action.value }; - case "changeBotToken": - return { ...state, botToken: action.value }; - case "changeSigningSecret": - return { ...state, signingSecret: action.value }; - case "setCopied": - return { ...state, copied: action.copied }; - case "setThreadRepliesRequireMention": - return { ...state, threadRepliesRequireMention: action.value }; - case "toggleManifest": - return { ...state, manifestOpen: !state.manifestOpen }; - } -} - -export function SlackChannelInlineSetup({ - agent, - onSuccess, -}: { - agent: ChannelInlineSetupAgent; - onSuccess?: () => void; -}) { - const { t } = useTranslation(); - const invalidateChannelBindings = useInvalidateAgentChannelBindings(agent.appId, agent.id); - const [state, dispatch] = useReducer( - slackChannelInlineSetupReducer, - SLACK_CHANNEL_INLINE_SETUP_INITIAL_STATE, - ); - const { - appLevelToken, - botToken, - copied, - manifestOpen, - signingSecret, - threadRepliesRequireMention, - } = state; - - const manifest = useMemo(() => buildSlackManifest(agent.name), [agent.name]); - - const mutation = useMutation({ - mutationFn: createSlackAgentChannelBinding, - onSuccess: async () => { - await invalidateChannelBindings(); - onSuccess?.(); - }, - }); - - const canSubmit = - agent.status === "published" && - botToken.trim().length > 0 && - signingSecret.trim().length > 0 && - !mutation.isPending; - - async function handleCopyManifest() { - await navigator.clipboard.writeText(manifest); - dispatch({ copied: true, type: "setCopied" }); - globalThis.setTimeout(() => { - dispatch({ copied: false, type: "setCopied" }); - }, 1500); - } - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - - if (!canSubmit) { - return; - } - - mutation.mutate({ - agentId: toAgentId(agent.id), - appLevelToken: appLevelToken.trim() || null, - botToken: botToken.trim(), - appId: toAppId(agent.appId), - signingSecret: signingSecret.trim(), - threadRepliesRequireMention, - }); - } - - return ( -
-
- - {manifestOpen ? ( -
-
- - -
-