@@ -0,0 +1,146 @@
|
||||
# agent
|
||||
|
||||
The bounded agent runtime's app integration: durable runs with step state, the approval queue a
|
||||
person reviews, and the run controls. The run job drives `@conusai-kit/agent`'s loop on TanStack
|
||||
AI's server `chat()`: the model (`AGENT_MODEL`, the AI gateway's `conusaiText` adapter) calls the
|
||||
admitted steps in order and answers the goal from their results, and the conversation is kept in
|
||||
`agent_thread` through `@tanstack/ai-persistence`. The run's tool calls go through the
|
||||
organization's tool registry with the app's scoped deployment credentials (`apps/worker/src/ports.ts`
|
||||
binds `AGENT_TOOLS` and `AGENT_MODEL`); the platform owns dispatch policy, metering, spend admission
|
||||
and the run ledger.
|
||||
|
||||
A plan step with `"needsApproval": true` is approved by a person before it runs: the run parks in
|
||||
`awaiting_approval` with the step's exact tool and arguments in the approval queue, and
|
||||
`agent.approval.review` records the decision on the step (approve resumes the run, reject cancels
|
||||
it; a step is decided once). A completed run's answer is AI-generated text: `agent.run.status` and
|
||||
the run page show it with the Art. 50 disclosure (`AiDisclosureBadge`).
|
||||
|
||||
## The grant a run's tools need
|
||||
|
||||
A run's tool calls go to the organization's MCP endpoint with the app's deployment token, and that
|
||||
endpoint admits a deployment token only with `ai_tools:call`. The scope is not a default and `*`
|
||||
never holds it, so `conusai.json` requests it, in the root file and in every profile copy that has a
|
||||
`token` block (`profiles/convex`, `profiles/mariadb`; `profiles/mongodb` has none). This module is
|
||||
Postgres-only, so nothing calls the endpoint on those profiles and their request can stay undecided.
|
||||
|
||||
A request is not a grant. A deployment that asks for the scope runs without it until an organization
|
||||
owner or admin approves it, in the project's Settings, Token permissions screen or with `conusai
|
||||
tokens approve --id <token_id> ai_tools:call`; anyone else gets a 403, and the approval is in the
|
||||
audit log. Approve it before starting runs.
|
||||
|
||||
Without the grant a run's tool call is refused with a 403: the run ends `failed` with the failure code
|
||||
`tool_error` and the reason in `failureDetail` and on its step, and the queue does not run it again. A
|
||||
policy denial ends the run the same way, and so does a tool that answers an error: its step is not
|
||||
committed, and none of the tool's text is kept on the run. A failed or cancelled run is final, and a
|
||||
later approval does not resume it: start a new run.
|
||||
|
||||
A failure that can pass later is left to the queue: a 429 or a 5xx, a network failure, busy compute, a
|
||||
catalog that is still indexing, or a `runtime-unavailable` denial. The next delivery resumes the step on
|
||||
its idempotency key. When the queue proves this is the final attempt (`ctx.finalAttempt`), an
|
||||
unhandled failure ends the run as `failed/internal` with a fixed exhaustion message. Worker shutdown
|
||||
preserves the recoverable state. Terminal replay never calls the model or tools again.
|
||||
|
||||
The job reads the initiating member from the stored run's `actorId`, and checks real current app
|
||||
membership and `agent.run.start` through `TENANCY.authorizeIn` at entry, before each model turn,
|
||||
and before each tool effect, including approval resumption. A removal or permission revocation
|
||||
ends the run with a fixed failure message; a request's cached permission never authorizes worker
|
||||
execution. App tenant UUIDs remain separate from Cloud deployment authority. The scoped run store
|
||||
matches org, workspace and project; wrong-scope lookups cannot execute another tenant's run.
|
||||
|
||||
The supported Postgres worker exposes its actual envelope and claim through `JobContext.execution`.
|
||||
Agent verifies scope and attribution and probes the data source's real transaction hook before any
|
||||
run/provider work. A raw factory without that hook fails closed. Every scoped repository operation
|
||||
and UOW locks the exact current claim in its own transaction until commit/rollback; this lock
|
||||
survives loss of the separate guard session. Owned-client error/end events abort a sticky signal;
|
||||
same-session health checks surround authorization, model/tool/resume, store and error boundaries.
|
||||
Claim loss remains recoverable queue interruption and never becomes terminal business failure.
|
||||
|
||||
The tools run with the token's own permissions, so a tool that needs another scope needs that scope
|
||||
requested and approved as well. A step calls only the tool and arguments a person admitted in the
|
||||
plan.
|
||||
|
||||
**Tool cost is not reported yet.** The registry's call result carries no metered usage, so a step
|
||||
records a cost of 0 (`registryAgentTools`): the budget guard counts a run's steps and time, and no
|
||||
tool's cost. Model calls are metered by the AI gateway, under the organization's spend cap.
|
||||
|
||||
## Procedures
|
||||
|
||||
| Procedure | Kind | Permission | What it answers |
|
||||
|---|---|---|---|
|
||||
| `agent.run.start` | write | `agent.run.start` | a queued run with its admitted plan |
|
||||
| `agent.run.status` | read (MCP tool) | `agent.run.read` | the run, its step states and progress |
|
||||
| `agent.run.events` | effect, `GET` stream | `agent.run.read` | the run's AG-UI events as Server-Sent Events |
|
||||
| `agent.run.list` | read | `agent.run.read` | the project's runs, a page at a time |
|
||||
| `agent.run.cancel` | write | `agent.run.cancel` | the persisted cancel request |
|
||||
| `agent.approval.list` | read | `agent.approval.review` | the parked approvals with their exact arguments |
|
||||
| `agent.approval.review` | write | `agent.approval.review` | the run after a person's decision |
|
||||
|
||||
`agent.run.events` (`GET /api/v1/agent.run.events?org=…&workspace=…&project=…&runId=…[&after=…]`)
|
||||
answers `text/event-stream` from `@conusai-kit/agent/agui`'s projection of the durable record: what
|
||||
`agent.run.status` shows the same reader, and nothing more (no tool results, artifacts or an
|
||||
approval's proposed arguments). `after` is the last delivered sequence number or SSE id
|
||||
(`agk1:{runId}:{seq}`); the stream replays strictly after it, re-reads the record every second
|
||||
until the run's terminal event, and closes a 25-second window with an untagged notice so the client
|
||||
reconnects and is authorized again. Another project's or organization's run is not found, exactly as
|
||||
`agent.run.status` answers, and an offset of another run is refused (422). The browser client is
|
||||
`@conusai-kit/agent/client` (`agentRunConnection`, `watchAgentRun`): the run page server-renders
|
||||
`agent.run.status` as the seed of a client island (`ui/live-run.tsx`) that follows this stream, so
|
||||
step states, a parked approval and the answer appear without a reload and nothing polls the run.
|
||||
|
||||
The run list and the approval queue are live too. The module declares the live collection `agent.run`
|
||||
(`contract/collections.ts`: table `agent_run`, key `runId`, batch-get `agent.run.getMany` under
|
||||
`agent.run.read`, at most 100 ids). The list keeps its server-rendered first page as the seed of a
|
||||
`liveCollection` (`ui/live-runs.tsx`), so a run another member starts appears and each run's status and
|
||||
progress move as its steps commit. The approval queue is a `useRead` of `agent.approval.list` read again on
|
||||
every `agent.run` change (a run that parks an approval or leaves one changes its status) and after the
|
||||
reviewer's own decision, which changes a step and not the run. Starting a run is a `useWrite` that
|
||||
resolves once the live stream has seen the new row; no page re-runs its loader after a write
|
||||
(`test/live-writes.test.ts`).
|
||||
|
||||
The procedure is declared `effect` rather than `read` only because the kernel admits a streamed
|
||||
HTTP response (`.download()`) for that kind alone; its handler reads and never writes, and it counts
|
||||
against the `default` rate-limit bucket like a read. Being an effect, a session caller's token is
|
||||
re-checked as high-risk on every connection.
|
||||
|
||||
## Why the module is Postgres-only (no Convex profile yet)
|
||||
|
||||
`module.convex.ts` ships a fail-closed Convex binding for a future wiring, but the module is
|
||||
**not** registered in the Convex profile (`convex/lib/runtime.ts`), and the manifest stays
|
||||
`profiles: ["postgres"]`. The integration review (2026-10-23) kept it Postgres-only because the
|
||||
run cannot execute on the Convex runtime today:
|
||||
|
||||
1. The `agent.run` **job** leaves the process on every step — the tool registry, the AI gateway,
|
||||
and whatever the managed tools call. Convex queries/mutations cannot perform network I/O; the
|
||||
Convex binding fails closed (`… must run through the Convex Node action boundary`) and, unlike
|
||||
documents' `documentsActions:process`, no composed Node action exists for the run job yet.
|
||||
2. A run's steps must recheck policy and meter through the platform between actions; that
|
||||
orchestration lives in the worker, and a partial registration would let a project start runs
|
||||
that always dispatch into the fence.
|
||||
|
||||
The read/write procedures (run list/get, cancel, approval review) would technically run in Convex
|
||||
transactions, but registering the module for metadata alone — while every started run fails — is
|
||||
a registered-broken feature, not a fail-closed gate. Wiring the profile means composing the run
|
||||
job's Node action first (mirror `convex/documentsActions.ts`).
|
||||
|
||||
S08 hardening rechecks current local membership and `agent.run.read` before every stream poll,
|
||||
without the request's permission memo. Revocation closes the body; reconnect is a fresh authorized
|
||||
request. Approval interrupts compare the resumed tool and canonical arguments with the stored
|
||||
review; drift fails durably, rejection cancels without a second cancel write, and an awaiting run
|
||||
expires on observation at its admitted duration. These local controls do not establish live model,
|
||||
registry-tool or sandbox readiness. See the S08 evidence boundaries.
|
||||
|
||||
|
||||
Durable Agent jobs require `JobContext.execution` from the supported Postgres worker factory
|
||||
(`createPostgresWorker` in `@conusai-kit/host/worker`). The actual pg-boss envelope must match
|
||||
`AGENT_SCOPE`, the job name, real system actor and immutable initiating run owner. The handler
|
||||
holds the adapter guard through all awaited work while its session is alive; domain transactions
|
||||
also hold their own compatible claim lock until commit/rollback. Missing or lost claims refuse
|
||||
separately from business failure or retry exhaustion. The guard uses its own bounded pool;
|
||||
shutdown aborts and drains callbacks without releasing live sessions early. Raw queue constructors
|
||||
without the factory's exact timestamp evidence, BullMQ and Convex supply no accepted Agent claim
|
||||
guarantee. Current membership checks still repeat between model/tool calls and approved resumes.
|
||||
|
||||
External tool keys remain `${record.id}:${planned.key}`. Providers must enforce idempotency/fencing
|
||||
and cooperate with cancellation; an already-started action cannot be undone by an abort signal.
|
||||
The supported domain fence and external-provider guarantees are separate. See the
|
||||
[adapter contract](../../../../sdks/node/packages/kit-jobs-pgboss/README.md#durable-claim-guards).
|
||||
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Real pg-boss claims for Agent tests, including local engine/provider tests.
|
||||
import { buildRegistry, Container, defineModule, type JobContext, newId, type ProjectScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { type DataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import { connectKyselyDataSourceFromEnv, type KyselyDataSource } from "@conusai-kit/data-kysely"
|
||||
import {
|
||||
createPgBoss,
|
||||
createPgBossClaimGuard,
|
||||
createPgBossJobQueue,
|
||||
createWorker,
|
||||
fromKyselyTransaction,
|
||||
withJobTransaction,
|
||||
} from "@conusai-kit/jobs-pgboss"
|
||||
import { Pool, type PoolClient } from "pg"
|
||||
import { afterAll, beforeAll } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { runJob } from "../adapters/procedures.js"
|
||||
import { RUN_JOB } from "../contract/index.js"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { grantPgBoss, migrate, type Postgres, startPostgres } from "./claimed-postgres.js"
|
||||
import { workerRegistry } from "./worker-fixture.js"
|
||||
|
||||
let postgres: Postgres
|
||||
let boss: ReturnType<typeof createPgBoss>
|
||||
let guard: ReturnType<typeof createPgBossClaimGuard>
|
||||
let sql: Pool
|
||||
let ownedClient: PoolClient
|
||||
/** Terminate only the client acquired from this test's dedicated guard pool. */
|
||||
export async function terminateClaimSession() {
|
||||
const {
|
||||
rows: [{ pid }],
|
||||
} = await ownedClient.query("select pg_backend_pid() as pid")
|
||||
await sql.query("select pg_terminate_backend($1)", [pid])
|
||||
}
|
||||
export type ClaimChange = "cancel" | "reclaim" | "delete" | "scope" | "actor" | "owner"
|
||||
const workers: ReturnType<typeof createWorker>[] = []
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
container: Container
|
||||
extra: Partial<JobContext>
|
||||
change?: ClaimChange
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
>()
|
||||
let domainData: KyselyDataSource | undefined
|
||||
/** Queue and domain tables share the actual database, as in the supported host. */
|
||||
export async function claimedDataSource(): Promise<KyselyDataSource> {
|
||||
if (!domainData) {
|
||||
const entities = entitiesOf(workerRegistry, "postgres")
|
||||
await migrate(postgres, entities)
|
||||
// Migrations grant access to public tables; queue objects remain owned by pg-boss.
|
||||
domainData = await connectKyselyDataSourceFromEnv(
|
||||
{ POSTGRES_URL: postgres.runtimeUrl, POSTGRES_READER_URL: postgres.readerUrl },
|
||||
{ entities, transactionContext: (trx, run) => withJobTransaction(fromKyselyTransaction(trx), run) },
|
||||
)
|
||||
}
|
||||
return domainData
|
||||
}
|
||||
let currentData: DataSource
|
||||
const registry = buildRegistry([
|
||||
defineModule({
|
||||
id: "agent",
|
||||
version: "1.0.0",
|
||||
kit: "^0.1.0",
|
||||
jobs: {
|
||||
[RUN_JOB]: {
|
||||
payload: z.object({ runId: z.string(), delivery: z.string() }),
|
||||
async handler(payload, ctx) {
|
||||
const target = pending.get(payload.delivery)
|
||||
if (!target) throw new Error("unregistered test delivery")
|
||||
try {
|
||||
if (!ctx.execution) throw new Error("supported adapter did not supply execution")
|
||||
const id = ctx.execution.id
|
||||
if (target.change === "delete") await sql.query("delete from pgboss.job where id = $1", [id])
|
||||
if (target.change === "cancel")
|
||||
await sql.query("update pgboss.job set state = 'cancelled' where id = $1", [id])
|
||||
if (target.change === "reclaim")
|
||||
await sql.query("update pgboss.job set retry_count = retry_count + 1 where id = $1", [id])
|
||||
if (target.change === "scope")
|
||||
await sql.query(
|
||||
"update pgboss.job set data = jsonb_set(data, '{scope,projectId}', to_jsonb($2::text)) where id = $1",
|
||||
[id, newId()],
|
||||
)
|
||||
if (target.change === "actor")
|
||||
await sql.query(
|
||||
"update pgboss.job set data = jsonb_set(data, '{onBehalfOf}', to_jsonb($2::text)) where id = $1",
|
||||
[id, newId()],
|
||||
)
|
||||
await runJob.handler(
|
||||
{ runId: payload.runId },
|
||||
{ ...ctx, ...target.extra, execution: ctx.execution, container: target.container },
|
||||
)
|
||||
target.resolve()
|
||||
} catch (error) {
|
||||
target.reject(error)
|
||||
} finally {
|
||||
pending.delete(payload.delivery)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
beforeAll(async () => {
|
||||
postgres = await startPostgres()
|
||||
boss = createPgBoss({ connectionString: postgres.ownerUrl })
|
||||
await boss.start()
|
||||
await boss.createQueue(RUN_JOB)
|
||||
await grantPgBoss(postgres)
|
||||
sql = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
|
||||
const guardPool = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
|
||||
guardPool.on("acquire", (client) => {
|
||||
ownedClient = client
|
||||
})
|
||||
guard = createPgBossClaimGuard(guardPool)
|
||||
}, 300_000)
|
||||
afterAll(async () => {
|
||||
for (const worker of workers) await worker.stop()
|
||||
await guard?.close()
|
||||
await sql?.end()
|
||||
await boss?.stop({ graceful: true })
|
||||
await domainData?.close()
|
||||
await postgres?.stop()
|
||||
})
|
||||
let started = false
|
||||
export async function claimedDelivery(
|
||||
data: DataSource,
|
||||
container: Container,
|
||||
scope: ProjectScope,
|
||||
runId: string,
|
||||
extra: Partial<JobContext> = {},
|
||||
change?: ClaimChange,
|
||||
) {
|
||||
currentData = data
|
||||
if (!started) {
|
||||
// Source forwarding is needed because a file exercises several independent tenancy fixtures.
|
||||
const source = new Proxy({} as DataSource, {
|
||||
get(_target, property) {
|
||||
const value = Reflect.get(currentData, property)
|
||||
return typeof value === "function" ? value.bind(currentData) : value
|
||||
},
|
||||
})
|
||||
workers.length = 0
|
||||
const worker = createWorker({
|
||||
registry,
|
||||
boss,
|
||||
claimGuard: guard,
|
||||
dataSource: source,
|
||||
container: new Container(),
|
||||
relay: { enabled: false },
|
||||
})
|
||||
workers.push(worker)
|
||||
await worker.start()
|
||||
started = true
|
||||
}
|
||||
// Mirror the actual adapter scope into the app's independently bound scope port.
|
||||
const base = extra.container ?? container
|
||||
const child = base.child()
|
||||
child.value(AGENT_SCOPE, { ...base.resolve(AGENT_SCOPE), actor: systemActor(RUN_JOB) })
|
||||
const delivery = newId()
|
||||
const settled = new Promise<void>((resolve, reject) =>
|
||||
pending.set(delivery, { container: child, extra, ...(change ? { change } : {}), resolve, reject }),
|
||||
)
|
||||
await createPgBossJobQueue({ boss }).enqueue(
|
||||
change === "owner" ? { ...scope, actor: { userId: newId() } } : scope,
|
||||
RUN_JOB,
|
||||
{ runId, delivery },
|
||||
)
|
||||
return settled
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Disposable local PostgreSQL for the app's actual claim tests. No SDK-private test code.
|
||||
import { execFileSync } from "node:child_process"
|
||||
import type { AnyEntityDef } from "@conusai-kit/data"
|
||||
import { readerMigration, runMigrationsFromEnv, schemaMigration } from "@conusai-kit/data-kysely"
|
||||
import { Pool } from "pg"
|
||||
|
||||
export interface Postgres {
|
||||
ownerUrl: string
|
||||
runtimeUrl: string
|
||||
readerUrl: string
|
||||
stop(): Promise<void>
|
||||
}
|
||||
const docker = (...args: string[]) =>
|
||||
execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
||||
|
||||
export async function startPostgres(): Promise<Postgres> {
|
||||
const name = `agent-claim-${crypto.randomUUID()}`
|
||||
const container = docker(
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--label",
|
||||
"conusai.test=app-agent-claim",
|
||||
"--publish",
|
||||
"127.0.0.1::5432",
|
||||
"--env",
|
||||
"POSTGRES_USER=owner",
|
||||
"--env",
|
||||
"POSTGRES_PASSWORD=owner",
|
||||
"--env",
|
||||
"POSTGRES_DB=agent",
|
||||
"pgvector/pgvector:pg18",
|
||||
)
|
||||
const stop = async () => {
|
||||
docker("rm", "--force", "--volumes", container)
|
||||
}
|
||||
let owner: Pool | undefined
|
||||
try {
|
||||
// Docker Desktop can return before the random published port is visible. An empty
|
||||
// port makes pg silently use 5432, so wait for the actual mapping before connecting.
|
||||
const mappingDeadline = Date.now() + 10_000
|
||||
let port = ""
|
||||
while (!/^[1-9][0-9]*$/.test(port)) {
|
||||
port = docker("port", container, "5432/tcp").split(":").at(-1) ?? ""
|
||||
if (/^[1-9][0-9]*$/.test(port)) break
|
||||
if (Date.now() >= mappingDeadline) throw new Error("Postgres test port was not published")
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
const ownerUrl = `postgresql://owner:owner@127.0.0.1:${port}/agent`
|
||||
owner = new Pool({ connectionString: ownerUrl, max: 1, connectionTimeoutMillis: 1000 })
|
||||
const deadline = Date.now() + 60_000
|
||||
while (true) {
|
||||
try {
|
||||
await owner.query("select 1")
|
||||
break
|
||||
} catch (error) {
|
||||
if (Date.now() >= deadline) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
await owner.query("create role app_runtime login password 'runtime' nosuperuser nobypassrls")
|
||||
await owner.query("create role app_reader login password 'reader' nosuperuser nobypassrls")
|
||||
return {
|
||||
ownerUrl,
|
||||
runtimeUrl: ownerUrl.replace("owner:owner@", "app_runtime:runtime@"),
|
||||
readerUrl: ownerUrl.replace("owner:owner@", "app_reader:reader@"),
|
||||
stop,
|
||||
}
|
||||
} catch (error) {
|
||||
await stop()
|
||||
throw error
|
||||
} finally {
|
||||
await owner?.end()
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrate(postgres: Postgres, entities: readonly AnyEntityDef[]) {
|
||||
await runMigrationsFromEnv({ POSTGRES_MIGRATE_URL: postgres.ownerUrl }, [
|
||||
schemaMigration(entities),
|
||||
readerMigration("app_reader", []),
|
||||
])
|
||||
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
|
||||
try {
|
||||
await owner.query("grant usage on schema public to app_runtime, app_reader")
|
||||
await owner.query("grant select, insert, update, delete on all tables in schema public to app_runtime")
|
||||
} finally {
|
||||
await owner.end()
|
||||
}
|
||||
}
|
||||
|
||||
export async function grantPgBoss(postgres: Postgres) {
|
||||
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
|
||||
try {
|
||||
await owner.query("grant usage on schema pgboss to app_runtime")
|
||||
await owner.query("grant select, insert, update, delete on all tables in schema pgboss to app_runtime")
|
||||
await owner.query("grant execute on all functions in schema pgboss to app_runtime")
|
||||
} finally {
|
||||
await owner.end()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The module's messages: en and lt declare the same keys, or a page in one language breaks.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { agentModule } from "../index.js"
|
||||
|
||||
describe("the agent module's messages", () => {
|
||||
const catalogues = buildRegistry([tenancyModule, agentModule]).catalogues
|
||||
|
||||
it("declares en and lt, and every en key resolves in lt", () => {
|
||||
const en = catalogues.en?.agent ?? {}
|
||||
const lt = catalogues.lt?.agent ?? {}
|
||||
const flatten = (tree: Record<string, unknown>, prefix = ""): string[] =>
|
||||
Object.entries(tree).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [`${prefix}${key}`] : flatten(value as Record<string, unknown>, `${prefix}${key}.`),
|
||||
)
|
||||
const enKeys = flatten(en)
|
||||
const ltKeys = new Set(flatten(lt))
|
||||
expect(enKeys.length).toBeGreaterThan(0)
|
||||
for (const key of enKeys) expect(ltKeys.has(key), `agent.${key} has no Lithuanian message`).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// What the generated tenant-isolation suite needs of this module: seeds and one fixture per
|
||||
// tenant-scoped procedure, built from the OTHER tenant's ids.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
|
||||
export interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const inWorkspaceOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug })
|
||||
const inProjectOf = (t: IsolationTenant) => ({ ...inWorkspaceOf(t), project: t.projectSlug })
|
||||
const runOf = (t: IsolationTenant) => t.extra.agentRunId ?? NOWHERE
|
||||
|
||||
const PLAN = [{ key: "read", label: "Read", tool: "documents.read", args: {} }]
|
||||
|
||||
export const isolation: ModuleIsolation = {
|
||||
async seed(call, inProject) {
|
||||
const started = (await call.callByName("agent.run.start", { ...inProject, goal: "Seed run", plan: PLAN })) as {
|
||||
runId: string
|
||||
}
|
||||
return { agentRunId: started.runId }
|
||||
},
|
||||
fixtures: {
|
||||
"agent.run.start": { input: (t) => ({ ...inProjectOf(t), goal: "Intruder", plan: PLAN }) },
|
||||
"agent.run.status": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.events": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.cancel": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.list": { input: (t) => inProjectOf(t) },
|
||||
// The live collection's read-back: another tenant's run id answers nothing.
|
||||
"agent.run.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [runOf(t)] }) },
|
||||
"agent.approval.list": { input: (t) => inProjectOf(t) },
|
||||
"agent.approval.review": {
|
||||
input: (t) => ({ ...inProjectOf(t), runId: runOf(t), stepKey: "read", decision: "approve" }),
|
||||
},
|
||||
// The conversation behind the Agent in the right panel (kit-agent's assistant module, mounted by this one).
|
||||
"assistant.turn.send": { input: (t) => ({ ...inProjectOf(t), turnId: NOWHERE, message: "Intruder" }) },
|
||||
"assistant.conversation.list": { input: (t) => inProjectOf(t) },
|
||||
"assistant.rootAction.reconcile": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
|
||||
"assistant.conversation.get": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
|
||||
"assistant.receipt.list": { input: (t) => inProjectOf(t) },
|
||||
// The Agent's own workspace reads, offered to a turn with no project open.
|
||||
"assistant.workspace.projects": { input: (t) => inWorkspaceOf(t) },
|
||||
"assistant.workspace.openProject": { input: (t) => ({ ...inWorkspaceOf(t), slug: t.projectSlug }) },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// The run page's live island: what it shows from the server's seed before the first event, and
|
||||
// from the run's AG-UI events (the projection `agent.run.events` serves, folded by the TanStack AI
|
||||
// client's reducer) after it — step states, a parked approval, the completed run's answer with its
|
||||
// Art. 50 disclosure, and a lost stream. Server-rendered, the island is exactly the seed.
|
||||
|
||||
import type { AgentRunRecord, AgentStepRecord } from "@conusai-kit/agent"
|
||||
import { runEventsOf } from "@conusai-kit/agent/agui"
|
||||
import { type AgUiChunk, initialAgentRunView, reduceAgentRunEvent } from "@conusai-kit/agent/client"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
import { LiveRun } from "../ui/live-run.js"
|
||||
import { liveRunState } from "../ui/live-run-state.js"
|
||||
|
||||
const RUN = "01a19940-0000-7000-0000-000000000001"
|
||||
const PLAN = [
|
||||
{ key: "read", label: "Read", tool: "documents.read", args: {} },
|
||||
{ key: "order", label: "Order", tool: "orders.create", args: { total: 4200 }, needsApproval: true },
|
||||
]
|
||||
|
||||
const seed: RunView = {
|
||||
runId: RUN,
|
||||
goal: "Order the parts",
|
||||
status: "queued",
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
plan: PLAN.map((step) => ({
|
||||
...step,
|
||||
needsApproval: step.needsApproval === true,
|
||||
status: "pending" as const,
|
||||
attempts: 0,
|
||||
error: null,
|
||||
})),
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
percent: 0,
|
||||
cancelRequested: false,
|
||||
answer: null,
|
||||
answerProvenance: null,
|
||||
actorId: "user-1",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
}
|
||||
|
||||
const record = (status: AgentRunRecord["status"]): AgentRunRecord => ({
|
||||
id: RUN,
|
||||
version: 1,
|
||||
status,
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
goal: seed.goal,
|
||||
plan: PLAN,
|
||||
actorId: "user-1",
|
||||
budget: { maxCostMicrocents: 1_000_000, maxSteps: 10, maxDurationSeconds: 600 },
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: "2026-11-02T10:00:01.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: seed.createdAt,
|
||||
answerProvenance: null,
|
||||
})
|
||||
|
||||
const step = (
|
||||
index: number,
|
||||
status: AgentStepRecord["status"],
|
||||
approval: AgentStepRecord["approval"] = null,
|
||||
): AgentStepRecord => ({
|
||||
id: `s-${index}`,
|
||||
runId: RUN,
|
||||
key: PLAN[index]?.key ?? "missing",
|
||||
index,
|
||||
label: PLAN[index]?.label ?? "missing",
|
||||
status,
|
||||
attempts: 1,
|
||||
usageCostMicrocents: 0,
|
||||
usageTokens: 0,
|
||||
error: null,
|
||||
externalEffect: false,
|
||||
artifacts: [],
|
||||
result: null,
|
||||
approval,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
})
|
||||
|
||||
const parked = {
|
||||
actionId: null,
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200 },
|
||||
reviewUrl: null,
|
||||
interrupt: { id: "approval_c-1", runId: `${RUN}.chat` },
|
||||
decision: null,
|
||||
}
|
||||
|
||||
const fold = (events: readonly unknown[]) =>
|
||||
(events as AgUiChunk[]).reduce(reduceAgentRunEvent, initialAgentRunView(RUN))
|
||||
|
||||
describe("the live run state", () => {
|
||||
it("is the seed until the first event", () => {
|
||||
const state = liveRunState(seed, initialAgentRunView(RUN))
|
||||
expect(state.status).toBe("queued")
|
||||
expect(state.steps.map((s) => s.status)).toEqual(["pending", "pending"])
|
||||
expect(state.answer).toBeNull()
|
||||
})
|
||||
|
||||
it("follows the run's events: a committed step, then a parked approval", () => {
|
||||
const view = fold(runEventsOf(record("awaiting_approval"), [step(0, "committed"), step(1, "pending", parked)]))
|
||||
const state = liveRunState(seed, view)
|
||||
expect(state.status).toBe("awaiting_approval")
|
||||
expect(state.steps.map((s) => s.status)).toEqual(["committed", "pending"])
|
||||
expect(state.percent).toBe(0.5)
|
||||
})
|
||||
|
||||
it("shows a completed run's answer and a failed run's code", () => {
|
||||
const done = fold(
|
||||
runEventsOf(
|
||||
record("completed"),
|
||||
[step(0, "committed"), step(1, "committed", { ...parked, decision: "approved" })],
|
||||
"Ordered.",
|
||||
),
|
||||
)
|
||||
expect(liveRunState(seed, done)).toMatchObject({ status: "completed", percent: 1, answer: "Ordered." })
|
||||
const failed = fold(
|
||||
runEventsOf(
|
||||
{ ...record("failed"), failureCode: "budget_exhausted", failureDetail: "the run reached its budget" },
|
||||
[step(0, "committed")],
|
||||
),
|
||||
)
|
||||
expect(liveRunState(seed, failed)).toMatchObject({
|
||||
status: "failed",
|
||||
failureCode: "budget_exhausted",
|
||||
answer: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("names a lost stream and keeps the last state it knew", () => {
|
||||
const view = {
|
||||
...fold(runEventsOf(record("running"), [step(0, "committed")])),
|
||||
error: { source: "transport" as const, code: "reconnect_limit", message: "gone", status: null },
|
||||
}
|
||||
const state = liveRunState(seed, view)
|
||||
expect(state.streamError).toBe("reconnect_limit")
|
||||
expect(state.steps[0]?.status).toBe("committed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the live run island", () => {
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
|
||||
const t = (key: string) => i18n.message(`agent.${key}`)
|
||||
const labels = {
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
plan: t("runs.plan"),
|
||||
step: t("runs.step"),
|
||||
tool: t("runs.tool"),
|
||||
needsApproval: t("runs.needsApproval"),
|
||||
answer: t("runs.answer"),
|
||||
answerDisclosure: t("runs.answerDisclosure"),
|
||||
streamLost: t("runs.streamLost"),
|
||||
statuses: {
|
||||
queued: t("runs.statusQueued"),
|
||||
running: t("runs.statusRunning"),
|
||||
awaiting_approval: t("runs.statusAwaitingApproval"),
|
||||
completed: t("runs.statusCompleted"),
|
||||
failed: t("runs.statusFailed"),
|
||||
cancelled: t("runs.statusCancelled"),
|
||||
uncertain: t("runs.statusUncertain"),
|
||||
},
|
||||
stepStatuses: {
|
||||
pending: t("runs.stepPending"),
|
||||
committed: t("runs.stepCommitted"),
|
||||
failed: t("runs.stepFailed"),
|
||||
cancelled: t("runs.stepCancelled"),
|
||||
uncertain: t("runs.stepUncertain"),
|
||||
},
|
||||
controls: {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
},
|
||||
}
|
||||
const render = (run: RunView) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<LiveRun slugs={{ org: "acme", workspace: "ws", project: "p" }} seed={run} labels={labels} />
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
it("renders the seed on the server, the approval mark on a gated step, and no answer yet", () => {
|
||||
const html = render(seed)
|
||||
expect(html).toContain(labels.statuses.queued)
|
||||
expect(html).toContain(labels.needsApproval)
|
||||
expect(html).not.toContain(labels.answer)
|
||||
})
|
||||
|
||||
it("renders a completed run's answer with the AI disclosure", () => {
|
||||
const html = render({ ...seed, status: "completed", answer: "Ordered the parts." })
|
||||
expect(html).toContain("Ordered the parts.")
|
||||
expect(html).toContain(labels.answerDisclosure.replaceAll("'", "'"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it
|
||||
// added (decision AT-D20: the entity definition is the one source of a column). Forward-only: the
|
||||
// first file stays the run and step tables as first shipped; the second adds the step's recorded
|
||||
// tool result and the run's conversation table (`agent_thread`, TanStack AI's message store); the
|
||||
// third adds the run answer's provenance (EU AI Act Art. 50(2)).
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads } from "@conusai-kit/agent"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { agentModule } from "../index.js"
|
||||
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
const migration = (name: string) => readFileSync(new URL(`../migrations/${name}`, import.meta.url), "utf8")
|
||||
const HEADER = "-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n"
|
||||
|
||||
describe("the migrations of the agent module", () => {
|
||||
it("are the run and step tables as first shipped, then the step's result column", () => {
|
||||
const added = ',\n "result" jsonb\n'
|
||||
const provenance = ',\n "answer_provenance" jsonb\n'
|
||||
const generated = tablesOf([agentRuns, agentSteps])
|
||||
expect(generated).toContain(added)
|
||||
expect(generated).toContain(provenance)
|
||||
expect(`${HEADER}${generated.replace(added, "\n").replace(provenance, "\n")}`).toBe(migration("0001_agent.sql"))
|
||||
expect(migration("0002_agent_thread.sql")).toContain(
|
||||
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it("add the run's conversation table in the second file, exactly its table", async () => {
|
||||
await expect(
|
||||
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n${tablesOf([agentThreads])}`,
|
||||
).toMatchFileSnapshot("../migrations/0002_agent_thread.sql")
|
||||
expect(AGENT_ENTITIES).toEqual([agentRuns, agentSteps, agentThreads])
|
||||
})
|
||||
|
||||
it("add the run answer's provenance column in the third file, exactly that column", () => {
|
||||
expect(tablesOf([agentRuns])).toContain(',\n "answer_provenance" jsonb\n')
|
||||
expect(migration("0003_agent_answer_provenance.sql")).toBe(
|
||||
`${HEADER}alter table "agent_run" add column "answer_provenance" jsonb;\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it("are declared by the manifest, and every table is row-level secured", () => {
|
||||
expect(agentModule.migrations).toEqual({ postgres: "migrations" })
|
||||
const sql = migration("0001_agent.sql") + migration("0002_agent_thread.sql")
|
||||
for (const table of ["agent_run", "agent_step", "agent_thread"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// The agent page on F10: its loader reads through the caller and returns plain data with every label
|
||||
// translated, and its view renders what reached the browser, the run page's first paint being the seed of
|
||||
// the live island. A path that is not one run is the kernel's 404, which is what the shell answers.
|
||||
|
||||
import { AppError, buildRegistry, Container, forbidden, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import type { ApprovalView, RunView } from "../contract/index.js"
|
||||
import { agentPage } from "../ui/pages.js"
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { agent: catalogues.en?.agent ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const run: RunView = {
|
||||
runId: "01a19940-0000-7000-0000-000000000001",
|
||||
goal: "Order the parts",
|
||||
status: "running",
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
plan: [
|
||||
{
|
||||
key: "read",
|
||||
label: "Read",
|
||||
tool: "documents.read",
|
||||
args: {},
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
usageCostMicrocents: 120,
|
||||
usageSteps: 3,
|
||||
percent: 0.5,
|
||||
cancelRequested: false,
|
||||
answer: null,
|
||||
answerProvenance: null,
|
||||
actorId: "user-1",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
startedAt: "2026-11-02T10:00:01.000Z",
|
||||
finishedAt: null,
|
||||
}
|
||||
|
||||
const approval: ApprovalView = {
|
||||
runId: run.runId,
|
||||
stepKey: "read",
|
||||
goal: run.goal,
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200 },
|
||||
reviewUrl: null,
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
}
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>, asked: string[] = []): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
asked.push(contract.name)
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
const load = (segments: string[], answers: Record<string, unknown>, asked?: string[]) =>
|
||||
agentPage.load({ slugs, segments, search: {}, caller: callerOf(answers, asked), i18n, container: new Container() })
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>) => {
|
||||
const data = await load(segments, answers)
|
||||
expect(JSON.parse(JSON.stringify(data))).toStrictEqual(data)
|
||||
const { View } = agentPage
|
||||
return renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />
|
||||
</DataProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const lists = { "agent.run.list": { runs: [run] }, "agent.approval.list": { approvals: [approval] } }
|
||||
|
||||
describe("the agent page", () => {
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.agent?.()).resolves.toBe(agentPage)
|
||||
})
|
||||
|
||||
it("lists the runs, each with its status in words, its progress and a link to it", async () => {
|
||||
const html = await render([], lists)
|
||||
const link = html.match(/<a\b[^>]*>Order the parts<\/a>/)?.[0] ?? ""
|
||||
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
|
||||
expect(link).toContain(`href="/acme/ws/p/agent/${run.runId}"`)
|
||||
expect(link).toContain('data-slot="row-link"')
|
||||
expect(html).toContain('<progress value="0.5" max="1">50%</progress>')
|
||||
expect(html).toContain("user-1")
|
||||
expect(html).toContain("orders.create")
|
||||
})
|
||||
|
||||
it("says when there is no run", async () => {
|
||||
const data = await load([], { "agent.run.list": { runs: [] }, "agent.approval.list": { approvals: [] } })
|
||||
if (data.kind !== "list") throw new Error("not the list")
|
||||
expect(data.runs).toEqual([])
|
||||
expect(await render([], { ...lists, "agent.run.list": { runs: [] } })).toContain(data.runLabels.empty)
|
||||
})
|
||||
|
||||
it("renders one run from its status, the seed of the live island", async () => {
|
||||
const asked: string[] = []
|
||||
const html = await render([run.runId], { "agent.run.status": run })
|
||||
await load([run.runId], { "agent.run.status": run }, asked)
|
||||
expect(asked).toEqual(["agent.run.status"])
|
||||
expect(html).toContain(">Order the parts</h1>")
|
||||
expect(html).toContain('href="/acme/ws/p/agent"')
|
||||
expect(html).toContain("<dt>Usage</dt><dd>3 steps, 120 microcents</dd>")
|
||||
expect(html).toContain('data-live="agent.run"')
|
||||
})
|
||||
|
||||
it("answers a path that is not one run with the kernel's 404, without reading", async () => {
|
||||
const asked: string[] = []
|
||||
const refused = load([run.runId, "extra"], lists, asked)
|
||||
await expect(refused).rejects.toBeInstanceOf(AppError)
|
||||
await expect(refused).rejects.toMatchObject({ status: 404 })
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it("hands a procedure's refusal on, as the AppError the shell answers", async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw forbidden("this page needs agent.run.read")
|
||||
})
|
||||
await expect(
|
||||
agentPage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: { call } as unknown as TransportCaller,
|
||||
i18n,
|
||||
container: new Container(),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 })
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../../inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../inclusion.js")>()),
|
||||
...(await import("../../../test/support/native-readiness.js")).selectedModules(["agent"]),
|
||||
}))
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Follow the offered step using a public gateway adapter with an in-process transport.
|
||||
// No network/provider call and no SDK-private or transitive TanStack testing import.
|
||||
import { conusaiText } from "@conusai-sdk/ai/tanstack"
|
||||
|
||||
export function planFollower() {
|
||||
return conusaiText("scripted-agent-test", {
|
||||
apiUrl: "https://gateway.example.invalid/api",
|
||||
token: "synthetic-test-token",
|
||||
maxRetries: 0,
|
||||
fetch: async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { tool_choice?: { function?: { name?: string } } }
|
||||
const step = body.tool_choice?.function?.name
|
||||
const delta =
|
||||
step === undefined
|
||||
? { role: "assistant", content: "Done." }
|
||||
: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } },
|
||||
],
|
||||
}
|
||||
const chunk = {
|
||||
id: "synthetic-completion",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "scripted-agent-test",
|
||||
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
|
||||
}
|
||||
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// The run retention sweep of the agent module (G3j): the hourly job deletes a terminal run past the
|
||||
// retention with its steps and conversation, in each project the app serves, and keeps a run still going,
|
||||
// a fresher one and another organization's. The job is the module's own (adapters/procedures.ts), so it runs
|
||||
// on whatever data source the worker binds; this test uses the in-memory one.
|
||||
|
||||
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads, threadIdOf } from "@conusai-kit/agent"
|
||||
import { Container, type TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, type DataSource } from "@conusai-kit/data"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { RUN_EXPIRE_JOB } from "../contract/constants.js"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { AGENT_JOBS, agentSchedules } from "../index.js"
|
||||
|
||||
const DAY = 86_400_000
|
||||
const NOW = Date.parse("2026-11-02T12:00:00.000Z")
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const ORG_A = "01900000-0000-7000-0000-000000000001"
|
||||
const ORG_B = "01900000-0000-7000-0000-00000000000b"
|
||||
const WORKSPACE = "01900000-0000-7000-0000-000000000002"
|
||||
const PROJECT = "01900000-0000-7000-0000-000000000003"
|
||||
const scopeOf = (orgId: string): TenantScope & { workspaceId: string; projectId: string } => ({
|
||||
level: "project",
|
||||
actor: { userId: USER as never, system: true },
|
||||
orgId,
|
||||
workspaceId: WORKSPACE,
|
||||
projectId: PROJECT,
|
||||
})
|
||||
const daysAgo = (days: number): string => new Date(NOW - days * DAY).toISOString()
|
||||
|
||||
async function seed(data: DataSource, orgId: string, status: string, finishedDaysAgo: number | null): Promise<string> {
|
||||
const repos = data.forScope(scopeOf(orgId))
|
||||
const run = await repos.of(agentRuns).insert({
|
||||
goal: "Review the contract",
|
||||
plan: [{ key: "read", label: "Read", tool: "documents.read", args: {} }],
|
||||
status,
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
actorId: USER,
|
||||
budgetCostMicrocents: 1_000_000,
|
||||
budgetSteps: 10,
|
||||
budgetSeconds: 300,
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: daysAgo((finishedDaysAgo ?? 1) + 1),
|
||||
finishedAt: finishedDaysAgo === null ? null : daysAgo(finishedDaysAgo),
|
||||
} as never)
|
||||
await repos.of(agentSteps).insert({
|
||||
runId: run.id,
|
||||
key: "read",
|
||||
index: 0,
|
||||
label: "Read",
|
||||
status: "committed",
|
||||
attempts: 1,
|
||||
usageCostMicrocents: 0,
|
||||
usageTokens: 0,
|
||||
error: null,
|
||||
externalEffect: false,
|
||||
artifacts: [],
|
||||
approval: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
result: null,
|
||||
} as never)
|
||||
await repos.of(agentThreads).insert({ threadId: threadIdOf(run.id), messages: [] } as never)
|
||||
return run.id
|
||||
}
|
||||
|
||||
/** The retention job, as the module registers it. */
|
||||
function expireJob() {
|
||||
const job = AGENT_JOBS[RUN_EXPIRE_JOB]
|
||||
if (!job) throw new Error("the agent module registers no run retention job")
|
||||
return job
|
||||
}
|
||||
|
||||
const runIdsOf = async (data: DataSource, orgId: string): Promise<string[]> =>
|
||||
(await data.forScope(scopeOf(orgId)).of(agentRuns).list({})).items.map((run) => run.id).sort()
|
||||
|
||||
/** The job as the worker runs it: its container binds the data source and the project scope it runs in. */
|
||||
function containerFor(data: DataSource, orgId: string): Container {
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(AGENT_SCOPE, scopeOf(orgId))
|
||||
return container
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe("the agent run retention job", () => {
|
||||
it("deletes a terminal run past the retention with its steps and conversation, and keeps the rest", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW)
|
||||
const data = createMemoryDataSource({ entities: [...AGENT_ENTITIES] })
|
||||
await seed(data, ORG_A, "completed", 100)
|
||||
await seed(data, ORG_A, "failed", 91)
|
||||
const fresh = await seed(data, ORG_A, "completed", 89)
|
||||
const running = await seed(data, ORG_A, "running", null)
|
||||
const otherOrg = await seed(data, ORG_B, "completed", 400)
|
||||
|
||||
await expireJob().handler(
|
||||
{ retentionDays: 90 },
|
||||
{ container: containerFor(data, ORG_A), correlationId: "t", attempt: 1 },
|
||||
)
|
||||
|
||||
expect(await runIdsOf(data, ORG_A)).toEqual([fresh, running].sort())
|
||||
expect((await data.forScope(scopeOf(ORG_A)).of(agentSteps).list({})).items.map((s) => s.runId).sort()).toEqual(
|
||||
[fresh, running].sort(),
|
||||
)
|
||||
expect((await data.forScope(scopeOf(ORG_A)).of(agentThreads).list({})).items.map((t) => t.threadId).sort()).toEqual(
|
||||
[fresh, running].map(threadIdOf).sort(),
|
||||
)
|
||||
// Negative control: another organization's run, past the same retention, is untouched in its own project.
|
||||
expect(await runIdsOf(data, ORG_B)).toEqual([otherOrg])
|
||||
})
|
||||
|
||||
it("takes its retention from the schedule's payload, which accepts only whole days from 1 to 3650", () => {
|
||||
const payload = expireJob().payload
|
||||
expect(payload.safeParse({ retentionDays: 90 }).success).toBe(true)
|
||||
expect(payload.safeParse({ retentionDays: 0 }).success).toBe(false)
|
||||
expect(payload.safeParse({ retentionDays: 3651 }).success).toBe(false)
|
||||
expect(payload.safeParse({ retentionDays: 2.5 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it("is scheduled hourly, once per project, with the retention it was given", () => {
|
||||
expect(agentSchedules(90)).toEqual([
|
||||
{
|
||||
job: RUN_EXPIRE_JOB,
|
||||
cron: "0 * * * *",
|
||||
scope: { level: "each-project" },
|
||||
payload: { retentionDays: 90 },
|
||||
key: "agent.runs.expire",
|
||||
missed: "skip",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
// The run job over the real tools adapter, MCP client and gateway adapter; only the platform is
|
||||
// answered, at `fetch`. What it pins is the line `StepOutcome` draws: a call a retry cannot fix ends
|
||||
// the run with a typed failure and the job completes, so the queue does not run it again, while a
|
||||
// transient failure rejects, so the queue does, and the step resumes on its idempotency key. A tool
|
||||
// that answers an error is never a committed step: the model would otherwise read the error as the
|
||||
// tool's result and the run would end `completed`.
|
||||
|
||||
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
|
||||
import type { JobContext } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { gatewayAgentModel, registryAgentTools, runJob } from "../adapters/procedures.js"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { cancelRun, startRun } from "../core/runs.js"
|
||||
import { claimedDataSource, claimedDelivery } from "./claimed-job.js"
|
||||
import { workerFixture } from "./worker-fixture.js"
|
||||
|
||||
const API_URL = "https://cloud.example.test/api"
|
||||
const MCP_URL = "https://cloud.example.test/v1/orgs/7/mcp"
|
||||
const GATEWAY_URL = `${API_URL}/ai/v1/chat/completions`
|
||||
const TOKEN = "dt_synthetic_fixture"
|
||||
const TOOL_TEXT = "the backing service said something only its tenant may read"
|
||||
|
||||
interface ToolCall {
|
||||
readonly name: string
|
||||
readonly idempotencyKey: string
|
||||
readonly authorization: string | null
|
||||
}
|
||||
type Mcp = (id: number) => Response
|
||||
|
||||
const rpc =
|
||||
(result: unknown): Mcp =>
|
||||
(id) =>
|
||||
Response.json({ jsonrpc: "2.0", id, result })
|
||||
const answered = rpc({
|
||||
resultType: "complete",
|
||||
isError: false,
|
||||
structuredContent: { rows: 3 },
|
||||
content: [{ type: "text", text: '{"rows":3}' }],
|
||||
})
|
||||
const denied = (reason: string) =>
|
||||
rpc({
|
||||
resultType: "complete",
|
||||
isError: true,
|
||||
structuredContent: { status: "denied", reason },
|
||||
content: [{ type: "text", text: `refused: ${reason}` }],
|
||||
})
|
||||
const status =
|
||||
(code: number): Mcp =>
|
||||
() =>
|
||||
new Response(null, { status: code })
|
||||
|
||||
/** The gateway as a model that follows the tool choice: it calls the step offered, then answers. */
|
||||
function gatewayReply(body: { tool_choice?: unknown }): Response {
|
||||
const choice = body.tool_choice
|
||||
const step =
|
||||
typeof choice === "object" && choice !== null
|
||||
? (choice as { function?: { name?: string } }).function?.name
|
||||
: undefined
|
||||
const delta =
|
||||
step === undefined
|
||||
? { role: "assistant", content: "Done." }
|
||||
: {
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } }],
|
||||
}
|
||||
const chunk = {
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gateway-model",
|
||||
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
|
||||
}
|
||||
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
/** A queued run of one step, and a platform whose tool endpoint answers with `tool.mcp`. */
|
||||
async function harness(toolName = "records__read") {
|
||||
const tool: { mcp: Mcp; calls: ToolCall[] } = { mcp: answered, calls: [] }
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const body = JSON.parse(String(init?.body ?? "{}"))
|
||||
if (url === GATEWAY_URL) return gatewayReply(body)
|
||||
if (url !== MCP_URL) throw new Error(`no such stand-in: ${url}`)
|
||||
tool.calls.push({
|
||||
name: body.params.name,
|
||||
idempotencyKey: body.params._meta["com.conusai/idempotencyKey"],
|
||||
authorization: new Headers(init?.headers).get("authorization"),
|
||||
})
|
||||
return tool.mcp(body.id)
|
||||
}),
|
||||
)
|
||||
const data = await claimedDataSource()
|
||||
const { container, scope } = await workerFixture(data)
|
||||
container.value(AGENT_SCOPE, scope)
|
||||
container.value(AGENT_MODEL, gatewayAgentModel({ apiUrl: API_URL, token: TOKEN, model: "gateway-model" }))
|
||||
container.value(
|
||||
AGENT_TOOLS,
|
||||
registryAgentTools({
|
||||
apiUrl: API_URL,
|
||||
token: TOKEN,
|
||||
orgId: "7",
|
||||
scope: { orgSlug: "acme", workspaceSlug: "main", projectSlug: "p" },
|
||||
}),
|
||||
)
|
||||
const started = await startRun({ data }, scope, {
|
||||
goal: "Read the records",
|
||||
plan: [{ key: "read", label: "Read the records", tool: toolName, args: { limit: 1 } }],
|
||||
})
|
||||
const store = dataRunStore(data, scope)
|
||||
return {
|
||||
tool,
|
||||
data,
|
||||
scope,
|
||||
container,
|
||||
runId: started.runId,
|
||||
/** One delivery of the job: a rejection is what makes the queue deliver it again. */
|
||||
deliver: (extra: Partial<JobContext> = {}) => claimedDelivery(data, container, scope, started.runId, extra),
|
||||
cancel: () => cancelRun({ data }, scope, started.runId),
|
||||
run: () => store.get(started.runId),
|
||||
step: async () => (await store.listSteps(started.runId))[0],
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("the agent run job's tool calls", () => {
|
||||
it("calls the tool with the deployment token and completes the run", async () => {
|
||||
const job = await harness()
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toEqual([
|
||||
{ name: "records__read", idempotencyKey: `${job.runId}:read`, authorization: `Bearer ${TOKEN}` },
|
||||
])
|
||||
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "committed", attempts: 1, result: { rows: 3 } })
|
||||
})
|
||||
|
||||
it.each<[string, Mcp, string]>([
|
||||
["a token without the grant (403)", status(403), "forbidden"],
|
||||
["a token the endpoint does not accept (401)", status(401), "unauthenticated"],
|
||||
["a policy denial", denied("forbidden"), "forbidden"],
|
||||
["a plan denial", denied("plan-required"), "plan-required"],
|
||||
])("ends the run failed/tool_error on %s, and the queue does not run it again", async (_case, mcp, reason) => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = mcp
|
||||
await expect(job.deliver()).resolves.toBeUndefined()
|
||||
const run = await job.run()
|
||||
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error" })
|
||||
expect(run?.failureDetail).toContain(reason)
|
||||
expect(run?.finishedAt).not.toBeNull()
|
||||
expect(await job.step()).toMatchObject({ status: "failed", attempts: 1, result: null })
|
||||
// A redelivery reaches a terminal run: no second call.
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not resume a refused run when the grant is approved later", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(403)
|
||||
await job.deliver()
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "tool_error" })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not resume a cancelled run: a run stuck on a transient failure can be ended by a person", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(503)
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
await job.cancel()
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "cancelled", failureCode: null })
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not commit a step whose tool answered an error, and keeps the tool's text out of the run", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = rpc({ resultType: "complete", isError: true, content: [{ type: "text", text: TOOL_TEXT }] })
|
||||
await expect(job.deliver()).resolves.toBeUndefined()
|
||||
const run = await job.run()
|
||||
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error", usageSteps: 0 })
|
||||
expect(run?.failureDetail).toContain("records__read")
|
||||
expect(run?.failureDetail).not.toContain(TOOL_TEXT)
|
||||
const step = await job.step()
|
||||
expect(step).toMatchObject({ status: "failed", result: null })
|
||||
expect(step?.error).not.toContain(TOOL_TEXT)
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each<[string, Mcp, string?]>([
|
||||
["a 503", status(503)],
|
||||
["a 429", status(429)],
|
||||
["a 500", status(500)],
|
||||
[
|
||||
"busy local compute",
|
||||
rpc({
|
||||
resultType: "complete",
|
||||
isError: true,
|
||||
structuredContent: { status: "compute_busy", retryAfterSeconds: 5 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a catalog that is still indexing",
|
||||
rpc({ resultType: "complete", isError: true, structuredContent: { status: "indexing", retryAfterSeconds: 5 } }),
|
||||
"find_tools",
|
||||
],
|
||||
["a runtime the platform could not reach", denied("runtime-unavailable")],
|
||||
])("leaves the run to the queue on %s, and the next delivery resumes the step", async (_case, mcp, toolName) => {
|
||||
const job = await harness(toolName)
|
||||
job.tool.mcp = mcp
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "pending", attempts: 1 })
|
||||
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "committed", attempts: 2 })
|
||||
// The same key both times: an effect the first call made is not made twice.
|
||||
expect(new Set(job.tool.calls.map((call) => call.idempotencyKey))).toEqual(new Set([`${job.runId}:read`]))
|
||||
})
|
||||
|
||||
it("leaves the run to the queue when the network fails", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
await expect(job.deliver()).rejects.toThrow("fetch failed")
|
||||
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
|
||||
})
|
||||
})
|
||||
|
||||
it("persists sanitized exhaustion on the actual final attempt, with no replay calls", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(503)
|
||||
await job.deliver({ finalAttempt: true })
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
|
||||
expect((await job.run())?.failureDetail).toBe("the durable job exhausted its retry attempts")
|
||||
await job.deliver({ finalAttempt: true })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("a revoked initiating member is refused before any provider call despite the warm memo", async () => {
|
||||
const job = await harness()
|
||||
const { orgMemberships } = await import("@conusai-kit/tenancy")
|
||||
const repos = job.data.forScope(job.scope)
|
||||
const membership = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!membership) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).delete(membership.id)
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("revocation after a tool prevents the next model call", async () => {
|
||||
const job = await harness()
|
||||
const { orgMemberships } = await import("@conusai-kit/tenancy")
|
||||
const original = job.container.resolve(AGENT_TOOLS)
|
||||
const child = job.container.child()
|
||||
child.value(AGENT_TOOLS, {
|
||||
async call(...args) {
|
||||
const result = await original.call(...args)
|
||||
const repos = job.data.forScope(job.scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).delete(member.id)
|
||||
return result
|
||||
},
|
||||
})
|
||||
await job.deliver({ container: child })
|
||||
expect(await job.run()).toMatchObject({ status: "failed", usageSteps: 1 })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === GATEWAY_URL)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("missing adapter execution refuses before durable or provider work even at exhaustion", async () => {
|
||||
const job = await harness()
|
||||
await expect(
|
||||
runJob.handler(
|
||||
{ runId: job.runId },
|
||||
{
|
||||
container: job.container,
|
||||
correlationId: "unsupported-adapter",
|
||||
attempt: 1,
|
||||
finalAttempt: true,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect((await job.run())?.status).toBe("queued")
|
||||
expect(job.tool.calls).toHaveLength(0)
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("an actual claim with an unsupported data source hook refuses before Agent effects", async () => {
|
||||
const job = await harness()
|
||||
const child = job.container.child()
|
||||
child.value(DATA_SOURCE, createMemoryDataSource({ entities: [] }))
|
||||
await expect(job.deliver({ container: child, finalAttempt: true })).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect((await job.run())?.status).toBe("queued")
|
||||
expect(job.tool.calls).toHaveLength(0)
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
// The agent module's use cases on the in-memory adapter: starting lists and statuses, cancel's
|
||||
// resume event, and the approval queue — which must surface the EXACT stored proposed arguments.
|
||||
|
||||
import { AGENT_ENTITIES, dataAgentPersistence, dataRunStore, threadIdOf } from "@conusai-kit/agent"
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { cancelRun, getManyRuns, getRun, listApprovals, listRuns, reviewApproval, startRun } from "../core/runs.js"
|
||||
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: USER },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const makeDeps = () => ({ data: createMemoryDataSource({ entities: [...AGENT_ENTITIES] }) })
|
||||
|
||||
const PLAN = [
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "f-1" } },
|
||||
{ key: "propose", label: "Propose lines", tool: "documents.extract", args: { fileId: "f-1", schema: "line" } },
|
||||
]
|
||||
|
||||
describe("the agent module's run use cases", () => {
|
||||
const deps = makeDeps()
|
||||
it("starts a run queued, with the plan visible, and lists it", async () => {
|
||||
const run = await startRun(deps, scope, { goal: "Review the contract", plan: PLAN })
|
||||
expect(run.status).toBe("queued")
|
||||
expect(run.goal).toBe("Review the contract")
|
||||
expect(run.plan).toEqual([
|
||||
{
|
||||
key: "read",
|
||||
label: "Read the document",
|
||||
tool: "documents.read",
|
||||
args: { fileId: "f-1" },
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
key: "propose",
|
||||
label: "Propose lines",
|
||||
tool: "documents.extract",
|
||||
args: { fileId: "f-1", schema: "line" },
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
expect(run.percent).toBe(0)
|
||||
expect(run.answer).toBeNull()
|
||||
const listed = await listRuns(deps, scope, {})
|
||||
expect(listed.runs).toHaveLength(1)
|
||||
expect((await getRun(deps, scope, run.runId)).runId).toBe(run.runId)
|
||||
// The outbox event that enqueues the run job in the worker.
|
||||
const pending = await deps.data.outbox.pending(10)
|
||||
expect(pending.some((record) => record.name === "agent.runResumeRequested")).toBe(true)
|
||||
})
|
||||
|
||||
it("reads runs back by id for the live list, and answers nothing for another project's or no run", async () => {
|
||||
const own = makeDeps()
|
||||
const run = await startRun(own, scope, { goal: "Read back", plan: PLAN })
|
||||
const listed = (await listRuns(own, scope, {})).runs
|
||||
expect(await getManyRuns(own, scope, [run.runId, run.runId])).toEqual(listed)
|
||||
const elsewhere: TenantScope = { ...scope, projectId: "01900000-0000-7000-0000-000000000004" }
|
||||
expect(await getManyRuns(own, elsewhere, [run.runId])).toEqual([])
|
||||
expect(await getManyRuns(own, scope, ["01900000-0000-7000-0000-0000000000ff"])).toEqual([])
|
||||
})
|
||||
|
||||
it("answers a completed run's answer with the provenance it is stored with (AI Act Art. 50(2))", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Review the contract", plan: PLAN })
|
||||
expect(run.answerProvenance).toBeNull()
|
||||
const provenance = {
|
||||
ai_generated: true as const,
|
||||
mode: "machine-generated" as const,
|
||||
digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
|
||||
generator: "conusai.ai-gateway",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
generated_at: "2026-11-02T10:30:00Z",
|
||||
}
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
const record = await store.get(run.runId)
|
||||
await store.transition(run.runId, record?.version ?? 0, {
|
||||
status: "completed",
|
||||
finishedAt: "2026-11-02T10:30:01.000Z",
|
||||
answerProvenance: provenance,
|
||||
})
|
||||
await dataAgentPersistence(fresh.data, scope).stores.messages.saveThread(threadIdOf(run.runId), [
|
||||
{ role: "user", content: "Review the contract" },
|
||||
{ role: "assistant", content: "The contract was reviewed." },
|
||||
])
|
||||
const completed = await getRun(fresh, scope, run.runId)
|
||||
expect(completed.answer).toBe("The contract was reviewed.")
|
||||
expect(completed.answerProvenance).toEqual(provenance)
|
||||
})
|
||||
|
||||
it("refuses an over-long or repeated plan before anything is written", async () => {
|
||||
const fresh = makeDeps()
|
||||
const tooLong = await startRun(fresh, scope, {
|
||||
goal: "x",
|
||||
plan: Array.from({ length: 26 }, (_, at) => ({ key: `s${at}`, label: `Step ${at}`, tool: "t", args: {} })),
|
||||
}).catch((error: unknown) => error)
|
||||
expect((tooLong as { status?: number }).status).toBe(422)
|
||||
const repeated = await startRun(fresh, scope, {
|
||||
goal: "x",
|
||||
plan: [
|
||||
{ key: "same", label: "One", tool: "t", args: {} },
|
||||
{ key: "same", label: "Two", tool: "t", args: {} },
|
||||
],
|
||||
}).catch((error: unknown) => error)
|
||||
expect((repeated as { status?: number }).status).toBe(422)
|
||||
expect((await listRuns(fresh, scope, {})).runs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("cancel persists the request and re-enqueues the resume; a parked approval surfaces its exact arguments", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Approve-aware run", plan: PLAN })
|
||||
// Simulate the engine parking an approval by writing the step row as the engine would.
|
||||
const { dataRunStore } = await import("@conusai-kit/agent")
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
|
||||
await store.parkStepApproval(
|
||||
run.runId,
|
||||
{ key: "read", index: 0, label: "Read the document" },
|
||||
{
|
||||
actionId: "act-7",
|
||||
tool: "documents.read",
|
||||
arguments: { fileId: "f-1", dryRun: false },
|
||||
reviewUrl: "https://console/review?action=act-7",
|
||||
interrupt: null,
|
||||
},
|
||||
new Date().toISOString(),
|
||||
)
|
||||
await fresh.data
|
||||
.forScope(scope)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test adjusts the run's status as the engine would.
|
||||
.of(AGENT_ENTITIES[0] as any)
|
||||
.update(run.runId, { status: "awaiting_approval" }, 1)
|
||||
|
||||
const queue = await listApprovals(fresh, scope)
|
||||
expect(queue.approvals).toHaveLength(1)
|
||||
expect(queue.approvals[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
stepKey: "read",
|
||||
tool: "documents.read",
|
||||
arguments: { fileId: "f-1", dryRun: false },
|
||||
reviewUrl: "https://console/review?action=act-7",
|
||||
})
|
||||
|
||||
const cancelled = await cancelRun(fresh, scope, run.runId)
|
||||
expect(cancelled.cancelled).toBe(true)
|
||||
expect((await getRun(fresh, scope, run.runId)).cancelRequested).toBe(true)
|
||||
// The cancel re-published the resume event: a parked run notices the request only when resumed.
|
||||
const pending = await fresh.data.outbox.pending(10)
|
||||
expect(pending.filter((record) => record.name === "agent.runResumeRequested")).toHaveLength(2)
|
||||
|
||||
const review = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" })
|
||||
expect(["queued", "running", "awaiting_approval", "cancelled"]).toContain(review.status)
|
||||
const bad = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "propose", decision: "approve" }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((bad as { status?: number }).status).toBe(422)
|
||||
// The decision is recorded on the step: the queue no longer offers it, and it is not decided twice.
|
||||
expect((await listApprovals(fresh, scope)).approvals).toHaveLength(0)
|
||||
const again = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "reject" }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((again as { status?: number }).status).toBe(409)
|
||||
expect((await store.listSteps(run.runId))[0]?.approval?.decision).toBe("approved")
|
||||
})
|
||||
})
|
||||
|
||||
it("does not offer or accept a pending approval after its run failed", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Synthetic approval", plan: PLAN })
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
await store.parkStepApproval(
|
||||
run.runId,
|
||||
{ key: "read", index: 0, label: "Read" },
|
||||
{
|
||||
actionId: null,
|
||||
tool: "records.write",
|
||||
arguments: {},
|
||||
reviewUrl: null,
|
||||
interrupt: null,
|
||||
},
|
||||
new Date().toISOString(),
|
||||
)
|
||||
const record = await store.get(run.runId)
|
||||
if (!record) throw new Error("expected a run")
|
||||
await store.transition(run.runId, record.version, { status: "failed", failureCode: "budget_exhausted" })
|
||||
expect((await listApprovals(fresh, scope)).approvals).toEqual([])
|
||||
await expect(
|
||||
reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" }),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The real procedure/kernel/tenancy path over memory repositories. No live Cloud/provider claim.
|
||||
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, orgMemberships, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { expect, it } from "vitest"
|
||||
import { AGENT_MODULE_BASE } from "../module.shared.js"
|
||||
|
||||
it("an open stream observes membership revocation and reconnect is refused", async () => {
|
||||
const registry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
const caller = (actor: Actor) => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy }, { actor })
|
||||
}
|
||||
const user = async (subject: string) => {
|
||||
const row = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject,
|
||||
email: `${subject}@example.test`,
|
||||
displayName: subject,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: row.id }
|
||||
}
|
||||
const owner = await user("owner")
|
||||
const member = await user("reader")
|
||||
const call = caller(owner)
|
||||
const { orgId } = (await call.callByName("tenancy.org.create", { slug: "alpha", name: "Alpha" })) as { orgId: string }
|
||||
await call.callByName("tenancy.workspace.create", { org: "alpha", slug: "work", name: "Work", visibility: "org" })
|
||||
await call.callByName("tenancy.project.create", {
|
||||
org: "alpha",
|
||||
workspace: "work",
|
||||
slug: "project",
|
||||
name: "Project",
|
||||
moduleKey: "agent",
|
||||
})
|
||||
await data.unitOfWork.run({ level: "org", actor: owner, orgId }, (tx) =>
|
||||
tx.of(orgMemberships).insert({ userId: member.userId, role: "viewer" }),
|
||||
)
|
||||
const slugs = { org: "alpha", workspace: "work", project: "project" }
|
||||
const run = (await call.callByName("agent.run.start", {
|
||||
...slugs,
|
||||
goal: "Synthetic run",
|
||||
plan: [{ key: "read", label: "Read", tool: "records.read", args: {} }],
|
||||
})) as { runId: string }
|
||||
const response = (await caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })) as Response
|
||||
if (!response.body) throw new Error("expected a streamed response")
|
||||
const reader = response.body.getReader()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain("RUN_STARTED")
|
||||
await caller(owner).callByName("tenancy.member.remove", { org: "alpha", userId: member.userId })
|
||||
expect(await reader.read()).toEqual({ done: true, value: undefined })
|
||||
await expect(caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
// The approval queue's markup: the EXACT stored proposed arguments are rendered (never a
|
||||
// narration), the Art. 50 disclosure rides with them, and the controls are named for a keyboard.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { ApprovalView } from "../contract/index.js"
|
||||
import { ApprovalQueue } from "../ui/approval-queue.js"
|
||||
import { RunControls } from "../ui/run-controls.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = "That did not work: {detail}"
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
{node}
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const approval: ApprovalView = {
|
||||
runId: "r1",
|
||||
stepKey: "read",
|
||||
goal: "Review the contract",
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200, currency: "EUR" },
|
||||
reviewUrl: "https://console/tool-approvals?action=act-1",
|
||||
createdAt: "2026-10-23T00:00:00.000Z",
|
||||
}
|
||||
|
||||
const labels = {
|
||||
heading: t("approvals.heading"),
|
||||
empty: t("approvals.empty"),
|
||||
proposed: t("approvals.proposed"),
|
||||
review: t("approvals.review"),
|
||||
approve: t("approvals.approve"),
|
||||
reject: t("approvals.reject"),
|
||||
decided: t("approvals.decided"),
|
||||
disclosure: t("approvals.disclosure"),
|
||||
failed,
|
||||
}
|
||||
|
||||
describe("the approval queue", () => {
|
||||
const html = inProvider(<ApprovalQueue slugs={slugs} seed={[approval]} labels={labels} />)
|
||||
|
||||
it("renders the exact stored proposed arguments, pretty-printed, with the disclosure", () => {
|
||||
// The exact stored arguments, HTML-escaped as any rendered text is.
|
||||
expect(html).toContain(""total": 4200")
|
||||
expect(html).toContain(""currency": "EUR"")
|
||||
expect(html).toContain(t("approvals.disclosure"))
|
||||
expect(html).toContain(t("approvals.proposed"))
|
||||
})
|
||||
|
||||
it("names the decision controls and links the console review", () => {
|
||||
expect(html).toContain(`>${t("approvals.approve")}<`)
|
||||
expect(html).toContain(`>${t("approvals.reject")}<`)
|
||||
expect(html).toContain('href="https://console/tool-approvals?action=act-1"')
|
||||
})
|
||||
|
||||
it("announces an empty queue", () => {
|
||||
const empty = inProvider(<ApprovalQueue slugs={slugs} seed={[]} labels={labels} />)
|
||||
expect(empty).toContain(t("approvals.empty"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the run controls", () => {
|
||||
const labels = {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
}
|
||||
|
||||
it("offers cancel on an active run and recovery copy on an uncertain one", () => {
|
||||
const active = inProvider(
|
||||
<RunControls slugs={slugs} run={{ runId: "r1", status: "running", failureCode: null }} labels={labels} />,
|
||||
)
|
||||
expect(active).toContain(`>${t("runs.cancel")}<`)
|
||||
const uncertain = inProvider(
|
||||
<RunControls slugs={slugs} run={{ runId: "r1", status: "uncertain", failureCode: null }} labels={labels} />,
|
||||
)
|
||||
expect(uncertain).toContain(
|
||||
t("recovery.uncertainHint")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(String.fromCharCode(34), """)
|
||||
.replaceAll(String.fromCharCode(39), "'"),
|
||||
)
|
||||
expect(uncertain).not.toContain(`>${t("runs.cancel")}<`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, type DataSource, SYSTEM_DATA, type SystemDataSource } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import type { AgentScope } from "../core/ports.js"
|
||||
import { AGENT_MODULE_BASE } from "../module.shared.js"
|
||||
|
||||
export const workerRegistry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
|
||||
|
||||
/** Real app membership/permission resolution, including a retained memo to expose stale use. */
|
||||
export async function workerFixture(data: DataSource & SystemDataSource) {
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
const tenancy = createTenancyHooks({ data, registry: workerRegistry })
|
||||
container.value(TENANCY, tenancy)
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: crypto.randomUUID(),
|
||||
email: "worker@example.test",
|
||||
displayName: "Worker fixture",
|
||||
locale: "en",
|
||||
})
|
||||
const actor = { userId: user.id }
|
||||
const caller = createCaller({ registry: workerRegistry, container, tenancy }, { actor })
|
||||
const org = `worker-${crypto.randomUUID()}`
|
||||
const { orgId } = (await caller.callByName("tenancy.org.create", { slug: org, name: "Worker" })) as { orgId: string }
|
||||
const { workspaceId } = (await caller.callByName("tenancy.workspace.create", {
|
||||
org,
|
||||
slug: "work",
|
||||
name: "Work",
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string }
|
||||
const { projectId } = (await caller.callByName("tenancy.project.create", {
|
||||
org,
|
||||
workspace: "work",
|
||||
slug: "project",
|
||||
name: "Project",
|
||||
moduleKey: "agent",
|
||||
})) as { projectId: string }
|
||||
const scope: AgentScope = { level: "project", actor, orgId, workspaceId, projectId }
|
||||
// Warm the request memo: worker admission must read past this after a revocation.
|
||||
await tenancy.authorize(scope, "agent.run.start", { name: "agent.run.start", kind: "effect" })
|
||||
return { container, scope, tenancy }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Actual nonowner PostgreSQL + app tenancy + Agent job. Scripted model/tools, no live provider.
|
||||
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
|
||||
import { newId } from "@conusai-kit/core"
|
||||
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
|
||||
import { orgMemberships, TENANCY } from "@conusai-kit/tenancy"
|
||||
import { beforeAll, expect, it, vi } from "vitest"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { startRun } from "../core/runs.js"
|
||||
import { type ClaimChange, claimedDataSource, claimedDelivery, terminateClaimSession } from "./claimed-job.js"
|
||||
import { planFollower } from "./plan-model.js"
|
||||
import { workerFixture } from "./worker-fixture.js"
|
||||
|
||||
let data: KyselyDataSource
|
||||
beforeAll(async () => {
|
||||
data = await claimedDataSource()
|
||||
}, 300_000)
|
||||
|
||||
async function fixture(needsApproval = false) {
|
||||
const { container, scope } = await workerFixture(data)
|
||||
// The actual worker binding uses a system actor; stored actorId alone supplies member authority.
|
||||
container.value(AGENT_SCOPE, { ...scope, actor: { userId: "system:agent", system: true } })
|
||||
let models = 0
|
||||
let revokeDuringModel = false
|
||||
let loseDuringModel = false
|
||||
let tools = 0
|
||||
const adapter = planFollower()
|
||||
const stream = adapter.chatStream.bind(adapter)
|
||||
adapter.chatStream = async function* (...args) {
|
||||
models++
|
||||
if (revokeDuringModel) await removeMember()
|
||||
if (loseDuringModel) await terminateClaimSession()
|
||||
yield* stream(...args)
|
||||
}
|
||||
container.value(AGENT_MODEL, adapter)
|
||||
let revoke = false
|
||||
let transient = false
|
||||
const removeMember = async () => {
|
||||
const repos = data.forScope(scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected member")
|
||||
await repos.of(orgMemberships).delete(member.id)
|
||||
}
|
||||
container.value(AGENT_TOOLS, {
|
||||
async call() {
|
||||
tools++
|
||||
if (transient) throw new Error("private upstream failure")
|
||||
if (revoke) await removeMember()
|
||||
return { status: "completed", content: [], usage: { costMicrocents: 0, steps: 1, tokens: 0 } }
|
||||
},
|
||||
})
|
||||
const { runId } = await startRun({ data }, scope, {
|
||||
goal: "Synthetic worker verification",
|
||||
plan: [
|
||||
{ key: "read", label: "Read", tool: "records.read", args: {}, needsApproval },
|
||||
{ key: "next", label: "Next", tool: "records.next", args: {} },
|
||||
],
|
||||
})
|
||||
const store = dataRunStore(data, scope)
|
||||
return {
|
||||
container,
|
||||
scope,
|
||||
runId,
|
||||
store,
|
||||
removeMember,
|
||||
counts: () => ({ models, tools }),
|
||||
loseDuringModel: () => {
|
||||
loseDuringModel = true
|
||||
},
|
||||
revokeDuringModel: () => {
|
||||
revokeDuringModel = true
|
||||
},
|
||||
revoke: () => {
|
||||
revoke = true
|
||||
},
|
||||
fail: () => {
|
||||
transient = true
|
||||
},
|
||||
deliver: (finalAttempt = false, change?: ClaimChange) =>
|
||||
claimedDelivery(data, container, scope, runId, { finalAttempt }, change),
|
||||
}
|
||||
}
|
||||
|
||||
it("executes and replays without repeating providers through the nonowner runtime", async () => {
|
||||
const job = await fixture()
|
||||
await job.deliver()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("completed")
|
||||
const counts = job.counts()
|
||||
expect(counts.tools).toBe(2)
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual(counts)
|
||||
})
|
||||
|
||||
it("refuses revoked membership before the first model and after a committed step", async () => {
|
||||
const before = await fixture()
|
||||
await before.removeMember()
|
||||
await before.deliver()
|
||||
expect(before.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await before.store.get(before.runId))?.status).toBe("failed")
|
||||
const between = await fixture()
|
||||
between.revoke()
|
||||
await between.deliver()
|
||||
expect(between.counts()).toEqual({ models: 1, tools: 1 })
|
||||
expect(await between.store.get(between.runId)).toMatchObject({ status: "failed", usageSteps: 1 })
|
||||
})
|
||||
|
||||
it("wrong org, workspace or project cannot execute or alter the immutable stored run", async () => {
|
||||
const job = await fixture()
|
||||
for (const field of ["orgId", "workspaceId", "projectId"] as const) {
|
||||
const container = job.container.child()
|
||||
container.value(AGENT_SCOPE, { ...job.scope, [field]: newId() })
|
||||
await expect(claimedDelivery(data, container, job.scope, job.runId, { finalAttempt: true })).rejects.toThrow()
|
||||
}
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("queued")
|
||||
})
|
||||
|
||||
it("bounded exhaustion persists terminal failure, preserving committed/replay semantics", async () => {
|
||||
const job = await fixture()
|
||||
job.fail()
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("running")
|
||||
await job.deliver(true)
|
||||
expect(await job.store.get(job.runId)).toMatchObject({
|
||||
status: "failed",
|
||||
failureDetail: "the durable job exhausted its retry attempts",
|
||||
})
|
||||
expect((await job.store.listSteps(job.runId))[0]?.attempts).toBe(2)
|
||||
const counts = job.counts()
|
||||
await job.deliver(true)
|
||||
expect(job.counts()).toEqual(counts)
|
||||
})
|
||||
|
||||
it("revocation between model and tool refuses the effect before any tool call", async () => {
|
||||
const job = await fixture()
|
||||
job.revokeDuringModel()
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual({ models: 1, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it("approval resumes the exact call, while revocation prevents approved resumption", async () => {
|
||||
const job = await fixture(true)
|
||||
await job.deliver(true)
|
||||
expect((await job.store.get(job.runId))?.status).toBe("awaiting_approval")
|
||||
expect(job.counts().tools).toBe(0)
|
||||
await job.store.decideApproval(job.runId, "read", "approved")
|
||||
await job.deliver()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("completed")
|
||||
expect(job.counts().tools).toBe(2)
|
||||
await job.deliver()
|
||||
expect(job.counts().tools).toBe(2)
|
||||
const revoked = await fixture(true)
|
||||
await revoked.deliver()
|
||||
await revoked.store.decideApproval(revoked.runId, "read", "approved")
|
||||
await revoked.removeMember()
|
||||
const counts = revoked.counts()
|
||||
await revoked.deliver()
|
||||
expect(revoked.counts()).toEqual(counts)
|
||||
expect((await revoked.store.get(revoked.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it("a current role demotion removes execution permission despite the retained memo", async () => {
|
||||
const job = await fixture()
|
||||
const repos = data.forScope(job.scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).update(member.id, { role: "viewer" }, member.version)
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it.each(["cancel", "reclaim", "delete", "scope", "actor"] as const)(
|
||||
"actual adapter refuses %s before provider or durable run mutation",
|
||||
async (change) => {
|
||||
const job = await fixture()
|
||||
const before = await job.store.get(job.runId)
|
||||
const storeCalls = vi.spyOn(data, "forScope")
|
||||
await expect(job.deliver(true, change)).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect(storeCalls).not.toHaveBeenCalled()
|
||||
storeCalls.mockRestore()
|
||||
expect(await job.store.get(job.runId)).toEqual(before)
|
||||
expect(await job.store.listSteps(job.runId)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it("actual original attribution must match the immutable run owner", async () => {
|
||||
const job = await fixture()
|
||||
const before = await job.store.get(job.runId)
|
||||
await expect(job.deliver(true, "owner")).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect(await job.store.get(job.runId)).toEqual(before)
|
||||
expect(await job.store.listSteps(job.runId)).toEqual([])
|
||||
})
|
||||
|
||||
it("actual owned guard backend loss during model admission prevents every later tool and terminal failure", async () => {
|
||||
const job = await fixture()
|
||||
job.loseDuringModel()
|
||||
await expect(job.deliver(true)).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 1, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).not.toBe("failed")
|
||||
})
|
||||
|
||||
it("actual guard loss during authorization refuses model admission before any provider call", async () => {
|
||||
const job = await fixture()
|
||||
const tenancy = job.container.resolve(TENANCY)
|
||||
const child = job.container.child()
|
||||
child.value(TENANCY, {
|
||||
...tenancy,
|
||||
async authorizeIn(...args) {
|
||||
const result = await tenancy.authorizeIn(...args)
|
||||
await terminateClaimSession()
|
||||
return result
|
||||
},
|
||||
})
|
||||
await expect(claimedDelivery(data, child, job.scope, job.runId, { finalAttempt: true })).rejects.toMatchObject({
|
||||
code: "job_claim_lost",
|
||||
})
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("queued")
|
||||
})
|
||||
@@ -0,0 +1,353 @@
|
||||
// The agent module's adapters: the procedure handlers, the tools gateway over the registry client,
|
||||
// the run job that drives the engine, and the subscriber that enqueues it.
|
||||
|
||||
import {
|
||||
AGENT_MODEL,
|
||||
AGENT_TOOLS,
|
||||
type AgentStep,
|
||||
dataAgentPersistence,
|
||||
dataRunStore,
|
||||
executeRun,
|
||||
expireRuns,
|
||||
RunAuthorityDenied,
|
||||
} from "@conusai-kit/agent"
|
||||
import { runEventsResponse } from "@conusai-kit/agent/agui"
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import {
|
||||
type EventContext,
|
||||
implement,
|
||||
internal,
|
||||
isSystemActor,
|
||||
JobClaimLost,
|
||||
type JobDef,
|
||||
subscribe,
|
||||
} from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
|
||||
import { assertJobTransactionFence } from "@conusai-kit/jobs-pgboss"
|
||||
import { TENANCY } from "@conusai-kit/tenancy"
|
||||
import { createRegistryClient, type RegistryClient, ToolDeniedError } from "@conusai-sdk/ai"
|
||||
import { type ConusaiTextAdapter, conusaiText } from "@conusai-sdk/ai/tanstack"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
approvalList,
|
||||
approvalReview,
|
||||
RUN_EXPIRE_JOB,
|
||||
RUN_JOB,
|
||||
runCancel,
|
||||
runEvents,
|
||||
runGetMany,
|
||||
runList,
|
||||
runResumeRequested,
|
||||
runStart,
|
||||
runStatus,
|
||||
} from "../contract/index.js"
|
||||
import { AGENT_SCOPE, type AgentActionScope, type AgentDeps, type AgentScope } from "../core/ports.js"
|
||||
import {
|
||||
cancelRun,
|
||||
getManyRuns,
|
||||
getRun,
|
||||
listApprovals,
|
||||
listRuns,
|
||||
openRunEvents,
|
||||
reviewApproval,
|
||||
startRun,
|
||||
} from "../core/runs.js"
|
||||
|
||||
export function agentDeps(container: Container): AgentDeps {
|
||||
return { data: container.resolve(DATA_SOURCE) }
|
||||
}
|
||||
|
||||
export const runStartImpl = implement(runStart, ({ input, ctx }) =>
|
||||
startRun(agentDeps(ctx.container), ctx.scope, {
|
||||
goal: input.goal,
|
||||
plan: input.plan,
|
||||
...(input.budget === undefined ? {} : { budget: input.budget }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const runStatusImpl = implement(runStatus, ({ input, ctx }) =>
|
||||
getRun(agentDeps(ctx.container), ctx.scope, input.runId),
|
||||
)
|
||||
|
||||
/**
|
||||
* The run's events as SSE: the request is authorized and the run found before the first byte, and
|
||||
* the body then tails the durable record until the run's terminal event or the end of the stream
|
||||
* window, when the client reconnects (and is authorized again) with its last offset. Each poll
|
||||
* also rechecks current membership and permission without the request memo.
|
||||
*/
|
||||
export const runEventsImpl = implement(runEvents, async ({ input, ctx }) => {
|
||||
const opened = await openRunEvents(agentDeps(ctx.container), ctx.scope, { runId: input.runId, after: input.after })
|
||||
return runEventsResponse({
|
||||
runId: input.runId,
|
||||
after: opened.after,
|
||||
load: async () => {
|
||||
// Request authorization is memoised. A stream must observe a removal or demotion before
|
||||
// reading another durable snapshot, rather than retaining that memo until reconnect.
|
||||
const data = ctx.container.resolve(DATA_SOURCE)
|
||||
const tenancy = ctx.container.resolve(TENANCY)
|
||||
const allowed = await data.unitOfWork.run(ctx.scope, (tx) =>
|
||||
tenancy.authorizeIn(tx, ctx.scope, runEvents.permission, "effect", runEvents.name),
|
||||
)
|
||||
return allowed ? opened.load() : null
|
||||
},
|
||||
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
||||
})
|
||||
})
|
||||
|
||||
export const runCancelImpl = implement(runCancel, ({ input, ctx }) =>
|
||||
cancelRun(agentDeps(ctx.container), ctx.scope, input.runId),
|
||||
)
|
||||
|
||||
export const runListImpl = implement(runList, ({ input, ctx }) =>
|
||||
listRuns(agentDeps(ctx.container), ctx.scope, {
|
||||
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const approvalListImpl = implement(approvalList, ({ ctx }) => listApprovals(agentDeps(ctx.container), ctx.scope))
|
||||
|
||||
export const approvalReviewImpl = implement(approvalReview, ({ input, ctx }) =>
|
||||
reviewApproval(agentDeps(ctx.container), ctx.scope, input),
|
||||
)
|
||||
|
||||
export const runGetManyImpl = implement(runGetMany, async ({ input, ctx }) => ({
|
||||
items: await getManyRuns(agentDeps(ctx.container), ctx.scope, input.ids),
|
||||
}))
|
||||
|
||||
export const AGENT_PROCEDURE_IMPLS = [
|
||||
runStartImpl,
|
||||
runStatusImpl,
|
||||
runEventsImpl,
|
||||
runCancelImpl,
|
||||
runListImpl,
|
||||
runGetManyImpl,
|
||||
approvalListImpl,
|
||||
approvalReviewImpl,
|
||||
]
|
||||
|
||||
// --- The model -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The run loop's model: the AI gateway's `model` as a TanStack AI text adapter, with the app's
|
||||
* deployment credentials. The worker binds it as `AGENT_MODEL`; the gateway meters every call and
|
||||
* applies the organization's spend cap, and no provider SDK or key is in the app.
|
||||
*/
|
||||
export function gatewayAgentModel(options: {
|
||||
apiUrl: string
|
||||
token: string
|
||||
model: string
|
||||
}): ConusaiTextAdapter<string> {
|
||||
return conusaiText(options.model, { apiUrl: options.apiUrl, token: options.token })
|
||||
}
|
||||
|
||||
// --- The tools gateway -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* A tool call no retry fixes by itself: the platform refused it, or the tool answered an error. The
|
||||
* run job turns it into the step's typed failure. Its message goes on the run, so it names the tool
|
||||
* and the refusal's reason and never the tool's own text, which may be a tenant's data.
|
||||
*/
|
||||
class ToolCallFailed extends Error {}
|
||||
|
||||
/**
|
||||
* The tools gateway over the organization's registry, with the app's scoped credentials. Each
|
||||
* step's call carries the step's idempotency key, so a resumed step re-calls the same persisted
|
||||
* action instead of creating a second one. When a write comes back `approval_required`, the exact
|
||||
* stored proposed arguments are parked for a person; this adapter never approves. A refusal and a
|
||||
* tool's own error are `ToolCallFailed`; anything else that throws is transient, and leaves the run to
|
||||
* the queue's retry.
|
||||
*/
|
||||
export function registryAgentTools(options: {
|
||||
apiUrl: string
|
||||
token: string
|
||||
/** The organization's id the registry is addressed by. */
|
||||
orgId: string
|
||||
/** Fixed, or resolved per call: jobs of different tenants share the process binding. */
|
||||
scope: AgentActionScope | (() => Promise<AgentActionScope>)
|
||||
fetch?: typeof fetch
|
||||
}): import("@conusai-kit/agent").AgentTools {
|
||||
// Lazy: building the client validates the organization id, and a bare test container binds the
|
||||
// tools without one until a call actually leaves the process.
|
||||
let registry: RegistryClient | undefined
|
||||
const client = (): RegistryClient => {
|
||||
registry ??= createRegistryClient({
|
||||
env: {
|
||||
CONUSAI_API_URL: options.apiUrl,
|
||||
CONUSAI_API_TOKEN: options.token,
|
||||
CONUSAI_ORG_ID: options.orgId,
|
||||
},
|
||||
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
||||
})
|
||||
return registry
|
||||
}
|
||||
const resolveScope =
|
||||
typeof options.scope === "function" ? options.scope : () => Promise.resolve(options.scope as AgentActionScope)
|
||||
return {
|
||||
async call(name, args, callOptions) {
|
||||
const result = await client()
|
||||
.tools.call(name, args, { idempotencyKey: callOptions.idempotencyKey })
|
||||
.catch((error: unknown) => {
|
||||
throw error instanceof ToolDeniedError && !error.retryable ? new ToolCallFailed(error.message) : error
|
||||
})
|
||||
if (result.status === "completed") {
|
||||
if (result.isError) throw new ToolCallFailed(`the tool ${name} answered an error`)
|
||||
return {
|
||||
status: "completed",
|
||||
content: result.content,
|
||||
// The registry call result does not carry metered usage yet (the metering slice owns
|
||||
// that); steps and duration are tracked, and cost lands when the platform answers it.
|
||||
usage: { costMicrocents: 0, steps: 1, tokens: 0 },
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "approval_required",
|
||||
approval: {
|
||||
actionId: result.request.actionId ?? null,
|
||||
tool: name,
|
||||
arguments: result.request.arguments,
|
||||
reviewUrl: result.request.actionId
|
||||
? client()
|
||||
.tools.actions(await resolveScope())
|
||||
.reviewUrl(result.request.actionId)
|
||||
: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- The run job and its subscriber ----------------------------------------
|
||||
|
||||
const runPayload = z.object({ runId: z.string() })
|
||||
type RunJobPayload = z.output<typeof runPayload>
|
||||
|
||||
export const runJob: JobDef<RunJobPayload> = {
|
||||
payload: runPayload,
|
||||
async handler(payload, ctx) {
|
||||
const resolved = ctx.container.resolve(AGENT_SCOPE)
|
||||
if (resolved.level !== "project")
|
||||
throw internal(`an agent job runs in a project scope, not a ${resolved.level} one`)
|
||||
const execution = ctx.execution
|
||||
const actual = execution?.scope
|
||||
if (
|
||||
execution?.adapter !== "pg-boss" ||
|
||||
execution.job !== RUN_JOB ||
|
||||
!actual ||
|
||||
actual.level !== "project" ||
|
||||
!isSystemActor(actual.actor) ||
|
||||
!isSystemActor(resolved.actor) ||
|
||||
actual.actor.userId !== resolved.actor.userId ||
|
||||
actual.orgId !== resolved.orgId ||
|
||||
actual.workspaceId !== resolved.workspaceId ||
|
||||
actual.projectId !== resolved.projectId
|
||||
)
|
||||
throw new JobClaimLost()
|
||||
return execution.run(async () => {
|
||||
const data = ctx.container.resolve(DATA_SOURCE)
|
||||
await assertJobTransactionFence(() => data.unitOfWork.run(resolved, async () => {}))
|
||||
await execution.assertOwned()
|
||||
const store = dataRunStore(data, resolved)
|
||||
const record = await store.get(payload.runId)
|
||||
if (!record) throw internal("the run this job belongs to is gone")
|
||||
if (execution.onBehalfOf !== record.actorId) throw new JobClaimLost()
|
||||
const authorize = async (): Promise<void> => {
|
||||
await execution.assertOwned()
|
||||
// The worker actor is a system actor. Only the immutable initiating member can authorize
|
||||
// this run, and every boundary reads current membership instead of request memo state.
|
||||
const member = { ...resolved, actor: { userId: record.actorId } }
|
||||
const tenancy = ctx.container.resolve(TENANCY)
|
||||
const allowed = await data.unitOfWork.run(member, (tx) =>
|
||||
tenancy.authorizeIn(tx, member, runStart.permission, "effect", runStart.name),
|
||||
)
|
||||
await execution.assertOwned()
|
||||
if (!allowed) throw new RunAuthorityDenied()
|
||||
}
|
||||
const plan: AgentStep[] = record.plan.map((planned) => ({
|
||||
key: planned.key,
|
||||
label: planned.label,
|
||||
...(planned.needsApproval === true
|
||||
? { requiresApproval: { tool: planned.tool, arguments: planned.args } }
|
||||
: {}),
|
||||
invoke: async (stepCtx) => {
|
||||
await authorize()
|
||||
const result = await ctx.container
|
||||
.resolve(AGENT_TOOLS)
|
||||
.call(planned.tool, planned.args, {
|
||||
signal: stepCtx.signal,
|
||||
idempotencyKey: `${record.id}:${planned.key}`,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ToolCallFailed) return { status: "failed" as const, detail: error.message }
|
||||
throw error
|
||||
})
|
||||
await execution.assertOwned()
|
||||
if (result.status === "failed") {
|
||||
return { ok: false as const, failure: "tool_error" as const, detail: result.detail }
|
||||
}
|
||||
if (result.status === "completed") {
|
||||
return {
|
||||
ok: true as const,
|
||||
usage: { costMicrocents: result.usage.costMicrocents, tokens: result.usage.tokens },
|
||||
artifacts: [{ kind: "tool_result", ref: planned.tool }],
|
||||
result: result.content,
|
||||
}
|
||||
}
|
||||
return { ok: false as const, awaitingApproval: result.approval }
|
||||
},
|
||||
}))
|
||||
await executeRun(
|
||||
{
|
||||
store,
|
||||
authorize,
|
||||
assertOwned: () => execution.assertOwned(),
|
||||
// Resolve provider bindings only after entry admission; a denied/terminal run never
|
||||
// constructs a gateway client. Metering and spend admission remain in the gateway.
|
||||
get adapter() {
|
||||
return ctx.container.resolve(AGENT_MODEL)
|
||||
},
|
||||
persistence: dataAgentPersistence(data, resolved),
|
||||
},
|
||||
payload.runId,
|
||||
plan,
|
||||
{
|
||||
...(ctx.finalAttempt === undefined ? {} : { finalAttempt: ctx.finalAttempt }),
|
||||
signal: ctx.signal ? AbortSignal.any([ctx.signal, execution.signal]) : execution.signal,
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
async function onRunResumeRequested(
|
||||
event: { name: string; payload: { workspaceId: string; projectId: string; runId: string } },
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const ambient = ctx.container.resolve(AGENT_SCOPE)
|
||||
if (ambient.level !== "project") throw internal("an agent event resolves to a project scope")
|
||||
const scope: AgentScope = { ...ambient, workspaceId: event.payload.workspaceId, projectId: event.payload.projectId }
|
||||
await ctx.container
|
||||
.resolve(JOB_QUEUE)
|
||||
.enqueue(scope, RUN_JOB, { runId: event.payload.runId }, { key: `agent.run.${event.payload.runId}` })
|
||||
}
|
||||
|
||||
const runExpirePayload = z.object({ retentionDays: z.number().int().min(1).max(3650) })
|
||||
type RunExpirePayload = z.output<typeof runExpirePayload>
|
||||
|
||||
/**
|
||||
* The retention sweep: deletes the finished runs of one project that are past the retention, with their step
|
||||
* log and conversation (`expireRuns`). Runs in the project the schedule fanned it out to, so another
|
||||
* organization's runs are not reached; a suspended organization gets no run at all (the fan-out skips it).
|
||||
*/
|
||||
export const runExpireJob: JobDef<RunExpirePayload> = {
|
||||
payload: runExpirePayload,
|
||||
async handler(payload, ctx) {
|
||||
const resolved = ctx.container.resolve(AGENT_SCOPE)
|
||||
if (resolved.level !== "project")
|
||||
throw internal(`the run retention sweep runs in a project scope, not a ${resolved.level} one`)
|
||||
await expireRuns(ctx.container.resolve(DATA_SOURCE), resolved, { retentionDays: payload.retentionDays })
|
||||
},
|
||||
}
|
||||
|
||||
export const AGENT_JOBS: Record<string, JobDef<unknown>> = { [RUN_JOB]: runJob, [RUN_EXPIRE_JOB]: runExpireJob }
|
||||
export const AGENT_SUBSCRIPTIONS = [subscribe(runResumeRequested, onRunResumeRequested)]
|
||||
@@ -0,0 +1,20 @@
|
||||
// The live collection of the agent module (umbrella TS6.1). The live server announces a change of `agent_run`
|
||||
// -- a run started, a step committed, an approval parked, a run finished or cancelled -- as
|
||||
// `{ entity: "agent.run", id }`, ids only; a reader reads the run back through `agent.run.getMany`, so the
|
||||
// procedure's permission and the repository's tenant wall decide what a member sees. The run list is rows of
|
||||
// this collection; the approval queue is read again on its changes, since a run that parks an approval or
|
||||
// leaves one changes its status. `pnpm kit check` refuses a collection without its batch-get procedure.
|
||||
|
||||
import type { ModuleCollection } from "@conusai-kit/core"
|
||||
|
||||
export const RUN_COLLECTION = {
|
||||
entity: "agent.run",
|
||||
table: "agent_run",
|
||||
key: "runId",
|
||||
scopeLevel: "project",
|
||||
read: "agent.run.read",
|
||||
list: "agent.run.list",
|
||||
getMany: "agent.run.getMany",
|
||||
} as const satisfies ModuleCollection
|
||||
|
||||
export const AGENT_COLLECTIONS: readonly ModuleCollection[] = [RUN_COLLECTION]
|
||||
@@ -0,0 +1,15 @@
|
||||
// The constants of the agent module: its job, events and audit actions.
|
||||
|
||||
/** The worker job that executes (or resumes) one run. */
|
||||
export const RUN_JOB = "agent.run"
|
||||
|
||||
/** The hourly sweep that deletes finished runs past the retention (AGENT_RUN_RETENTION_DAYS), per project. */
|
||||
export const RUN_EXPIRE_JOB = "agent.runs.expire"
|
||||
|
||||
/** Top of every hour. */
|
||||
export const RUN_EXPIRE_CRON = "0 * * * *"
|
||||
|
||||
export const RUN_STARTED = "agent.run.started"
|
||||
export const RUN_RESUME_REQUESTED = "agent.run.resume_requested"
|
||||
export const RUN_CANCELLED_ACTION = "agent.run.cancelled"
|
||||
export const APPROVAL_REVIEWED = "agent.approval.reviewed"
|
||||
@@ -0,0 +1,23 @@
|
||||
// The events of the agent module. A web write publishes the request; the module's own subscriber
|
||||
// (in the worker's outbox relay) enqueues the run job with the run id as its singleton key, so a
|
||||
// start, a cancel or a review resumes the one job.
|
||||
|
||||
import { defineEvent } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
|
||||
const inProject = {
|
||||
orgId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
}
|
||||
|
||||
/** A run was asked to start (or resume after a cancel/review decision). */
|
||||
export const runResumeRequested = defineEvent(
|
||||
"agent.runResumeRequested",
|
||||
z.object({
|
||||
...inProject,
|
||||
runId: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AGENT_EVENTS = [runResumeRequested]
|
||||
@@ -0,0 +1,31 @@
|
||||
// The public API of the agent module: what a page, another module and the OpenAPI document may
|
||||
// import.
|
||||
|
||||
export { AGENT_COLLECTIONS, RUN_COLLECTION } from "./collections.js"
|
||||
export {
|
||||
APPROVAL_REVIEWED,
|
||||
RUN_CANCELLED_ACTION,
|
||||
RUN_EXPIRE_CRON,
|
||||
RUN_EXPIRE_JOB,
|
||||
RUN_JOB,
|
||||
RUN_RESUME_REQUESTED,
|
||||
RUN_STARTED,
|
||||
} from "./constants.js"
|
||||
export { AGENT_EVENTS, runResumeRequested } from "./events.js"
|
||||
export type { ApprovalView, RunView } from "./procedures.js"
|
||||
export {
|
||||
AGENT_PROCEDURES,
|
||||
approvalList,
|
||||
approvalReview,
|
||||
approvalViewSchema,
|
||||
planStepSchema,
|
||||
runBudgetSchema,
|
||||
runCancel,
|
||||
runEvents,
|
||||
runEventsAfterSchema,
|
||||
runGetMany,
|
||||
runList,
|
||||
runStart,
|
||||
runStatus,
|
||||
runViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,215 @@
|
||||
// The procedure contracts of the agent module: schemas and names, no handler and no port.
|
||||
//
|
||||
// A run is a bounded, person-requested plan of tool calls that the worker executes durably, as a
|
||||
// TanStack AI agent loop through the AI gateway (`@conusai-kit/agent`): the model calls the steps
|
||||
// in order and answers the goal from their results. The progress a page renders is the run row
|
||||
// and its step log, and the only writes a tool's result can cause are proposals a person decides.
|
||||
// The approval queue renders the EXACT stored proposed arguments — never a model's narration.
|
||||
|
||||
import { answerProvenanceSchema } from "@conusai-kit/agent/client"
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { APPROVAL_REVIEWED, RUN_CANCELLED_ACTION } from "./constants.js"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
|
||||
/** One step of the plan a person admits: one explicitly published tool, exact arguments. */
|
||||
export const planStepSchema = z
|
||||
.object({
|
||||
// The key is also the step's tool name in the model loop: providers allow at most 64.
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9-]*$/, "lower-case letters, digits and dashes"),
|
||||
label: z.string().min(1).max(200),
|
||||
tool: z.string().min(1).max(200),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
/** A person approves this call (its tool and exact arguments) before it runs. */
|
||||
needsApproval: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** What a run may spend; the engine clamps it by the hard safety ceilings. */
|
||||
export const runBudgetSchema = z
|
||||
.object({
|
||||
maxCostMicrocents: z.number().int().positive().optional(),
|
||||
maxSteps: z.number().int().positive().optional(),
|
||||
maxDurationSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const runViewSchema = z.object({
|
||||
runId: z.string(),
|
||||
goal: z.string(),
|
||||
status: z.enum(["queued", "running", "awaiting_approval", "completed", "failed", "cancelled", "uncertain"]),
|
||||
failureCode: z
|
||||
.enum(["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"])
|
||||
.nullable(),
|
||||
failureDetail: z.string().nullable(),
|
||||
/** The plan as admitted: what the run will do, visible before it does it. */
|
||||
plan: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
tool: z.string(),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
needsApproval: z.boolean(),
|
||||
/** The step's state from the step log; steps not begun have no row yet. */
|
||||
status: z.enum(["pending", "committed", "failed", "cancelled", "uncertain"]),
|
||||
attempts: z.number().int(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
usageCostMicrocents: z.number().int(),
|
||||
usageSteps: z.number().int(),
|
||||
/** Committed steps over the plan: the progress bar. */
|
||||
percent: z.number(),
|
||||
cancelRequested: z.boolean(),
|
||||
/** A completed run's answer: AI-generated text (EU AI Act Art. 50), shown with its disclosure. */
|
||||
answer: z.string().nullable(),
|
||||
/**
|
||||
* The answer's machine-readable marking (EU AI Act Art. 50(2)): the provenance the AI gateway
|
||||
* returned with it (model, provider, generation time). Null without an answer, or when the gateway
|
||||
* marked none; keep it with the answer wherever the answer is copied or exported.
|
||||
*/
|
||||
answerProvenance: answerProvenanceSchema.nullable(),
|
||||
actorId: z.string(),
|
||||
createdAt: z.string(),
|
||||
startedAt: z.string().nullable(),
|
||||
finishedAt: z.string().nullable(),
|
||||
})
|
||||
export type RunView = z.output<typeof runViewSchema>
|
||||
|
||||
/** One parked approval: the exact stored proposed arguments, copied before they were proposed. */
|
||||
export const approvalViewSchema = z.object({
|
||||
runId: z.string(),
|
||||
stepKey: z.string(),
|
||||
goal: z.string(),
|
||||
tool: z.string(),
|
||||
/** The exact arguments the tool was called with; a reviewer reads THESE, never a narration. */
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
/** Where the persisted proposal is reviewed in the console, when the platform names one. */
|
||||
reviewUrl: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
export type ApprovalView = z.output<typeof approvalViewSchema>
|
||||
|
||||
export const runStart = procedure("agent.run.start")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("agent.run.start")
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
goal: z.string().min(1).max(2_000),
|
||||
plan: z.array(planStepSchema).min(1).max(25),
|
||||
budget: runBudgetSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.output(runViewSchema)
|
||||
.contract()
|
||||
|
||||
export const runStatus = procedure("agent.run.status")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("agent.run.read")
|
||||
.tool()
|
||||
.input(z.object({ ...inProject, runId: z.string() }))
|
||||
.output(runViewSchema)
|
||||
.contract()
|
||||
|
||||
/**
|
||||
* The resume point of a run's event stream: the last delivered sequence number, or the SSE id
|
||||
* `agk1:{runId}:{seq}` the client last saw (`-1` or absent: from the start).
|
||||
*/
|
||||
export const runEventsAfterSchema = z.string().regex(/^(?:-1|\d{1,9}|agk1:[A-Za-z0-9_-]{1,128}:\d{1,9})$/)
|
||||
|
||||
/**
|
||||
* The run's AG-UI 1.0 events as Server-Sent Events (`@conusai-kit/agent/agui`), for TanStack AI's
|
||||
* resumable client (`@conusai-kit/agent/client`). A reader sees exactly what `agent.run.status`
|
||||
* shows, under the same permission; the stream only reads the durable record.
|
||||
*
|
||||
* It is declared an `effect` because the kernel admits a streamed HTTP response (`download`) only
|
||||
* for that kind (kit-core registry); its handler reads and never writes. It counts against the
|
||||
* `default` bucket, like a read, because a watched run reconnects every stream window.
|
||||
*/
|
||||
export const runEvents = procedure("agent.run.events")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("agent.run.read")
|
||||
.input(z.object({ ...inProject, runId: z.string(), after: runEventsAfterSchema.optional() }))
|
||||
.output(z.custom<Response>((value) => value instanceof Response, "a streamed event response"))
|
||||
.download()
|
||||
.rateLimit("default")
|
||||
.contract()
|
||||
|
||||
export const runCancel = procedure("agent.run.cancel")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("agent.run.cancel")
|
||||
.input(z.object({ ...inProject, runId: z.string() }))
|
||||
.output(z.object({ runId: z.string(), cancelled: z.boolean() }))
|
||||
.audit(RUN_CANCELLED_ACTION)
|
||||
.contract()
|
||||
|
||||
export const runList = procedure("agent.run.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("agent.run.read")
|
||||
.input(z.object({ ...inProject, cursor: z.string().optional(), limit: z.number().int().min(1).max(100).optional() }))
|
||||
.output(z.object({ runs: z.array(runViewSchema), cursor: z.string().optional(), hasMore: z.boolean() }))
|
||||
.contract()
|
||||
|
||||
/**
|
||||
* The live collection's batch-get (contract C-05): the runs with these ids, under the list's permission. An
|
||||
* id of another tenant, of another project or of nothing is left out of the answer.
|
||||
*/
|
||||
export const runGetMany = procedure("agent.run.getMany")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("agent.run.read")
|
||||
.input(z.object({ ...inProject, ids: z.array(z.string().min(1).max(64)).min(1).max(100) }))
|
||||
.output(z.object({ items: z.array(runViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const approvalList = procedure("agent.approval.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("agent.approval.review")
|
||||
.input(z.object({ ...inProject }))
|
||||
.output(z.object({ approvals: z.array(approvalViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const approvalReview = procedure("agent.approval.review")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("agent.approval.review")
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
runId: z.string(),
|
||||
stepKey: z.string(),
|
||||
decision: z.enum(["approve", "reject"]),
|
||||
reason: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
runId: z.string(),
|
||||
status: z.enum(["queued", "running", "awaiting_approval", "completed", "failed", "cancelled", "uncertain"]),
|
||||
}),
|
||||
)
|
||||
.audit(APPROVAL_REVIEWED)
|
||||
.contract()
|
||||
|
||||
export const AGENT_PROCEDURES = [
|
||||
runStart,
|
||||
runStatus,
|
||||
runEvents,
|
||||
runCancel,
|
||||
runList,
|
||||
runGetMany,
|
||||
approvalList,
|
||||
approvalReview,
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
// The ports the agent module's use cases are written against, and the tokens the composition
|
||||
// roots bind. The runtime itself (durable run record, step log, engine) is @conusai-kit/agent;
|
||||
// this module is its template integration: procedures, job and pages.
|
||||
|
||||
import { type TenantScope, token } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
|
||||
export type AgentScope = TenantScope & { workspaceId: string; projectId: string }
|
||||
|
||||
export interface AgentDeps {
|
||||
readonly data: DataSource
|
||||
}
|
||||
|
||||
/**
|
||||
* The project a run's job executes in, with a system actor. The worker binds it per job from the
|
||||
* enqueueing scope (the same arrangement as `DOCUMENTS_SCOPE`, decision AT-F6): a payload never
|
||||
* names a tenant.
|
||||
*/
|
||||
export const AGENT_SCOPE = token<TenantScope>("agent.scope")
|
||||
|
||||
/** The scope the tools adapter needs to review persisted proposals in the console. */
|
||||
export interface AgentActionScope {
|
||||
readonly orgSlug: string
|
||||
readonly workspaceSlug: string
|
||||
readonly projectSlug: string
|
||||
}
|
||||
|
||||
/** Resolves the slug scope the registry's tool-actions API is addressed by, from ids. */
|
||||
export async function actionScopeOf(
|
||||
data: import("@conusai-kit/data").DataSource,
|
||||
scope: AgentScope,
|
||||
): Promise<AgentActionScope> {
|
||||
const { organizations, workspaces, projects } = await import("@conusai-kit/tenancy")
|
||||
const org = await data.global(organizations).get(scope.orgId)
|
||||
const workspace = await data.forScope(scope).of(workspaces).get(scope.workspaceId)
|
||||
const project = await data.forScope(scope).of(projects).get(scope.projectId)
|
||||
if (!org || !workspace || !project) throw new Error("the run's organization, workspace or project is gone")
|
||||
return { orgSlug: org.slug, workspaceSlug: workspace.slug, projectSlug: project.slug }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// The agent module's use cases: start a run, read one, list them, cancel one, and the approval
|
||||
// queue. Every write is the run row and the outbox event in one transaction; the worker's
|
||||
// subscriber enqueues the run job, and the engine (@conusai-kit/agent) does the durable execution.
|
||||
|
||||
import {
|
||||
AGENT_ENTITIES,
|
||||
type AgentRunRecord,
|
||||
type AgentStepRecord,
|
||||
admitPlan,
|
||||
agentRuns,
|
||||
answerOf,
|
||||
clampBudget,
|
||||
dataAgentPersistence,
|
||||
dataRunStore,
|
||||
isTerminalRunStatus,
|
||||
} from "@conusai-kit/agent"
|
||||
import { resumeAfter } from "@conusai-kit/agent/agui"
|
||||
import { notFound, validationFailed } from "@conusai-kit/core"
|
||||
import { type ApprovalView, RUN_JOB, type RunView, runResumeRequested } from "../contract/index.js"
|
||||
import type { AgentDeps, AgentScope } from "./ports.js"
|
||||
|
||||
const DEFAULT_BUDGET = { maxCostMicrocents: 100_000_000, maxSteps: 10, maxDurationSeconds: 600 }
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export const runViewOf = async (deps: AgentDeps, scope: AgentScope, run: { id: string }): Promise<RunView> => {
|
||||
const store = dataRunStore(deps.data, scope)
|
||||
const record = await store.get(run.id)
|
||||
if (!record) throw notFound("no such run")
|
||||
const steps = await store.listSteps(run.id)
|
||||
const stepViews = record.plan.map((planned) => {
|
||||
const logged = steps.find((candidate) => candidate.key === planned.key)
|
||||
return {
|
||||
key: planned.key,
|
||||
label: planned.label,
|
||||
tool: planned.tool,
|
||||
args: planned.args,
|
||||
needsApproval: planned.needsApproval === true,
|
||||
status: logged?.status ?? ("pending" as const),
|
||||
attempts: logged?.attempts ?? 0,
|
||||
error: logged?.error ?? null,
|
||||
}
|
||||
})
|
||||
const answer = await answerOf(dataAgentPersistence(deps.data, scope), record)
|
||||
return {
|
||||
runId: record.id,
|
||||
goal: record.goal,
|
||||
status: record.status,
|
||||
failureCode: record.failureCode,
|
||||
failureDetail: record.failureDetail,
|
||||
plan: stepViews,
|
||||
usageCostMicrocents: record.usageCostMicrocents,
|
||||
usageSteps: record.usageSteps,
|
||||
percent: record.plan.length === 0 ? 0 : Math.min(record.usageSteps, record.plan.length) / record.plan.length,
|
||||
cancelRequested: record.cancelRequestedAt !== null,
|
||||
answer,
|
||||
answerProvenance: answer === null ? null : record.answerProvenance,
|
||||
actorId: record.actorId,
|
||||
createdAt: record.createdAt,
|
||||
startedAt: record.startedAt,
|
||||
finishedAt: record.finishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a run: the plan is admitted against the hard ceilings before anything is written, the run
|
||||
* row and the resume event land in one transaction, and the subscriber's enqueue (singleton on
|
||||
* the run id) is what actually starts the work. Idempotency is the caller's idempotency key at
|
||||
* the transport; a repeated start with the same key answers the first run.
|
||||
*/
|
||||
export async function startRun(
|
||||
deps: AgentDeps,
|
||||
scope: AgentScope,
|
||||
input: {
|
||||
goal: string
|
||||
plan: {
|
||||
key: string
|
||||
label: string
|
||||
tool: string
|
||||
args: Record<string, unknown>
|
||||
needsApproval?: boolean | undefined
|
||||
}[]
|
||||
budget?: {
|
||||
maxCostMicrocents?: number | undefined
|
||||
maxSteps?: number | undefined
|
||||
maxDurationSeconds?: number | undefined
|
||||
}
|
||||
},
|
||||
): Promise<RunView> {
|
||||
const budget = clampBudget({
|
||||
maxCostMicrocents: input.budget?.maxCostMicrocents ?? DEFAULT_BUDGET.maxCostMicrocents,
|
||||
maxSteps: input.budget?.maxSteps ?? DEFAULT_BUDGET.maxSteps,
|
||||
maxDurationSeconds: input.budget?.maxDurationSeconds ?? DEFAULT_BUDGET.maxDurationSeconds,
|
||||
})
|
||||
// The plan is data here; the engine's steps come from these rows in the worker.
|
||||
admitPlan(
|
||||
input.plan.map((planned) => ({
|
||||
key: planned.key,
|
||||
label: planned.label,
|
||||
invoke: () => Promise.resolve({ ok: true as const }),
|
||||
})),
|
||||
budget,
|
||||
)
|
||||
const run = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = await tx.of(agentRuns).insert({
|
||||
goal: input.goal,
|
||||
plan: input.plan.map((planned) => ({
|
||||
key: planned.key,
|
||||
label: planned.label,
|
||||
tool: planned.tool,
|
||||
args: planned.args,
|
||||
...(planned.needsApproval === true ? { needsApproval: true } : {}),
|
||||
})),
|
||||
status: "queued",
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
actorId: scope.actor.userId,
|
||||
budgetCostMicrocents: budget.maxCostMicrocents,
|
||||
budgetSteps: budget.maxSteps,
|
||||
budgetSeconds: budget.maxDurationSeconds,
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
})
|
||||
await tx.publish(runResumeRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
runId: row.id,
|
||||
})
|
||||
return row
|
||||
})
|
||||
return runViewOf(deps, scope, run)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a run's event stream: the run must be one this scope can read (another organization's or
|
||||
* project's run is not found, exactly as `agent.run.status` answers), and the resume point must
|
||||
* belong to it. Answers the offset to replay after and a reader of the durable record. The reader
|
||||
* reads the run row before its step log: both only move forward, so a step log newer than its run
|
||||
* row projects a prefix of the next projection, never a terminal event ahead of its steps.
|
||||
*/
|
||||
export async function openRunEvents(
|
||||
deps: AgentDeps,
|
||||
scope: AgentScope,
|
||||
input: { runId: string; after?: string | undefined },
|
||||
): Promise<{
|
||||
after: number
|
||||
load: () => Promise<{ run: AgentRunRecord; steps: readonly AgentStepRecord[]; answer: string | null } | null>
|
||||
}> {
|
||||
const store = dataRunStore(deps.data, scope)
|
||||
const persistence = dataAgentPersistence(deps.data, scope)
|
||||
if (!(await store.get(input.runId))) throw notFound("no such run")
|
||||
let after: number
|
||||
try {
|
||||
after = resumeAfter(input.runId, input.after)
|
||||
} catch {
|
||||
throw validationFailed([{ path: "after", message: "the offset does not belong to this run" }], "invalid offset")
|
||||
}
|
||||
return {
|
||||
after,
|
||||
load: async () => {
|
||||
const run = await store.get(input.runId)
|
||||
return run ? { run, steps: await store.listSteps(input.runId), answer: await answerOf(persistence, run) } : null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRun(deps: AgentDeps, scope: AgentScope, runId: string): Promise<RunView> {
|
||||
return runViewOf(deps, scope, { id: runId })
|
||||
}
|
||||
|
||||
export async function listRuns(
|
||||
deps: AgentDeps,
|
||||
scope: AgentScope,
|
||||
input: { cursor?: string | undefined; limit?: number | undefined },
|
||||
): Promise<{ runs: RunView[]; cursor?: string; hasMore: boolean }> {
|
||||
const store = dataRunStore(deps.data, scope, { pageSize: PAGE })
|
||||
const page = await store.list({
|
||||
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
})
|
||||
return {
|
||||
runs: await Promise.all(page.runs.map((run) => runViewOf(deps, scope, run))),
|
||||
...("cursor" in page ? { cursor: page.cursor } : {}),
|
||||
hasMore: page.hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
/** The most ids one batch-get reads (contract C-05). */
|
||||
export const MAX_BATCH = 100
|
||||
|
||||
/**
|
||||
* The live collection's read-back: the runs with these ids, as the list shows them. A run this caller
|
||||
* cannot see is not in the answer: the repository's tenant wall leaves it out.
|
||||
*/
|
||||
export async function getManyRuns(deps: AgentDeps, scope: AgentScope, runIds: readonly string[]): Promise<RunView[]> {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(agentRuns)
|
||||
.list({ where: [{ field: "id", op: "in", values: [...new Set(runIds)] }], limit: MAX_BATCH })
|
||||
return Promise.all(page.items.map((run) => runViewOf(deps, scope, run)))
|
||||
}
|
||||
|
||||
/** Cancels a run: the persisted request is honored between steps and inside the in-flight call. */
|
||||
export async function cancelRun(
|
||||
deps: AgentDeps,
|
||||
scope: AgentScope,
|
||||
runId: string,
|
||||
): Promise<{ runId: string; cancelled: boolean }> {
|
||||
const store = dataRunStore(deps.data, scope)
|
||||
const record = await store.get(runId)
|
||||
if (!record) throw notFound("no such run")
|
||||
if (record.cancelRequestedAt !== null) return { runId, cancelled: true }
|
||||
await store.requestCancel(runId, new Date().toISOString())
|
||||
// A parked run only notices the request when it is resumed; enqueue the resume so the cancel
|
||||
// lands even while the run waits in awaiting_approval.
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
await tx.publish(runResumeRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
runId,
|
||||
})
|
||||
})
|
||||
return { runId, cancelled: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The approval queue: every step that parked an approval, with the EXACT stored proposed
|
||||
* arguments. Nothing here is a model's narration; the arguments are what the tool was called
|
||||
* with, copied before they were proposed.
|
||||
*/
|
||||
export async function listApprovals(deps: AgentDeps, scope: AgentScope): Promise<{ approvals: ApprovalView[] }> {
|
||||
const approvals: ApprovalView[] = []
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(agentRuns)
|
||||
.list({
|
||||
order: [{ field: "createdAt", direction: "desc" }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
for (const run of page.items) {
|
||||
if (isTerminalRunStatus(run.status)) continue
|
||||
const steps = await dataRunStore(deps.data, scope).listSteps(run.id)
|
||||
for (const step of steps) {
|
||||
// Only what still waits for a person: a decided approval or a step that moved on is history.
|
||||
if (step.approval === null || step.approval.decision !== null || step.status !== "pending") continue
|
||||
approvals.push({
|
||||
runId: run.id,
|
||||
stepKey: step.key,
|
||||
goal: run.goal,
|
||||
tool: step.approval.tool,
|
||||
arguments: step.approval.arguments,
|
||||
reviewUrl: step.approval.reviewUrl,
|
||||
createdAt: run.createdAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
return { approvals }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a person's decision on the step and resumes the run. An approved `needsApproval` step
|
||||
* resumes its TanStack AI interrupt and runs once; an approved platform proposal re-enters the
|
||||
* parked step, whose tools adapter re-calls with the step's idempotency key so the platform executes
|
||||
* the approved, persisted proposal exactly once. Rejection cancels the run: nothing is executed. A
|
||||
* step with nothing parked, or one already decided, is refused.
|
||||
*/
|
||||
export async function reviewApproval(
|
||||
deps: AgentDeps,
|
||||
scope: AgentScope,
|
||||
input: { runId: string; stepKey: string; decision: "approve" | "reject" },
|
||||
): Promise<{ runId: string; status: RunView["status"] }> {
|
||||
const store = dataRunStore(deps.data, scope)
|
||||
const record = await store.get(input.runId)
|
||||
if (!record) throw notFound("no such run")
|
||||
await store.decideApproval(input.runId, input.stepKey, input.decision === "approve" ? "approved" : "rejected")
|
||||
if (input.decision === "reject") {
|
||||
await store.requestCancel(input.runId, new Date().toISOString())
|
||||
}
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
await tx.publish(runResumeRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
runId: input.runId,
|
||||
})
|
||||
})
|
||||
return { runId: input.runId, status: (await store.get(input.runId))?.status ?? record.status }
|
||||
}
|
||||
|
||||
export { AGENT_ENTITIES, RUN_JOB }
|
||||
@@ -0,0 +1,78 @@
|
||||
// English messages of the agent module. Every string a page of this module shows is a key here
|
||||
// and in lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Agent runs",
|
||||
intro: "Bounded runs of published tools. A run proposes; a person approves. AI output is disclosed where it appears.",
|
||||
runs: {
|
||||
heading: "Runs",
|
||||
empty: "No runs yet.",
|
||||
goal: "Goal",
|
||||
status: "Status",
|
||||
statusQueued: "Queued",
|
||||
statusRunning: "Running",
|
||||
statusAwaitingApproval: "Awaiting approval",
|
||||
statusCompleted: "Completed",
|
||||
statusFailed: "Failed",
|
||||
statusCancelled: "Cancelled",
|
||||
statusUncertain: "Uncertain — needs reconciliation",
|
||||
progress: "Progress",
|
||||
percent: "{percent}%",
|
||||
startedBy: "Started by",
|
||||
startedAt: "Started",
|
||||
finishedAt: "Finished",
|
||||
usageLabel: "Usage",
|
||||
usage: "{steps} steps, {cost} microcents",
|
||||
failure: "Failure",
|
||||
cancel: "Cancel run",
|
||||
cancelling: "Cancelling",
|
||||
needsApproval: "approved by a person before it runs",
|
||||
answer: "Answer",
|
||||
answerDisclosure:
|
||||
"AI-generated: a model wrote this answer from the run's tool results. Check it before you rely on it.",
|
||||
streamLost: "Live updates stopped. Reload the page to see the run's current state.",
|
||||
back: "All runs",
|
||||
plan: "Plan",
|
||||
step: "Step",
|
||||
tool: "Tool",
|
||||
stepPending: "Pending",
|
||||
stepCommitted: "Done",
|
||||
stepFailed: "Failed",
|
||||
stepCancelled: "Cancelled",
|
||||
stepUncertain: "Uncertain",
|
||||
},
|
||||
start: {
|
||||
heading: "Start a run",
|
||||
goalLabel: "Goal",
|
||||
goalHint: "What this run should achieve, in a sentence a reviewer reads.",
|
||||
planLabel: "Plan",
|
||||
planHint: "One tool call per step, as JSON: {example}",
|
||||
submit: "Start run",
|
||||
starting: "Starting",
|
||||
failed: "That did not work: {detail}",
|
||||
},
|
||||
approvals: {
|
||||
heading: "Approvals waiting for you",
|
||||
empty: "Nothing is waiting for a decision.",
|
||||
proposed: "Proposed arguments",
|
||||
review: "Review in console",
|
||||
approve: "Approve",
|
||||
reject: "Reject",
|
||||
decided: "Decision recorded; the run resumes.",
|
||||
disclosure:
|
||||
"Proposed by an agent run: these are the exact arguments the call will be made with. Approve only what you would do yourself.",
|
||||
},
|
||||
recovery: {
|
||||
failedHint: "A failed run can be started again; the plan is visible above.",
|
||||
cancelledHint: "This run was cancelled. Start a new run to do the work again.",
|
||||
uncertainHint:
|
||||
"This run timed out after an external effect. Check the affected service before deciding: reconcile by cancelling, or let the platform's reconciliation settle it.",
|
||||
reconcileCancel: "Mark reconciled (cancel)",
|
||||
budgetExhausted: "The run reached its budget.",
|
||||
approvalUnavailable:
|
||||
"The approval could not be completed here. Open the console to review the persisted proposal, then approve again.",
|
||||
toolError: "A tool refused the call.",
|
||||
planRefused: "The plan was refused at admission.",
|
||||
internal: "The run failed internally.",
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,2 @@
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,84 @@
|
||||
// Lietuviški agent modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
|
||||
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Agentų vykdymai",
|
||||
intro:
|
||||
"Riboti paskelbtų įrankių vykdymai. Agentas pasiūlo; žmogus patvirtina. DI rezultatai atskleidžiami ten, kur jie rodomi.",
|
||||
runs: {
|
||||
heading: "Vykdymai",
|
||||
empty: "Vykdymų dar nėra.",
|
||||
goal: "Tikslas",
|
||||
status: "Būsena",
|
||||
statusQueued: "Laukia",
|
||||
statusRunning: "Vyksta",
|
||||
statusAwaitingApproval: "Laukia patvirtinimo",
|
||||
statusCompleted: "Baigta",
|
||||
statusFailed: "Nepavyko",
|
||||
statusCancelled: "Atšaukta",
|
||||
statusUncertain: "Neaišku — reikia suderinti",
|
||||
progress: "Pažanga",
|
||||
percent: "{percent} %",
|
||||
startedBy: "Pradėjo",
|
||||
startedAt: "Pradžia",
|
||||
finishedAt: "Pabaiga",
|
||||
usageLabel: "Sąnaudos",
|
||||
usage: "{steps} žingsniai, {cost} mikrocentų",
|
||||
failure: "Nepavyko",
|
||||
cancel: "Atšaukti vykdymą",
|
||||
cancelling: "Atšaukiama",
|
||||
needsApproval: "prieš vykdant tvirtina žmogus",
|
||||
answer: "Atsakymas",
|
||||
answerDisclosure:
|
||||
"Sukurta DI: šį atsakymą iš vykdymo įrankių rezultatų parašė modelis. Prieš juo remdamiesi, patikrinkite.",
|
||||
streamLost:
|
||||
"Tiesioginiai atnaujinimai sustojo. Įkelkite puslapį iš naujo, kad matytumėte dabartinę vykdymo būseną.",
|
||||
back: "Visi vykdymai",
|
||||
plan: "Planas",
|
||||
step: "Žingsnis",
|
||||
tool: "Įrankis",
|
||||
stepPending: "Laukia",
|
||||
stepCommitted: "Atlikta",
|
||||
stepFailed: "Nepavyko",
|
||||
stepCancelled: "Atšaukta",
|
||||
stepUncertain: "Neaišku",
|
||||
},
|
||||
start: {
|
||||
heading: "Pradėti vykdymą",
|
||||
goalLabel: "Tikslas",
|
||||
goalHint: "Ką šis vykdymas turi pasiekti — sakiniu, kurį perskaitys tikrintojas.",
|
||||
planLabel: "Planas",
|
||||
planHint: "Vienas įrankio iškvietimas žingsniui, kaip JSON: {example}",
|
||||
submit: "Pradėti vykdymą",
|
||||
starting: "Pradedama",
|
||||
failed: "Nepavyko: {detail}",
|
||||
},
|
||||
approvals: {
|
||||
heading: "Patvirtinimai, laukiantys jūsų",
|
||||
empty: "Nieko nelaukia sprendimo.",
|
||||
proposed: "Pasiūlyti argumentai",
|
||||
review: "Peržiūrėti konsolėje",
|
||||
approve: "Patvirtinti",
|
||||
reject: "Atmesti",
|
||||
decided: "Sprendimas įrašytas; vykdymas tęsiamas.",
|
||||
disclosure:
|
||||
"Pasiūlė agento vykdymas: tai tikslūs argumentai, su kuriais bus atliktas iškvietimas. Patvirtinkite tik tai, ką darytumėte patys.",
|
||||
},
|
||||
recovery: {
|
||||
failedHint: "Nepavykusį vykdymą galima pradėti iš naujo; planas matomas aukščiau.",
|
||||
cancelledHint: "Šis vykdymas atšauktas. Norėdami pakartoti darbą, pradėkite naują vykdymą.",
|
||||
uncertainHint:
|
||||
"Vykdymas baigėsi neaiškiai po išorinio poveikio. Prieš spręsdami patikrinkite paveiktą paslaugą: suderinkite atšaukdami arba leiskite platformos suderinimui užbaigti.",
|
||||
reconcileCancel: "Pažymėti suderinta (atšaukti)",
|
||||
budgetExhausted: "Vykdymas pasiekė biudžetą.",
|
||||
approvalUnavailable:
|
||||
"Patvirtinimo čia užbaigti nepavyko. Atidarykite konsolę ir peržiūrėkite įrašytą pasiūlymą, tada vėl patvirtinkite.",
|
||||
toolError: "Įrankis atmetė iškvietimą.",
|
||||
planRefused: "Planas atmestas priėmimo metu.",
|
||||
internal: "Vykdymas nutrūko vidinės klaidos.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// The agent module's public surface: the manifest and what the composition roots bind.
|
||||
|
||||
export { AGENT_MODEL, AGENT_TOOLS } from "@conusai-kit/agent"
|
||||
export { ASSISTANT_SCOPE } from "@conusai-kit/agent/turn"
|
||||
export { DATA_SOURCE as AGENT_DATA } from "@conusai-kit/data"
|
||||
export {
|
||||
AGENT_JOBS,
|
||||
AGENT_PROCEDURE_IMPLS,
|
||||
AGENT_SUBSCRIPTIONS,
|
||||
gatewayAgentModel,
|
||||
registryAgentTools,
|
||||
} from "./adapters/procedures.js"
|
||||
export * from "./contract/index.js"
|
||||
export type { AgentActionScope, AgentDeps, AgentScope } from "./core/ports.js"
|
||||
export { AGENT_SCOPE, actionScopeOf } from "./core/ports.js"
|
||||
export { cancelRun, getRun, listApprovals, listRuns, openRunEvents, reviewApproval, startRun } from "./core/runs.js"
|
||||
export { convexAgentModule } from "./module.convex.js"
|
||||
export { agentModule, assistantAppModule } from "./module.js"
|
||||
export { AGENT_MODULE_BASE } from "./module.shared.js"
|
||||
export { agentSchedules } from "./schedules.js"
|
||||
@@ -0,0 +1,66 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
create table "agent_run" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"goal" text not null,
|
||||
"plan" jsonb not null,
|
||||
"status" text not null,
|
||||
"failure_code" text,
|
||||
"failure_detail" text,
|
||||
"actor_id" text not null,
|
||||
"budget_cost_microcents" integer not null,
|
||||
"budget_steps" integer not null,
|
||||
"budget_seconds" integer not null,
|
||||
"usage_cost_microcents" integer not null,
|
||||
"usage_steps" integer not null,
|
||||
"cancel_requested_at" text,
|
||||
"started_at" text,
|
||||
"finished_at" text
|
||||
);
|
||||
|
||||
create index "agent_run_org_id_workspace_id_project_id_status_idx" on "agent_run" ("org_id", "workspace_id", "project_id", "status");
|
||||
|
||||
create table "agent_step" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"run_id" uuid not null,
|
||||
"key" text not null,
|
||||
"index" integer not null,
|
||||
"label" text not null,
|
||||
"status" text not null,
|
||||
"attempts" integer not null,
|
||||
"usage_cost_microcents" integer not null,
|
||||
"usage_tokens" integer not null,
|
||||
"error" text,
|
||||
"external_effect" boolean not null,
|
||||
"artifacts" jsonb not null,
|
||||
"approval" jsonb,
|
||||
"started_at" text,
|
||||
"finished_at" text
|
||||
);
|
||||
|
||||
alter table "agent_step" add constraint "agent_step_org_id_workspace_id_project_id_run_id_key_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "run_id", "key");
|
||||
|
||||
create index "agent_step_org_id_workspace_id_project_id_run_id_idx" on "agent_step" ("org_id", "workspace_id", "project_id", "run_id");
|
||||
|
||||
alter table "agent_run" enable row level security;
|
||||
|
||||
alter table "agent_run" force row level security;
|
||||
|
||||
create policy "agent_run_tenant" on "agent_run" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "agent_step" enable row level security;
|
||||
|
||||
alter table "agent_step" force row level security;
|
||||
|
||||
create policy "agent_step_tenant" on "agent_step" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
alter table "agent_step" add column "result" jsonb;
|
||||
|
||||
create table "agent_thread" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"thread_id" text not null,
|
||||
"messages" jsonb not null
|
||||
);
|
||||
|
||||
alter table "agent_thread" add constraint "agent_thread_org_id_workspace_id_project_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "thread_id");
|
||||
|
||||
alter table "agent_thread" enable row level security;
|
||||
|
||||
alter table "agent_thread" force row level security;
|
||||
|
||||
create policy "agent_thread_tenant" on "agent_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
alter table "agent_run" add column "answer_provenance" jsonb;
|
||||
@@ -0,0 +1,105 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
create table "assistant_conversation" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"title" text not null,
|
||||
"created_by" text not null,
|
||||
"last_turn_at" text not null
|
||||
);
|
||||
|
||||
create index "assistant_conversation_org_id_workspace_id_project_id_created_by_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id", "created_by");
|
||||
|
||||
create index "assistant_conversation_org_id_workspace_id_project_id_last_turn_at_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id", "last_turn_at");
|
||||
|
||||
create table "assistant_receipt" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"conversation_id" uuid not null,
|
||||
"turn_id" text not null,
|
||||
"tool_call_id" text not null,
|
||||
"tool" text not null,
|
||||
"kind" text not null,
|
||||
"outcome" text not null,
|
||||
"proposal_id" text,
|
||||
"arguments_hash" text not null,
|
||||
"created_by" text not null
|
||||
);
|
||||
|
||||
alter table "assistant_receipt" add constraint "assistant_receipt_org_id_workspace_id_project_id_turn_id_tool_call_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "turn_id", "tool_call_id");
|
||||
|
||||
create index "assistant_receipt_org_id_workspace_id_project_id_created_by_idx" on "assistant_receipt" ("org_id", "workspace_id", "project_id", "created_by");
|
||||
|
||||
create index "assistant_receipt_org_id_workspace_id_project_id_conversation_id_idx" on "assistant_receipt" ("org_id", "workspace_id", "project_id", "conversation_id");
|
||||
|
||||
create table "assistant_thread" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"thread_id" text not null,
|
||||
"messages" jsonb not null
|
||||
);
|
||||
|
||||
alter table "assistant_thread" add constraint "assistant_thread_org_id_workspace_id_project_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "thread_id");
|
||||
|
||||
create table "assistant_turn" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"turn_id" text not null,
|
||||
"conversation_id" uuid not null,
|
||||
"created_by" text not null,
|
||||
"input_hash" text not null,
|
||||
"status" text not null,
|
||||
"steps" integer not null,
|
||||
"tool_calls" integer not null,
|
||||
"degraded" boolean not null,
|
||||
"answer_provenance" jsonb,
|
||||
"started_at" text not null,
|
||||
"finished_at" text
|
||||
);
|
||||
|
||||
alter table "assistant_turn" add constraint "assistant_turn_org_id_workspace_id_project_id_turn_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "turn_id");
|
||||
|
||||
create index "assistant_turn_org_id_workspace_id_project_id_conversation_id_idx" on "assistant_turn" ("org_id", "workspace_id", "project_id", "conversation_id");
|
||||
|
||||
alter table "assistant_conversation" enable row level security;
|
||||
|
||||
alter table "assistant_conversation" force row level security;
|
||||
|
||||
create policy "assistant_conversation_tenant" on "assistant_conversation" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_receipt" enable row level security;
|
||||
|
||||
alter table "assistant_receipt" force row level security;
|
||||
|
||||
create policy "assistant_receipt_tenant" on "assistant_receipt" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_thread" enable row level security;
|
||||
|
||||
alter table "assistant_thread" force row level security;
|
||||
|
||||
create policy "assistant_thread_tenant" on "assistant_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_turn" enable row level security;
|
||||
|
||||
alter table "assistant_turn" force row level security;
|
||||
|
||||
create policy "assistant_turn_tenant" on "assistant_turn" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
@@ -0,0 +1,118 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
-- The Agent is the workspace's (2026-11-03): its conversations are a workspace's, with a project optional. Its tables
|
||||
-- are made again from the kit's migrations/0001_assistant.sql, and what 0004 held goes with them: no conversation is
|
||||
-- carried over, because there was none to keep (no user had the template's Agent yet).
|
||||
drop table "assistant_receipt";
|
||||
|
||||
drop table "assistant_thread";
|
||||
|
||||
drop table "assistant_turn";
|
||||
|
||||
drop table "assistant_conversation";
|
||||
|
||||
create table "assistant_conversation" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"title" text not null,
|
||||
"created_by" text not null,
|
||||
"last_turn_at" text not null,
|
||||
"project_id" uuid
|
||||
);
|
||||
|
||||
create index "assistant_conversation_org_id_workspace_id_created_by_idx" on "assistant_conversation" ("org_id", "workspace_id", "created_by");
|
||||
|
||||
create index "assistant_conversation_org_id_workspace_id_last_turn_at_idx" on "assistant_conversation" ("org_id", "workspace_id", "last_turn_at");
|
||||
|
||||
create index "assistant_conversation_org_id_workspace_id_project_id_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id");
|
||||
|
||||
create table "assistant_receipt" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"conversation_id" uuid not null,
|
||||
"turn_id" text not null,
|
||||
"step" integer not null,
|
||||
"tool_call_id" text not null,
|
||||
"tool" text not null,
|
||||
"kind" text not null,
|
||||
"outcome" text not null,
|
||||
"proposal_id" text,
|
||||
"arguments_hash" text not null,
|
||||
"created_by" text not null,
|
||||
"project_id" uuid
|
||||
);
|
||||
|
||||
alter table "assistant_receipt" add constraint "assistant_receipt_org_id_workspace_id_turn_id_step_tool_call_id_key" unique nulls not distinct ("org_id", "workspace_id", "turn_id", "step", "tool_call_id");
|
||||
|
||||
create index "assistant_receipt_org_id_workspace_id_created_by_idx" on "assistant_receipt" ("org_id", "workspace_id", "created_by");
|
||||
|
||||
create index "assistant_receipt_org_id_workspace_id_conversation_id_idx" on "assistant_receipt" ("org_id", "workspace_id", "conversation_id");
|
||||
|
||||
create table "assistant_thread" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"thread_id" text not null,
|
||||
"messages" jsonb not null
|
||||
);
|
||||
|
||||
alter table "assistant_thread" add constraint "assistant_thread_org_id_workspace_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "thread_id");
|
||||
|
||||
create table "assistant_turn" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"turn_id" text not null,
|
||||
"conversation_id" uuid not null,
|
||||
"created_by" text not null,
|
||||
"input_hash" text not null,
|
||||
"status" text not null,
|
||||
"steps" integer not null,
|
||||
"tool_calls" integer not null,
|
||||
"degraded" boolean not null,
|
||||
"answer_provenance" jsonb,
|
||||
"started_at" text not null,
|
||||
"deadline_at" text not null,
|
||||
"finished_at" text
|
||||
);
|
||||
|
||||
alter table "assistant_turn" add constraint "assistant_turn_org_id_workspace_id_turn_id_key" unique nulls not distinct ("org_id", "workspace_id", "turn_id");
|
||||
|
||||
create index "assistant_turn_org_id_workspace_id_conversation_id_idx" on "assistant_turn" ("org_id", "workspace_id", "conversation_id");
|
||||
|
||||
alter table "assistant_conversation" enable row level security;
|
||||
|
||||
alter table "assistant_conversation" force row level security;
|
||||
|
||||
create policy "assistant_conversation_tenant" on "assistant_conversation" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_receipt" enable row level security;
|
||||
|
||||
alter table "assistant_receipt" force row level security;
|
||||
|
||||
create policy "assistant_receipt_tenant" on "assistant_receipt" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_thread" enable row level security;
|
||||
|
||||
alter table "assistant_thread" force row level security;
|
||||
|
||||
create policy "assistant_thread_tenant" on "assistant_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "assistant_turn" enable row level security;
|
||||
|
||||
alter table "assistant_turn" force row level security;
|
||||
|
||||
create policy "assistant_turn_tenant" on "assistant_turn" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
@@ -0,0 +1,23 @@
|
||||
// The Convex profile binding of the agent module, shipped fail-closed for a future wiring: the
|
||||
// integration review kept the module Postgres-only because a run's tool calls leave the process
|
||||
// (the registry client, the AI gateway) and the run job has no composed Convex Node action yet
|
||||
// (see README.md). The job fails closed here so the work can only reach the platform SDKs through
|
||||
// a composed Node action, never the Convex transaction dispatcher — the same arrangement as
|
||||
// documents' OCR.
|
||||
|
||||
import { defineModule, internal, type JobDef } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { RUN_JOB } from "./contract/index.js"
|
||||
import { AGENT_MODULE_BASE } from "./module.shared.js"
|
||||
|
||||
const actionOnly: JobDef<{ runId: string }> = {
|
||||
payload: z.object({ runId: z.string() }),
|
||||
async handler() {
|
||||
throw internal(`${RUN_JOB} must run through the Convex Node action boundary`)
|
||||
},
|
||||
}
|
||||
|
||||
export const convexAgentModule = defineModule({
|
||||
...AGENT_MODULE_BASE,
|
||||
jobs: { [RUN_JOB]: actionOnly },
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared, runtime-neutral agent manifest data. The normal app binds the worker implementation of
|
||||
// agent.run; the Convex binding file (module.convex.ts) ships fail-closed for a future wiring —
|
||||
// the module stays Postgres-only until the run job has its composed Node action (see README.md).
|
||||
|
||||
import { AGENT_ENTITIES } from "@conusai-kit/agent"
|
||||
import type { ModuleManifest } from "@conusai-kit/core"
|
||||
import { repositoryBindings } from "@conusai-kit/data"
|
||||
import { AGENT_PROCEDURE_IMPLS } from "./adapters/procedures.js"
|
||||
import { AGENT_COLLECTIONS, APPROVAL_REVIEWED, RUN_CANCELLED_ACTION } from "./contract/index.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
const tables = repositoryBindings(...AGENT_ENTITIES)
|
||||
|
||||
export const AGENT_MODULE_BASE: ModuleManifest = {
|
||||
id: "agent",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
// Watching runs and reading one is open to the project's readers.
|
||||
"agent.run.read": "viewer",
|
||||
// Starting, cancelling and reviewing are a person's acts on the project's behalf.
|
||||
"agent.run.start": "editor",
|
||||
"agent.run.cancel": "editor",
|
||||
"agent.approval.review": "editor",
|
||||
},
|
||||
procedures: AGENT_PROCEDURE_IMPLS,
|
||||
// The runs are live (TS6.1): a change of agent_run reaches every member's open page.
|
||||
collections: [...AGENT_COLLECTIONS],
|
||||
repositories: { postgres: tables },
|
||||
migrations: { postgres: "migrations" },
|
||||
// The module ships the Postgres profile; module.convex.ts is the fail-closed Convex binding,
|
||||
// deliberately not registered until the run job has its composed Node action (see README.md).
|
||||
profiles: ["postgres"],
|
||||
audit: [RUN_CANCELLED_ACTION, APPROVAL_REVIEWED],
|
||||
ui: {
|
||||
nav: [{ scope: "project", label: "agent.title", href: "agent", permission: "agent.run.read" }],
|
||||
},
|
||||
i18n: { en, lt },
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// The agent module's manifest (spec §4.1): the one place it registers anything. The module's own
|
||||
// outbox subscriber enqueues the run job (singleton on the run id), so a request writes the run
|
||||
// row and answers; the worker executes and resumes it.
|
||||
|
||||
import { agentRunRetentionDaysOf } from "@app/config"
|
||||
import { assistantModule, assistantSchedules } from "@conusai-kit/agent/turn"
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { AGENT_JOBS, AGENT_SUBSCRIPTIONS } from "./adapters/procedures.js"
|
||||
import { AGENT_EVENTS } from "./contract/index.js"
|
||||
import { AGENT_MODULE_BASE } from "./module.shared.js"
|
||||
import { agentSchedules } from "./schedules.js"
|
||||
|
||||
export const agentModule = defineModule({
|
||||
...AGENT_MODULE_BASE,
|
||||
jobs: AGENT_JOBS,
|
||||
// `agent.runs.expire`, hourly for every project: deletes the finished runs past the retention the environment
|
||||
// sets (AGENT_RUN_RETENTION_DAYS), with their step logs and conversations.
|
||||
schedules: (env) => agentSchedules(agentRunRetentionDaysOf(env)),
|
||||
events: { publishes: [...AGENT_EVENTS], subscribes: AGENT_SUBSCRIPTIONS },
|
||||
})
|
||||
|
||||
/**
|
||||
* The conversation behind the Agent in the frame's right panel (`@conusai-kit/agent/turn`). The kit's module
|
||||
* declares no migrations: its tables are this module's `0005_assistant_workspace.sql`, the kit's file
|
||||
* after the drop of what `0004_assistant.sql` made. Its sweep
|
||||
* deletes a conversation nobody wrote to for 90 days, with its turns, logs and receipts.
|
||||
*/
|
||||
export const assistantAppModule = defineModule({ ...assistantModule, schedules: () => assistantSchedules() })
|
||||
@@ -0,0 +1,26 @@
|
||||
// When the agent module's run retention sweep runs (G3j): hourly, in each project the app serves, with the
|
||||
// retention the environment sets (AGENT_RUN_RETENTION_DAYS, config/src/agent.ts). The composition root turns
|
||||
// it on (apps/worker/src/schedules.ts), as it does the account and files sweeps. The schedule is the
|
||||
// adapter-neutral form, so the module imports no adapter.
|
||||
|
||||
import { RUN_EXPIRE_CRON, RUN_EXPIRE_JOB } from "./contract/constants.js"
|
||||
|
||||
export function agentSchedules(retentionDays: number): readonly {
|
||||
job: string
|
||||
cron: string
|
||||
scope: { level: "each-project" }
|
||||
payload: { retentionDays: number }
|
||||
key: string
|
||||
missed: "skip"
|
||||
}[] {
|
||||
return Object.freeze([
|
||||
Object.freeze({
|
||||
job: RUN_EXPIRE_JOB,
|
||||
cron: RUN_EXPIRE_CRON,
|
||||
scope: Object.freeze({ level: "each-project" as const }),
|
||||
payload: Object.freeze({ retentionDays }),
|
||||
key: "agent.runs.expire",
|
||||
missed: "skip" as const,
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
// The approval queue: what a run's tool asked a person to decide. The panel renders the EXACT
|
||||
// stored proposed arguments — a pretty-printed JSON of what the tool was called with — never a
|
||||
// model's narration of them, and it carries the AI disclosure a reviewer reads before deciding.
|
||||
//
|
||||
// The queue is live: the page's view hands over the approvals it rendered as the seed, and this reads them
|
||||
// again whenever the live stream announces a change of a run (`agent.run`) -- a run that parks an approval
|
||||
// or leaves one changes its status -- and after this person's own decision, so nothing reloads the page.
|
||||
|
||||
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useRead, useWrite } from "@conusai-kit/ui/data"
|
||||
import { PageSection, PageState } from "@conusai-kit/ui/patterns"
|
||||
import { Button, ItemActions } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { type ApprovalView, RUN_COLLECTION } from "../contract/index.js"
|
||||
|
||||
export interface ApprovalQueueLabels {
|
||||
readonly heading: string
|
||||
readonly empty: string
|
||||
readonly proposed: string
|
||||
readonly review: string
|
||||
readonly approve: string
|
||||
readonly reject: string
|
||||
readonly decided: string
|
||||
readonly disclosure: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function ApprovalQueue({
|
||||
slugs,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
/** The approvals the server rendered: shown at once, then kept current. */
|
||||
seed: readonly ApprovalView[]
|
||||
labels: ApprovalQueueLabels
|
||||
}): ReactElement {
|
||||
const queue = useRead<{ approvals: readonly ApprovalView[] }>(
|
||||
"agent.approval.list",
|
||||
{ ...slugs },
|
||||
{
|
||||
live: {
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entities: [RUN_COLLECTION.entity],
|
||||
},
|
||||
initial: { approvals: seed },
|
||||
},
|
||||
)
|
||||
const approvals = queue.data?.approvals ?? seed
|
||||
const review = useWrite<ProjectSlugs & { runId: string; stepKey: string; decision: "approve" | "reject" }, unknown>(
|
||||
"agent.approval.review",
|
||||
)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const decide = async (approval: ApprovalView, decision: "approve" | "reject"): Promise<void> => {
|
||||
setBusy(`${approval.runId}:${approval.stepKey}`)
|
||||
setMessage(null)
|
||||
try {
|
||||
await review.mutate({ ...slugs, runId: approval.runId, stepKey: approval.stepKey, decision })
|
||||
setMessage(labels.decided)
|
||||
// The decision is a step's, which the stream does not announce until the run moves: read it again now.
|
||||
await queue.refetch()
|
||||
} catch (error) {
|
||||
setMessage(labels.failed.replace("{detail}", describeError(error)))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
{approvals.length === 0 ? <PageState title={labels.empty} /> : null}
|
||||
{approvals.map((approval) => (
|
||||
<PageSection
|
||||
key={`${approval.runId}:${approval.stepKey}`}
|
||||
title={approval.tool}
|
||||
description={approval.goal}
|
||||
level={3}
|
||||
variant="card"
|
||||
>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<h4>{labels.proposed}</h4>
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
<ItemActions>
|
||||
<Button disabled={busy !== null} onClick={() => void decide(approval, "approve")}>
|
||||
{labels.approve}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={() => void decide(approval, "reject")}>
|
||||
{labels.reject}
|
||||
</Button>
|
||||
{approval.reviewUrl !== null ? (
|
||||
<Button asChild variant="link">
|
||||
<a href={approval.reviewUrl} target="_blank" rel="noreferrer">
|
||||
{labels.review}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</PageSection>
|
||||
))}
|
||||
{message !== null ? (
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ApprovalQueue } from "./approval-queue.js"
|
||||
export { RunList, type RunListLabels, useLiveRuns } from "./live-runs.js"
|
||||
export { agentPage, runStatusKey } from "./pages.js"
|
||||
export { RunControls } from "./run-controls.js"
|
||||
export { StartForm } from "./start-form.js"
|
||||
@@ -0,0 +1,77 @@
|
||||
// What the run page shows, from the server's first render (`agent.run.status`) and the run's AG-UI
|
||||
// events as `@conusai-kit/agent/client` folds them. Until the first event arrives the seed is the
|
||||
// answer, so the hydrated page matches the server's; from then on the events are: the stream replays
|
||||
// the run from its start, so the view holds every step that began.
|
||||
|
||||
import type { AgentRunView } from "@conusai-kit/agent/client"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
const FAILURE_CODES = ["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"] as const
|
||||
type FailureCode = (typeof FAILURE_CODES)[number]
|
||||
const isFailureCode = (code: string): code is FailureCode => (FAILURE_CODES as readonly string[]).includes(code)
|
||||
|
||||
export interface LiveRunState {
|
||||
readonly status: RunView["status"]
|
||||
readonly failureCode: RunView["failureCode"]
|
||||
readonly failureDetail: string | null
|
||||
readonly steps: readonly {
|
||||
readonly key: string
|
||||
readonly status: RunView["plan"][number]["status"]
|
||||
readonly error: string | null
|
||||
}[]
|
||||
readonly percent: number
|
||||
/** The AI-generated answer of a completed run. */
|
||||
readonly answer: string | null
|
||||
/**
|
||||
* The answer's provenance (AI Act Art. 50(2)), from the server's render: a run that completes
|
||||
* while the page is open shows its answer marked without it until the page is loaded again.
|
||||
*/
|
||||
readonly answerProvenance: RunView["answerProvenance"]
|
||||
/** The stream could not be read: the page still shows the last state it knew. */
|
||||
readonly streamError: string | null
|
||||
}
|
||||
|
||||
export function liveRunState(seed: RunView, view: AgentRunView): LiveRunState {
|
||||
const streamError = view.error?.source === "transport" ? view.error.code : null
|
||||
if (view.events === 0) {
|
||||
return {
|
||||
status: seed.status,
|
||||
failureCode: seed.failureCode,
|
||||
failureDetail: seed.failureDetail,
|
||||
steps: seed.plan.map((step) => ({ key: step.key, status: step.status, error: step.error })),
|
||||
percent: seed.percent,
|
||||
answer: seed.answer,
|
||||
answerProvenance: seed.answerProvenance,
|
||||
streamError,
|
||||
}
|
||||
}
|
||||
const steps = seed.plan.map((planned) => {
|
||||
const step = view.steps.find((candidate) => candidate.key === planned.key)
|
||||
if (step === undefined || !step.finished) return { key: planned.key, status: "pending" as const, error: null }
|
||||
return { key: planned.key, status: step.outcome ?? ("committed" as const), error: step.error }
|
||||
})
|
||||
const committed = steps.filter((step) => step.status === "committed").length
|
||||
const runError = view.error?.source === "run" ? view.error : null
|
||||
return {
|
||||
status: statusOf(seed, view),
|
||||
failureCode: runError !== null && isFailureCode(runError.code) ? runError.code : null,
|
||||
failureDetail: runError?.message ?? null,
|
||||
steps,
|
||||
percent: seed.plan.length === 0 ? 0 : committed / seed.plan.length,
|
||||
answer: view.phase === "completed" && view.text !== "" ? view.text : null,
|
||||
answerProvenance: view.phase === "completed" ? seed.answerProvenance : null,
|
||||
streamError,
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(seed: RunView, view: AgentRunView): RunView["status"] {
|
||||
switch (view.phase) {
|
||||
case "connecting":
|
||||
return seed.status
|
||||
// The stream opens every run with RUN_STARTED; a run the worker has not claimed is still queued.
|
||||
case "running":
|
||||
return seed.status === "queued" && view.steps.length === 0 ? "queued" : "running"
|
||||
default:
|
||||
return view.phase
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// One run, live (umbrella TS5.8): the page's loader reads the run from `agent.run.status` and its view
|
||||
// hands it over as the seed, so the page arrives complete; this island then follows the run's AG-UI
|
||||
// event stream (`agent.run.events`) through TanStack AI's resumable connection
|
||||
// (`agentRunConnection`, `watchAgentRun` from `@conusai-kit/agent/client`) — step states, the
|
||||
// status, a parked approval and the completed run's answer appear without a reload, and no request
|
||||
// polls the run. A dropped stream reconnects with its last offset inside the connection.
|
||||
|
||||
import { agentRunConnection, initialAgentRunView, watchAgentRun } from "@conusai-kit/agent/client"
|
||||
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useEffect, useState } from "react"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
import { liveRunState } from "./live-run-state.js"
|
||||
import { RunControls, type RunControlsLabels } from "./run-controls.js"
|
||||
|
||||
export interface LiveRunLabels {
|
||||
readonly status: string
|
||||
readonly progress: string
|
||||
readonly plan: string
|
||||
readonly step: string
|
||||
readonly tool: string
|
||||
readonly needsApproval: string
|
||||
readonly answer: string
|
||||
readonly answerDisclosure: string
|
||||
readonly streamLost: string
|
||||
/** The message of each run status, by status. */
|
||||
readonly statuses: Record<RunView["status"], string>
|
||||
/** The message of each step status, by status. */
|
||||
readonly stepStatuses: Record<RunView["plan"][number]["status"], string>
|
||||
readonly controls: RunControlsLabels
|
||||
}
|
||||
|
||||
export function LiveRun({
|
||||
slugs,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: RunView
|
||||
labels: LiveRunLabels
|
||||
}): ReactElement {
|
||||
const [view, setView] = useState(() => initialAgentRunView(seed.runId))
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams({ org: slugs.org, workspace: slugs.workspace, project: slugs.project })
|
||||
const connection = agentRunConnection({ url: `/api/v1/agent.run.events?${query.toString()}` })
|
||||
const stop = new AbortController()
|
||||
void watchAgentRun({ connection, runId: seed.runId, signal: stop.signal, onChange: setView })
|
||||
return () => stop.abort()
|
||||
}, [slugs.org, slugs.workspace, slugs.project, seed.runId])
|
||||
|
||||
const state = liveRunState(seed, view)
|
||||
return (
|
||||
<div data-live="agent.run">
|
||||
<dl>
|
||||
<dt>{labels.status}</dt>
|
||||
<dd role="status">{labels.statuses[state.status]}</dd>
|
||||
</dl>
|
||||
<progress value={state.percent} max={1} aria-label={labels.progress}>
|
||||
{Math.round(state.percent * 100)}%
|
||||
</progress>
|
||||
{state.streamError !== null ? <p role="alert">{labels.streamLost}</p> : null}
|
||||
<RunControls
|
||||
slugs={slugs}
|
||||
run={{ runId: seed.runId, status: state.status, failureCode: state.failureCode }}
|
||||
labels={labels.controls}
|
||||
/>
|
||||
<section aria-labelledby="plan-heading">
|
||||
<h2 id="plan-heading">{labels.plan}</h2>
|
||||
<Table>
|
||||
<TableCaption>{labels.plan}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.step}</TableHead>
|
||||
<TableHead scope="col">{labels.tool}</TableHead>
|
||||
<TableHead scope="col">{labels.status}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{seed.plan.map((step, index) => {
|
||||
const live = state.steps[index]
|
||||
return (
|
||||
<TableRow key={step.key}>
|
||||
<TableCell>
|
||||
{step.label}
|
||||
{step.needsApproval ? ` (${labels.needsApproval})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{step.tool}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{labels.stepStatuses[live?.status ?? step.status]}
|
||||
{live?.error ? `: ${live.error}` : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
{state.answer !== null ? (
|
||||
<section aria-labelledby="answer-heading">
|
||||
<h2 id="answer-heading">{labels.answer}</h2>
|
||||
<AiDisclosureBadge label={labels.answerDisclosure} provenance={state.answerProvenance} />
|
||||
<p>{state.answer}</p>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
// The project's runs, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
|
||||
// over as the seed, so the page arrives complete; this keeps it current through the organization's live
|
||||
// stream -- a run another member starts appears, and each run's status and progress move as its steps
|
||||
// commit, without a reload. The rows come from `agent.run.getMany`, under the same permission as the list,
|
||||
// so the stream itself never carries a run's goal or plan.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
|
||||
import { PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { RUN_COLLECTION, type RunView } from "../contract/index.js"
|
||||
|
||||
export interface RunListLabels {
|
||||
readonly heading: string
|
||||
/** What to say when there is no run. */
|
||||
readonly empty: string
|
||||
readonly goal: string
|
||||
readonly status: string
|
||||
readonly progress: string
|
||||
readonly startedBy: string
|
||||
/** The message of each run status, by status. */
|
||||
readonly statuses: Record<RunView["status"], string>
|
||||
}
|
||||
|
||||
export function useLiveRuns(slugs: ProjectSlugs, seed: readonly RunView[]): readonly RunView[] {
|
||||
const client = useDataClient()
|
||||
// One collection for the life of the page; the seed is the first render's.
|
||||
const [runs] = useState(() =>
|
||||
liveCollection<RunView>({
|
||||
client,
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entity: RUN_COLLECTION.entity,
|
||||
key: RUN_COLLECTION.key,
|
||||
seed,
|
||||
list: {
|
||||
procedure: RUN_COLLECTION.list,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { runs: RunView[] }).runs,
|
||||
},
|
||||
getMany: {
|
||||
procedure: RUN_COLLECTION.getMany,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { items: RunView[] }).items,
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Newest first, as the list procedure orders them.
|
||||
const { data, isReady } = useLive(runs, (q, rows) =>
|
||||
q.from({ run: rows }).orderBy(({ run }: { run: RunView }) => run.createdAt, "desc"),
|
||||
)
|
||||
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
|
||||
return isReady ? data : seed
|
||||
}
|
||||
|
||||
export function RunList({
|
||||
slugs,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: readonly RunView[]
|
||||
labels: RunListLabels
|
||||
}): ReactElement {
|
||||
const rows = useLiveRuns(slugs, seed)
|
||||
const pageHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`
|
||||
const columns: ResourceColumn<RunView>[] = [
|
||||
{ id: "goal", header: labels.goal, wrap: true },
|
||||
{ id: "status", header: labels.status, cell: (run) => labels.statuses[run.status] },
|
||||
{
|
||||
id: "percent",
|
||||
header: labels.progress,
|
||||
cell: (run) => (
|
||||
<progress value={run.percent} max={1}>
|
||||
{Math.round(run.percent * 100)}%
|
||||
</progress>
|
||||
),
|
||||
},
|
||||
{ id: "actorId", header: labels.startedBy },
|
||||
]
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
<ResourceList
|
||||
label={labels.heading}
|
||||
columns={columns}
|
||||
data={rows}
|
||||
rowId={(run) => run.runId}
|
||||
rowHref={(run) => `${pageHref}/${run.runId}`}
|
||||
rowLabel={(run) => run.goal}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// The agent module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web shell
|
||||
// runs on the server under `/<org>/<workspace>/<project>/agent`, and the view of what it returned.
|
||||
//
|
||||
// /agent the run list, the approval queue and the start form
|
||||
// /agent/<runId> one run: the plan with per-step progress, the budget, the recovery controls and
|
||||
// a completed run's AI-generated answer, live through the run's event stream
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses, and every label is translated in the
|
||||
// loader. The run page's first render is `agent.run.status`, the seed of a client island that follows the
|
||||
// run's AG-UI events (live-run.tsx); both come from the durable record, so a reload after a restart tells
|
||||
// the truth. The progress bar is a real <progress> element, keyboard-operable controls only.
|
||||
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { DetailLayout, FactList, Page, PageHeader } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
|
||||
import { ApprovalQueue, type ApprovalQueueLabels } from "./approval-queue.js"
|
||||
import { LiveRun, type LiveRunLabels } from "./live-run.js"
|
||||
import { RunList, type RunListLabels } from "./live-runs.js"
|
||||
import { StartForm, type StartFormLabels } from "./start-form.js"
|
||||
|
||||
/** The run list, the approval queue and the start form. */
|
||||
export interface AgentListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly approvals: readonly ApprovalView[]
|
||||
readonly approvalLabels: ApprovalQueueLabels
|
||||
readonly start: StartFormLabels
|
||||
readonly runLabels: RunListLabels
|
||||
/** The first page of runs: the live list's seed. */
|
||||
readonly runs: readonly RunView[]
|
||||
}
|
||||
|
||||
/** One run: the seed of the live island, and the facts that do not change while it runs. */
|
||||
export interface AgentRunData {
|
||||
readonly kind: "run"
|
||||
readonly back: string
|
||||
readonly startedBy: string
|
||||
readonly usageLabel: string
|
||||
readonly usage: string
|
||||
readonly run: RunView
|
||||
readonly labels: LiveRunLabels
|
||||
}
|
||||
|
||||
export type AgentPageData = AgentListData | AgentRunData
|
||||
|
||||
export type AgentPage = ProjectPage<TransportCaller, I18n, Container, AgentPageData>
|
||||
|
||||
export const runStatusKey: Record<RunView["status"], string> = {
|
||||
queued: "runs.statusQueued",
|
||||
running: "runs.statusRunning",
|
||||
awaiting_approval: "runs.statusAwaitingApproval",
|
||||
completed: "runs.statusCompleted",
|
||||
failed: "runs.statusFailed",
|
||||
cancelled: "runs.statusCancelled",
|
||||
uncertain: "runs.statusUncertain",
|
||||
}
|
||||
|
||||
const stepStatusKey: Record<RunView["plan"][number]["status"], string> = {
|
||||
pending: "runs.stepPending",
|
||||
committed: "runs.stepCommitted",
|
||||
failed: "runs.stepFailed",
|
||||
cancelled: "runs.stepCancelled",
|
||||
uncertain: "runs.stepUncertain",
|
||||
}
|
||||
|
||||
const mapValues = <K extends string>(keys: Record<K, string>, t: (key: string) => string): Record<K, string> =>
|
||||
Object.fromEntries(Object.entries<string>(keys).map(([status, key]) => [status, t(key)])) as Record<K, string>
|
||||
|
||||
export const agentPage: AgentPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<AgentPageData> {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
if (segments.length === 0) {
|
||||
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
|
||||
return {
|
||||
kind: "list",
|
||||
title: t("title"),
|
||||
intro: t("intro"),
|
||||
approvals: approvals.approvals as ApprovalView[],
|
||||
approvalLabels: {
|
||||
heading: t("approvals.heading"),
|
||||
empty: t("approvals.empty"),
|
||||
proposed: t("approvals.proposed"),
|
||||
review: t("approvals.review"),
|
||||
approve: t("approvals.approve"),
|
||||
reject: t("approvals.reject"),
|
||||
decided: t("approvals.decided"),
|
||||
disclosure: t("approvals.disclosure"),
|
||||
failed,
|
||||
},
|
||||
start: {
|
||||
heading: t("start.heading"),
|
||||
goalLabel: t("start.goalLabel"),
|
||||
goalHint: t("start.goalHint"),
|
||||
planLabel: t("start.planLabel"),
|
||||
planHint: t("start.planHint"),
|
||||
submit: t("start.submit"),
|
||||
starting: t("start.starting"),
|
||||
failed,
|
||||
},
|
||||
runLabels: {
|
||||
heading: t("runs.heading"),
|
||||
empty: t("runs.empty"),
|
||||
goal: t("runs.goal"),
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
startedBy: t("runs.startedBy"),
|
||||
statuses: mapValues(runStatusKey, t),
|
||||
},
|
||||
runs: runs.runs,
|
||||
}
|
||||
}
|
||||
|
||||
// A path that is not one run id is a page that does not exist.
|
||||
const [runId, ...rest] = segments
|
||||
if (runId === undefined || rest.length > 0) throw notFound("no such page")
|
||||
const run = await caller.call(runStatus, { ...slugs, runId })
|
||||
return {
|
||||
kind: "run",
|
||||
back: t("runs.back"),
|
||||
startedBy: t("runs.startedBy"),
|
||||
usageLabel: t("runs.usageLabel"),
|
||||
usage: t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents }),
|
||||
run,
|
||||
labels: {
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
plan: t("runs.plan"),
|
||||
step: t("runs.step"),
|
||||
tool: t("runs.tool"),
|
||||
needsApproval: t("runs.needsApproval"),
|
||||
answer: t("runs.answer"),
|
||||
answerDisclosure: t("runs.answerDisclosure"),
|
||||
streamLost: t("runs.streamLost"),
|
||||
statuses: mapValues(runStatusKey, t),
|
||||
stepStatuses: mapValues(stepStatusKey, t),
|
||||
controls: {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data }: ProjectPageViewProps<AgentPageData>): ReactElement {
|
||||
if (data.kind === "list") {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.title} description={data.intro} />
|
||||
<ApprovalQueue slugs={slugs} seed={data.approvals} labels={data.approvalLabels} />
|
||||
<DetailLayout aside={<StartForm slugs={slugs} labels={data.start} />}>
|
||||
<RunList slugs={slugs} seed={data.runs} labels={data.runLabels} />
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
const { run } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={run.goal}
|
||||
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{data.back}</a>}
|
||||
/>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: data.startedBy, value: run.actorId },
|
||||
{ label: data.usageLabel, value: data.usage },
|
||||
]}
|
||||
/>
|
||||
<LiveRun slugs={slugs} seed={run} labels={data.labels} />
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// One run's controls: cancel while it runs, and recovery from its terminal states. The run page is
|
||||
// live (live-run.tsx), so a cancel shows up there without a reload. The recovery
|
||||
// copy tells the person what the state means and what they can do — failed runs can be started
|
||||
// again from the same plan, cancelled runs are simply over, and an uncertain run (a timeout after
|
||||
// an external effect) is reconciled by a person, not retried blindly.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
export interface RunControlsLabels {
|
||||
readonly cancel: string
|
||||
readonly cancelling: string
|
||||
readonly failedHint: string
|
||||
readonly cancelledHint: string
|
||||
readonly uncertainHint: string
|
||||
readonly budgetExhausted: string
|
||||
readonly approvalUnavailable: string
|
||||
readonly toolError: string
|
||||
readonly planRefused: string
|
||||
readonly internal: string
|
||||
}
|
||||
|
||||
export function RunControls({
|
||||
slugs,
|
||||
run,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
run: Pick<RunView, "runId" | "status" | "failureCode">
|
||||
labels: RunControlsLabels
|
||||
}): ReactElement {
|
||||
const cancelRun = useWrite<ProjectSlugs & { runId: string }, unknown>("agent.run.cancel")
|
||||
const [problem, setProblem] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const cancel = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setProblem(false)
|
||||
try {
|
||||
await cancelRun.mutate({ ...slugs, runId: run.runId })
|
||||
} catch {
|
||||
setProblem(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const active = run.status === "queued" || run.status === "running" || run.status === "awaiting_approval"
|
||||
const failureText = (code: RunView["failureCode"]): string => {
|
||||
switch (code) {
|
||||
case "budget_exhausted":
|
||||
return labels.budgetExhausted
|
||||
case "approval_unavailable":
|
||||
return labels.approvalUnavailable
|
||||
case "tool_error":
|
||||
return labels.toolError
|
||||
case "plan_refused":
|
||||
return labels.planRefused
|
||||
default:
|
||||
return labels.internal
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{problem ? <p role="alert">{labels.internal}</p> : null}
|
||||
{active ? (
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void cancel()}>
|
||||
{busy ? labels.cancelling : labels.cancel}
|
||||
</Button>
|
||||
) : null}
|
||||
{run.status === "failed" ? (
|
||||
<p role="alert">
|
||||
{failureText(run.failureCode)} {labels.failedHint}
|
||||
</p>
|
||||
) : null}
|
||||
{run.status === "cancelled" ? <p role="status">{labels.cancelledHint}</p> : null}
|
||||
{run.status === "uncertain" ? <p role="alert">{labels.uncertainHint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
// Starting a run: the goal and the plan as JSON. The plan is what the run will do, shown to the
|
||||
// person before they start it, and admitted by the server against the hard ceilings. The start resolves
|
||||
// once the live stream has seen the new run, so the page's live run list already shows it.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { FormLayout, PageSection, TextField } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export interface StartFormLabels {
|
||||
readonly heading: string
|
||||
readonly goalLabel: string
|
||||
readonly goalHint: string
|
||||
readonly planLabel: string
|
||||
readonly planHint: string
|
||||
readonly submit: string
|
||||
readonly starting: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
const EXAMPLE = JSON.stringify([
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "…" } },
|
||||
])
|
||||
|
||||
const parses = (text: string): boolean => {
|
||||
try {
|
||||
JSON.parse(text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function StartForm({ slugs, labels }: { slugs: ProjectSlugs; labels: StartFormLabels }): ReactElement {
|
||||
const startRun = useWrite<ProjectSlugs & { goal: string; plan: unknown }, unknown>("agent.run.start", {
|
||||
org: slugs.org,
|
||||
})
|
||||
return (
|
||||
<PageSection title={labels.heading} variant="card">
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.starting}
|
||||
validate={({ plan = "" }) =>
|
||||
parses(plan) ? undefined : { plan: labels.failed.replace("{detail}", "invalid JSON") }
|
||||
}
|
||||
onSubmit={async ({ goal = "", plan = "" }) => {
|
||||
await startRun.mutate({ ...slugs, goal, plan: JSON.parse(plan) })
|
||||
}}
|
||||
>
|
||||
<TextField name="goal" label={labels.goalLabel} description={labels.goalHint} required maxLength={2_000} />
|
||||
<TextField
|
||||
multiline
|
||||
name="plan"
|
||||
label={labels.planLabel}
|
||||
description={labels.planHint.replace("{example}", EXAMPLE)}
|
||||
required
|
||||
rows={6}
|
||||
defaultValue={EXAMPLE}
|
||||
/>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user