ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
@@ -0,0 +1,25 @@
// The module the app runs is the kit's: this file proves the re-export composes in this app, with
// every module it installed. The module's own tests are in `@conusai-kit/account`.
import { accountDataOf } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import { accountModule, type DeletionView } from "../index.js"
import { makeApp } from "./harness.js"
describe("the account module of this app", () => {
it("is the kit's module, in the module list", () => {
expect(appModules).toContain(accountModule)
})
it("deletes an account over every share this app installed", async () => {
const app = makeApp()
const ada = await app.signUp("ada@example.test")
const erasing = accountDataOf(app.registry).filter((share) => share.erase)
const view = (await ada.call().callByName("account.deletion.request", { confirm: ada.email })) as DeletionView
expect(view).toMatchObject({ state: "completed", pending: [] })
expect([...view.erased].sort()).toEqual(erasing.map((share) => share.key).sort())
})
})
+4
View File
@@ -0,0 +1,4 @@
// Why the account module's procedures need no tenant-isolation fixture, for the generated suite's
// `exempt` list (test/isolation.test.ts).
export { accountIsolationExempt } from "@conusai-kit/account/testing"
+35
View File
@@ -0,0 +1,35 @@
// The app wired the way apps/web and apps/worker wire the account module, through
// `@conusai-kit/account/testing`: this app's modules, and the recording platforms of the opt-in ones.
import { type AccountAppOptions, makeAccountApp, withoutModules } from "@conusai-kit/account/testing"
import type { ModuleManifest } from "@conusai-kit/core"
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
import { recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
import { appModules } from "../../index.js"
export * from "@conusai-kit/account/testing"
/** The app's modules without `removed`, as `kit template:prune --without` leaves them. */
export const modulesWithout = (...removed: string[]): ModuleManifest[] => withoutModules(appModules, ...removed)
export function makeApp(options: Partial<AccountAppOptions> = {}) {
const guides = recordingGuidesPlatform() // kit:guides
const support = recordingSupportPlatform() // kit:support
const app = makeAccountApp({
...options,
modules: options.modules ?? appModules,
bind(root, registry) {
root.value(GUIDES_PLATFORM, guides) // kit:guides
root.value(GUIDES_REGISTRY, registry) // kit:guides
root.value(SUPPORT_PLATFORM, support) // kit:support
options.bind?.(root, registry)
},
})
return {
...app,
guides, // kit:guides
support, // kit:support
}
}
+3
View File
@@ -0,0 +1,3 @@
// The account module's public API: what a page, another module and the OpenAPI document may import.
export * from "@conusai-kit/account/contract"
+8
View File
@@ -0,0 +1,8 @@
// This app's account module is `@conusai-kit/account`: the person's own account, export my data and
// delete my account, with the resumable deletion ledger, and its migrations, which the migrate job
// reads from the package. This folder keeps what belongs to the app: any extension of the module. To
// extend it, change this file: `{ ...accountModule, ... }` under the same id adds a procedure, a job or
// a share of an account, and the rest of the app reads it from here. A module's own share of an account
// does not need this: contribute it to `accountData`.
export * from "@conusai-kit/account"
+3
View File
@@ -0,0 +1,3 @@
// The account module's components: the panels and label builders of the app's account page.
export * from "@conusai-kit/account/ui"
+30
View File
@@ -0,0 +1,30 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# Activities
Status: Implementation in progress; disabled by default; live acceptance pending.
`kit module:on activities` includes task creation/editing, complete/reopen, discussion and a
project task timeline. It requests only Activities read/write and comment-write scopes. An
owner/admin must separately enable the service and approve permissions; the current verified
binding, compatible service release and dependencies must be ready. Convex is explicitly
unsupported. Turning the module off retains service records.
The adapter resolves the current server-prepared `RequestBusinessBinding` after app membership
and permission checks. There is no deployment singleton or organization-wide fallback. The
service enforces the complete app/environment partition; this module additionally derives the
app project reference from resolved scope and refuses foreign-project task IDs before reads or
mutations. Browser input cannot select a binding, actor, participant or arbitrary subject.
Tasks and comments live only in the Activities service. Member participant references are opaque
and attributable; scoped export/erasure and retained audit obligations remain mandatory. There
is no duplicate app activity table, browser storage, default delete/administrative privacy grant,
email transmission or AI processing. Pending scoped privacy/job/callback acceptance is a blocker,
not proof that a legacy organization-only callback erased app data.
Mutations use stable per-submission request UUIDs, project/operation namespaces and the revision
shown to the reviewer. Task updates preserve fields the form does not edit. Service policy
refusals are bounded app states; remote error bodies are never rendered or logged by this module.
Run the packed-template gate and module isolation suite. Fake ports prove UI/contracts and
project ownership handling only. J10/J14/J16/J18 require the real signed gateway, database runtime
roles, persisted results, callbacks, privacy obligations and browser/API/MCP verification.
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Simulated ports prove module boundaries, not live service/database acceptance.
import { actorRef, projectRef } from "@conusai-kit/business"
import type { ProjectScope } from "@conusai-kit/core"
import { describe, expect, it, vi } from "vitest"
import type { ActivitiesService, TaskRecord } from "../core/ports.js"
import { addComment, changeTask, createTask, listComments, listTasks, ownedTask, updateTask } from "../core/tasks.js"
import { en } from "../i18n/en.js"
import { lt } from "../i18n/lt.js"
const bindingId = "01900000-0000-7000-8000-000000000001"
const projectId = "01900000-0000-7000-8000-000000000002"
const taskId = "act_01900000-0000-7000-8000-000000000003"
const userId = "01900000-0000-7000-8000-000000000004"
const requestId = "01900000-0000-7000-8000-000000000005"
const scope: ProjectScope = {
level: "project",
orgId: bindingId,
workspaceId: bindingId,
projectId,
actor: { userId: userId as never },
}
function fixture() {
const task: TaskRecord = {
taskId,
subject: "Synthetic task",
body: "Fixture details",
revision: "1",
status: "open",
targets: [projectRef(bindingId, projectId)],
createdAt: "2026-11-03T00:00:00Z",
}
const outcomes = new Map<string, { taskId: string; revision: string }>()
let commits = 0
const service: ActivitiesService = {
bindingId,
get: vi.fn(async () => ({ ...task })),
list: vi.fn(async () => ({ rows: [{ ...task }] })),
comments: vi.fn(async () => ({ rows: [] })),
timeline: vi.fn(async () => ({ rows: [{ ...task }] })),
create: vi.fn(async () => ({ taskId, revision: "1" })),
update: vi.fn(async () => ({ taskId, revision: "2" })),
comment: vi.fn(async () => ({
commentId: "cmt_01900000-0000-7000-8000-000000000003",
revision: "2",
})),
change: vi.fn(async (input) => {
const prior = outcomes.get(input.key)
if (prior) return prior
if (input.expectedVersion !== task.revision) throw new Error("fixture stale revision")
task.status = input.state === "done" ? "done" : "open"
task.revision = (BigInt(task.revision) + 1n).toString()
const result = { taskId, revision: task.revision }
outcomes.set(input.key, result)
commits += 1
return result
}),
}
return { task, service, commits: () => commits }
}
describe("project Activities module boundaries (simulated ports)", () => {
it("derives project and participant references from resolved scope, with an operation key", async () => {
const { service } = fixture()
await createTask(service, scope, { subject: " Task ", requestId })
expect(service.create).toHaveBeenCalledWith({
subject: "Task",
body: "",
target: projectRef(bindingId, projectId),
participant: actorRef(bindingId, userId),
key: `activities:${projectId}:create:${requestId}`,
})
await listTasks(service, scope, {})
expect(service.list).toHaveBeenCalledWith({ target: projectRef(bindingId, projectId), status: "open" })
})
it("refuses guessed task ids from another app project before writes or discussion reads", async () => {
const { task, service } = fixture()
task.targets = [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")]
for (const operation of [
() => ownedTask(service, scope, taskId),
() => listComments(service, scope, { taskId }),
() => addComment(service, scope, { taskId, body: "Synthetic", expectedVersion: "1", requestId }),
() => changeTask(service, scope, { taskId, expectedVersion: "1", requestId }, "done"),
])
await expect(operation()).rejects.toMatchObject({ code: "not_found" })
expect(service.comment).not.toHaveBeenCalled()
expect(service.change).not.toHaveBeenCalled()
expect(service.comments).not.toHaveBeenCalled()
})
it("refuses future revision prediction before external mutation", async () => {
const { service } = fixture()
await expect(changeTask(service, scope, { taskId, expectedVersion: "2", requestId }, "done")).rejects.toMatchObject(
{ code: "conflict" },
)
expect(service.change).not.toHaveBeenCalled()
})
it("lets the service deduplicate a lost-response retry without a second domain commit", async () => {
const f = fixture()
const input = { taskId, expectedVersion: "1", requestId }
const first = await changeTask(f.service, scope, input, "done")
expect(await changeTask(f.service, scope, input, "done")).toEqual(first)
expect(f.commits()).toBe(1)
expect(await changeTask(f.service, scope, { ...input, expectedVersion: "2" }, "open")).toEqual({
taskId,
revision: "3",
})
expect(f.commits()).toBe(2)
})
it("rechecks project ownership after discussion reads", async () => {
const { service } = fixture()
vi.mocked(service.comments).mockImplementation(async () => {
vi.mocked(service.get).mockResolvedValue({
...fixture().task,
targets: [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")],
})
return { rows: [{ commentId: taskId, body: "Must not be returned", author: null, erased: false }] }
})
await expect(listComments(service, scope, { taskId })).rejects.toMatchObject({ code: "not_found" })
})
it("passes the authorized target to an update so the adapter preserves other fields", async () => {
const { service } = fixture()
await updateTask(service, scope, { taskId, subject: "Changed", body: "Details", expectedVersion: "1", requestId })
expect(service.update).toHaveBeenCalledWith(
expect.objectContaining({ target: projectRef(bindingId, projectId), expectedVersion: "1", subject: "Changed" }),
)
})
it("has the same translated keys in both languages", () =>
expect(Object.keys(en).sort()).toEqual(Object.keys(lt).sort()))
})
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { businessAppError, toBusinessError } from "@conusai-kit/business"
import { businessRefusalNotices } from "@conusai-kit/business/refusals"
import { describe, expect, it } from "vitest"
import { en } from "../i18n/en.js"
import { lt } from "../i18n/lt.js"
import { describeFailure, type Labels } from "../ui/labels.js"
// What the loader hands the forms (ui/pages.tsx): the module's messages and the kit's refusal sentences.
const labelsOf = (messages: typeof en, locale: "en" | "lt"): Labels => ({
...messages,
refusals: businessRefusalNotices(locale, {
conflict: messages.conflict,
revision_conflict: messages.conflict,
invalid: messages.invalid,
}),
})
describe("Activities error text", () => {
it.each([
[en, "en"],
[lt, "lt"],
] as const)("translates known refusals without showing remote detail", (messages, locale) => {
const labels = labelsOf(messages, locale)
for (const [code, expected] of [
["business_conflict", messages.conflict],
["business_revision_conflict", messages.conflict],
["business_invalid", messages.invalid],
["service_partition_revoked", labels.refusals.service_partition_revoked],
["service_degraded", labels.refusals.service_degraded],
["service_migrating", labels.refusals.service_migrating],
]) {
const message = describeFailure({ problem: { code, detail: "upstream private fixture value" } }, labels)
expect(message).toBe(expected)
expect(message).not.toContain("private fixture value")
}
expect(describeFailure({ problem: { code: "untrusted", detail: "upstream private fixture value" } }, labels)).toBe(
messages.unavailable,
)
})
it("tells a Lithuanian reader the kit's sentence for a refusal of the Activities service", () => {
const refusal = businessAppError(
toBusinessError(
{ type: "urn:conusai:activities:erased-participant", detail: "upstream private fixture value" },
new Response(null, { status: 409 }),
),
)
expect(describeFailure(refusal, labelsOf(lt, "lt"))).toBe(
"Dalyvio duomenys ištrinti, todėl šios veiklos pakeisti negalima.",
)
expect(describeFailure({ problem: { code: "service_migrating" } }, labelsOf(lt, "lt"))).toBe(
"Paslauga atnaujinama. Bandykite po kelių minučių.",
)
})
})
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
import { businessModulesOn } from "../../index.js"
const id = "01900000-0000-7000-8000-000000000001"
const slugs = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
const task = (t: IsolationTenant) => ({ ...slugs(t), taskId: `act_${id}` })
const mutate = (t: IsolationTenant) => ({ ...task(t), expectedVersion: "1", requestId: id })
export const activitiesIsolationFixtures: Record<string, IsolationFixture> = {
"activities.task.list": { input: slugs },
"activities.task.get": { input: task },
"activities.timeline.list": { input: slugs },
"activities.task.create": { input: (t) => ({ ...slugs(t), subject: "Synthetic", requestId: id }) },
"activities.task.update": { input: (t) => ({ ...mutate(t), subject: "Synthetic", body: "Fixture" }) },
"activities.task.complete": { input: mutate },
"activities.task.reopen": { input: mutate },
"activities.comment.list": { input: task },
"activities.comment.add": { input: (t) => ({ ...mutate(t), body: "Synthetic" }) },
}
export const isolation: {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
} = { seed: async () => ({}), fixtures: businessModulesOn.includes("activities") ? activitiesIsolationFixtures : {} }
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Generated SDK + synthetic HTTP proves wire contracts, never live service acceptance.
import { actorRef, projectRef, requestBusinessBinding } from "@conusai-kit/business"
import { businessServiceHooks } from "@conusai-kit/business/server"
import { Container, type ServiceAdmissionContext } from "@conusai-kit/core"
import { afterEach, describe, expect, it, vi } from "vitest"
import { sdkActivitiesService } from "../adapters/service.js"
import { activityId, commentReceipt, receipt } from "../contract/index.js"
import { activitiesModule } from "../module.js"
const bindingId = "01900000-0000-7000-8000-000000000001"
const projectId = "01900000-0000-7000-8000-000000000002"
const actorId = "01900000-0000-7000-8000-000000000003"
const taskId = "act_01900000-0000-7000-8000-000000000004"
const commentId = "cmt_01900000-0000-7000-8000-000000000005"
const revision = "9007199254740993"
const target = projectRef(bindingId, projectId)
const member = actorRef(bindingId, actorId)
const task = {
id: taskId,
kind: "task",
status: "open",
revision: "RAW_REVISION",
subject: "Synthetic task",
body: "Details",
targets: [target],
participants: [member],
assignee: member,
due_at: "2026-12-01T00:00:00Z",
ext: { fixture: "preserve" },
pinned: true,
created_at: "2026-11-03T00:00:00Z",
updated_at: "2026-11-03T00:00:00Z",
erased: false,
deleted: false,
}
function json(value: unknown) {
return new Response(JSON.stringify(value).replaceAll('"RAW_REVISION"', revision), {
headers: { "content-type": "application/json" },
})
}
async function fixture(reply: (request: Request) => Promise<Response> | Response) {
const requests: Request[] = []
vi.stubGlobal("fetch", async (input: Request | string | URL) => {
const request = new Request(input)
requests.push(request.clone())
return reply(request)
})
const container = new Container()
const hooks = businessServiceHooks({
env: { apiUrl: "https://platform.example.test/api", token: "synthetic-token", orgSlug: "fixture" },
platformOrganizationId: 7,
platformProjectId: 9,
platformEnvironmentId: 11,
profile: "postgres",
controlPlane: {
lookup: async (appTenantId) => ({
partition_major: 1,
binding_id: bindingId,
organization_id: 7,
project_id: 9,
environment_id: 11,
environment_uuid: projectId,
app_tenant_id: appTenantId,
}),
observe: async () => {
throw new Error("This fixture does not certify readiness")
},
},
})
const context: ServiceAdmissionContext = {
container,
module: "activities",
procedure: "activities.task.list",
kind: "read",
scope: { level: "org", orgId: bindingId, actor: { userId: actorId } },
}
await hooks.prepareServices?.(container, context, activitiesModule.serviceRequirements ?? [])
return { service: sdkActivitiesService(requestBusinessBinding(container)), requests }
}
afterEach(() => vi.unstubAllGlobals())
describe("Activities generated wire adapter (synthetic HTTP)", () => {
it("accepts real resource prefixes and preserves int64 revision digits", async () => {
const { service, requests } = await fixture(() => json(task))
const result = await service.get(taskId)
expect(result.revision).toBe(revision)
expect(activityId.parse(result.taskId)).toBe(taskId)
expect(() => activityId.parse(taskId.slice(4))).toThrow()
expect(requests[0]?.headers.get("Conusai-App-Binding")).toBe(bindingId)
expect(requests[0]?.headers.get("Conusai-App-Actor")).toBe(actorId)
expect(requests[0]?.redirect).toBe("error")
})
it("forwards a service cursor unchanged in both directions", async () => {
const cursor = "eyJvcmRlciI6WzEsInN5bnRoZXRpYyJdfQ"
const { service, requests } = await fixture(() => json({ data: [task], next_after: cursor }))
expect((await service.list({ target, after: cursor })).after).toBe(cursor)
expect(new URL(requests[0]?.url ?? "").searchParams.get("after")).toBe(cursor)
expect(new URL(requests[0]?.url ?? "").searchParams.get("target")).toBe(target)
})
it("preserves untouched fields on edit and sends revision digits exactly", async () => {
let body = ""
const { service } = await fixture(async (request) => {
if (request.method === "GET") return json(task)
body = await request.text()
return json({ id: taskId, revision: "RAW_REVISION" })
})
const current = await service.get(taskId)
const result = await service.update({
task: current,
target,
subject: "Changed",
body: "Edited",
expectedVersion: revision,
key: "synthetic-update",
})
expect(receipt.parse(result).revision).toBe(revision)
expect(body).toContain(`"revision":${revision}`)
expect(JSON.parse(body).fields).toMatchObject({
assignee: member,
due_at: task.due_at,
ext: task.ext,
participants: [member],
targets: [target],
pinned: true,
subject: "Changed",
body: "Edited",
})
})
it("returns the actual comment receipt instead of inventing a comment body", async () => {
const { service } = await fixture(() => json({ id: commentId, revision: "RAW_REVISION" }))
const result = await service.comment({
taskId,
body: "Synthetic comment",
expectedVersion: revision,
key: "synthetic-comment",
})
expect(commentReceipt.parse(result)).toEqual({ commentId, revision })
expect(result).not.toHaveProperty("body")
})
it("refuses a changed project target before the adapter writes", async () => {
const other = projectRef(bindingId, "01900000-0000-7000-8000-000000000099")
const { service, requests } = await fixture(() => json({ ...task, targets: [other] }))
await expect(
service.update({
task: {
taskId,
subject: "Old",
body: "",
revision,
status: "open",
targets: [target],
createdAt: task.created_at,
},
target,
subject: "Changed",
body: "Edited",
expectedVersion: revision,
key: "synthetic-foreign",
}),
).rejects.toMatchObject({ code: "not_found" })
expect(requests.every((request) => request.method === "GET")).toBe(true)
})
})
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { implement } from "@conusai-kit/core"
import * as contract from "../contract/index.js"
import * as tasks from "../core/tasks.js"
import { activitiesServiceOf } from "./service.js"
export const ACTIVITIES_PROCEDURES = [
implement(contract.taskList, ({ ctx, input }) =>
tasks.listTasks(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input),
),
implement(contract.taskGet, ({ ctx, input }) =>
tasks.ownedTask(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input.taskId),
),
implement(contract.taskCreate, ({ ctx, input }) =>
tasks.createTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
),
implement(contract.taskUpdate, ({ ctx, input }) =>
tasks.updateTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
),
implement(contract.taskComplete, ({ ctx, input }) =>
tasks.changeTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input, "done"),
),
implement(contract.taskReopen, ({ ctx, input }) =>
tasks.changeTask(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input, "open"),
),
implement(contract.commentList, ({ ctx, input }) =>
tasks.listComments(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input),
),
implement(contract.commentAdd, ({ ctx, input }) =>
tasks.addComment(activitiesServiceOf(ctx.container, "effect"), ctx.scope, input),
),
implement(contract.timeline, ({ ctx, input }) =>
tasks.projectTimeline(activitiesServiceOf(ctx.container, ctx.kind), ctx.scope, input.after),
),
]
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The generated client receives only the current server-prepared binding.
import {
businessAppError,
type RequestBusinessBinding,
requestBusinessBinding,
toBusinessError,
} from "@conusai-kit/business"
import { type Container, notFound, type ProcedureKind } from "@conusai-kit/core"
import {
type Activity,
activitiesAddComment,
activitiesCompleteTask,
activitiesCreateTask,
activitiesFindActivities,
activitiesGetActivity,
activitiesListComments,
activitiesReopenTask,
activitiesTimelineFor,
activitiesUpdateActivity,
type Comment,
type Fields,
type Receipt,
} from "@conusai-sdk/activities"
import { createClient } from "@conusai-sdk/activities/client"
import type { CommentView, TaskReceipt } from "../contract/index.js"
import {
ACTIVITIES_SERVICE,
type ActivitiesReads,
type ActivitiesService,
readsOf,
type TaskRecord,
} from "../core/ports.js"
async function answer<T>(
operation: Promise<{ data?: T; error?: unknown; response?: Response }>,
): Promise<Exclude<T, undefined>> {
let result: Awaited<typeof operation>
try {
result = await operation
} catch (error) {
throw businessAppError(toBusinessError(error, undefined))
}
if (result.error !== undefined || result.data === undefined)
throw businessAppError(toBusinessError(result.error, result.response))
return result.data as Exclude<T, undefined>
}
const view = (value: Activity): TaskRecord => {
if (value.kind !== "task" || value.status === undefined || value.status === null || value.erased || value.deleted)
throw notFound()
return {
taskId: value.id,
subject: value.subject,
body: value.body ?? "",
revision: value.revision.toString(),
status: value.status,
targets: value.targets,
createdAt: value.created_at,
}
}
const commentView = (value: Comment): CommentView => ({
commentId: value.id,
body: value.erased ? "" : value.body,
author: value.author ?? null,
erased: value.erased,
})
const receiptView = (value: Receipt): TaskReceipt => ({ taskId: value.id, revision: value.revision.toString() })
const fieldsOf = (row: Activity): Fields => ({
subject: row.subject,
targets: row.targets,
...(row.body === undefined ? {} : { body: row.body }),
...(row.assignee === undefined ? {} : { assignee: row.assignee }),
...(row.due_at === undefined ? {} : { due_at: row.due_at }),
...(row.ext === undefined ? {} : { ext: row.ext }),
...(row.participants === undefined ? {} : { participants: row.participants }),
...(row.pinned === undefined ? {} : { pinned: row.pinned }),
})
/** No org-wide client constructor or deployment singleton is supported. */
export function sdkActivitiesService(binding: RequestBusinessBinding): ActivitiesService {
const client = binding.client(createClient)
const org = { org_slug: binding.orgSlug }
const get = (id: string) => answer(activitiesGetActivity({ client, path: { ...org, activity_id: id } }))
return {
bindingId: binding.bindingId,
async list(input) {
const result = await answer(
activitiesFindActivities({
client,
path: org,
query: {
target: input.target,
...(input.status ? { status: input.status } : {}),
...(input.after ? { after: input.after } : {}),
limit: 50n,
},
}),
)
return {
rows: result.data.filter((row) => row.kind === "task" && !row.erased && !row.deleted).map(view),
...(result.next_after ? { after: result.next_after } : {}),
}
},
async get(id) {
return view(await get(id))
},
async timeline(input) {
const result = await answer(
activitiesTimelineFor({
client,
path: org,
query: { target: input.target, ...(input.after ? { after: input.after } : {}), limit: 50n },
}),
)
return {
rows: result.data.filter((row) => row.kind === "task" && !row.erased && !row.deleted).map(view),
...(result.next_after ? { after: result.next_after } : {}),
}
},
async comments(input) {
const result = await answer(
activitiesListComments({
client,
path: { ...org, activity_id: input.taskId },
query: {
...(input.after ? { after: input.after } : {}),
limit: 50n,
},
}),
)
return { rows: result.data.map(commentView), ...(result.next_after ? { after: result.next_after } : {}) }
},
async create(input) {
return receiptView(
await answer(
activitiesCreateTask({
client,
path: org,
body: {
kind: "task",
idempotency_key: input.key,
fields: {
subject: input.subject,
body: input.body,
targets: [input.target],
participants: [input.participant],
},
},
}),
),
)
},
async update(input) {
const current = await get(input.task.taskId)
if (!current.targets.includes(input.target)) throw notFound()
return receiptView(
await answer(
activitiesUpdateActivity({
client,
path: { ...org, activity_id: current.id },
body: {
fields: { ...fieldsOf(current), subject: input.subject, body: input.body },
revision: BigInt(input.expectedVersion),
idempotency_key: input.key,
},
}),
),
)
},
async change(input) {
const operation = input.state === "done" ? activitiesCompleteTask : activitiesReopenTask
return receiptView(
await answer(
operation({
client,
path: { ...org, activity_id: input.taskId },
body: { revision: BigInt(input.expectedVersion), idempotency_key: input.key },
}),
),
)
},
async comment(input) {
const result = await answer(
activitiesAddComment({
client,
path: { ...org, activity_id: input.taskId },
body: { body: input.body, revision: BigInt(input.expectedVersion), idempotency_key: input.key },
}),
)
return { commentId: result.id, revision: result.revision.toString() }
},
}
}
export function activitiesServiceOf(container: Container, kind: "effect"): ActivitiesService
export function activitiesServiceOf(container: Container, kind: ProcedureKind): ActivitiesReads
export function activitiesServiceOf(container: Container, kind: ProcedureKind): ActivitiesReads {
const service = container.has(ACTIVITIES_SERVICE)
? container.resolve(ACTIVITIES_SERVICE)
: sdkActivitiesService(requestBusinessBinding(container))
return kind === "effect" ? service : readsOf(service)
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Project-scoped task and comment contracts. Authority comes from resolved scope.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
const id = z.string().uuid()
const resourceSuffix = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
export const activityId = z.string().regex(new RegExp(`^act_${resourceSuffix}$`))
const commentId = z.string().regex(new RegExp(`^cmt_${resourceSuffix}$`))
// Cursors belong to the service and are forwarded unchanged; they are not resource IDs.
const cursor = z.string().min(1).max(4096)
const revision = z.string().regex(/^[1-9][0-9]{0,18}$/)
export const taskView = z.object({
taskId: activityId,
subject: z.string(),
body: z.string(),
revision,
status: z.enum(["open", "done", "cancelled"]),
createdAt: z.string(),
})
export type TaskView = z.output<typeof taskView>
export const commentView = z.object({
commentId,
body: z.string(),
author: z.string().nullable(),
erased: z.boolean(),
})
export type CommentView = z.output<typeof commentView>
export const commentReceipt = z.object({ commentId, revision })
export type CommentReceipt = z.output<typeof commentReceipt>
export const receipt = z.object({ taskId: activityId, revision })
export type TaskReceipt = z.output<typeof receipt>
const write = { ...inProject, taskId: activityId, expectedVersion: revision, requestId: id }
export const taskList = procedure("activities.task.list")
.kind("read")
.scope("project")
.permission("activities.task.read")
.input(z.object({ ...inProject, status: z.enum(["open", "done", "cancelled"]).optional(), after: cursor.optional() }))
.output(z.object({ rows: z.array(taskView), after: cursor.optional() }))
.contract()
export const taskGet = procedure("activities.task.get")
.kind("read")
.scope("project")
.permission("activities.task.read")
.input(z.object({ ...inProject, taskId: activityId }))
.output(taskView)
.contract()
export const taskCreate = procedure("activities.task.create")
.kind("effect")
.scope("project")
.permission("activities.task.write")
.input(
z.object({
...inProject,
subject: z.string().trim().min(1).max(200),
body: z.string().max(10000).optional(),
requestId: id,
}),
)
.output(receipt)
.contract()
export const taskUpdate = procedure("activities.task.update")
.kind("effect")
.scope("project")
.permission("activities.task.write")
.input(z.object({ ...write, subject: z.string().trim().min(1).max(200), body: z.string().max(10000) }))
.output(receipt)
.contract()
export const taskComplete = procedure("activities.task.complete")
.kind("effect")
.scope("project")
.permission("activities.task.write")
.input(z.object(write))
.output(receipt)
.contract()
export const taskReopen = procedure("activities.task.reopen")
.kind("effect")
.scope("project")
.permission("activities.task.write")
.input(z.object(write))
.output(receipt)
.contract()
export const commentList = procedure("activities.comment.list")
.kind("read")
.scope("project")
.permission("activities.task.read")
.input(z.object({ ...inProject, taskId: activityId, after: cursor.optional() }))
.output(z.object({ rows: z.array(commentView), after: cursor.optional() }))
.contract()
export const commentAdd = procedure("activities.comment.add")
.kind("effect")
.scope("project")
.permission("activities.comment.write")
.input(z.object({ ...write, body: z.string().trim().min(1).max(10000) }))
.output(commentReceipt)
.contract()
export const timeline = procedure("activities.timeline.list")
.kind("read")
.scope("project")
.permission("activities.task.read")
.input(z.object({ ...inProject, after: cursor.optional() }))
.output(z.object({ rows: z.array(taskView), after: cursor.optional() }))
.contract()
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { token } from "@conusai-kit/core"
import type { CommentReceipt, CommentView, TaskReceipt, TaskView } from "../contract/index.js"
export interface Page<T> {
rows: T[]
after?: string
}
export interface TaskRecord extends TaskView {
targets: string[]
}
export interface ActivitiesReads {
readonly bindingId: string
list(input: { target: string; status?: "open" | "done" | "cancelled"; after?: string }): Promise<Page<TaskView>>
get(taskId: string): Promise<TaskRecord>
comments(input: { taskId: string; after?: string }): Promise<Page<CommentView>>
timeline(input: { target: string; after?: string }): Promise<Page<TaskView>>
}
export interface ActivitiesService extends ActivitiesReads {
create(input: {
subject: string
body: string
target: string
participant: string
key: string
}): Promise<TaskReceipt>
update(input: {
task: TaskRecord
target: string
subject: string
body: string
expectedVersion: string
key: string
}): Promise<TaskReceipt>
change(input: { taskId: string; state: "done" | "open"; expectedVersion: string; key: string }): Promise<TaskReceipt>
comment(input: { taskId: string; body: string; expectedVersion: string; key: string }): Promise<CommentReceipt>
}
export const readsOf = (service: ActivitiesReads): ActivitiesReads => ({
bindingId: service.bindingId,
list: (input) => service.list(input),
get: (id) => service.get(id),
comments: (input) => service.comments(input),
timeline: (input) => service.timeline(input),
})
export const ACTIVITIES_SERVICE = token<ActivitiesService>("activities.service", {
views: { read: readsOf, write: readsOf },
})
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { actorRef, projectRef } from "@conusai-kit/business"
import { conflict, notFound, type ProjectScope } from "@conusai-kit/core"
import type { ActivitiesReads, ActivitiesService } from "./ports.js"
const keyOf = (scope: ProjectScope, operation: string, requestId: string) =>
`activities:${scope.projectId}:${operation}:${requestId}`
const targetOf = (service: ActivitiesReads, scope: ProjectScope) => projectRef(service.bindingId, scope.projectId)
/** An app project is a narrower boundary than its organization partition. */
export async function ownedTask(service: ActivitiesReads, scope: ProjectScope, taskId: string) {
const task = await service.get(taskId)
if (!task.targets.includes(targetOf(service, scope))) throw notFound()
return task
}
function currentOrRetry(actual: string, requested: string) {
// A future revision could become valid after a concurrent target move. A
// prior revision must reach the service's idempotency check on a real retry.
if (BigInt(requested) > BigInt(actual)) throw conflict("The task changed; reload before editing.")
}
export const listTasks = (
service: ActivitiesReads,
scope: ProjectScope,
input: { status?: "open" | "done" | "cancelled" | undefined; after?: string | undefined },
) =>
service.list({
target: targetOf(service, scope),
status: input.status ?? "open",
...(input.after ? { after: input.after } : {}),
})
export const projectTimeline = (service: ActivitiesReads, scope: ProjectScope, after?: string) =>
service.timeline({ target: targetOf(service, scope), ...(after ? { after } : {}) })
export const createTask = (
service: ActivitiesService,
scope: ProjectScope,
input: { subject: string; body?: string | undefined; requestId: string },
) =>
service.create({
subject: input.subject.trim(),
body: input.body ?? "",
target: targetOf(service, scope),
participant: actorRef(service.bindingId, scope.actor.userId),
key: keyOf(scope, "create", input.requestId),
})
export async function updateTask(
service: ActivitiesService,
scope: ProjectScope,
input: { taskId: string; subject: string; body: string; expectedVersion: string; requestId: string },
) {
const task = await ownedTask(service, scope, input.taskId)
currentOrRetry(task.revision, input.expectedVersion)
return service.update({
task,
target: targetOf(service, scope),
subject: input.subject.trim(),
body: input.body,
expectedVersion: input.expectedVersion,
key: keyOf(scope, "update", input.requestId),
})
}
export async function changeTask(
service: ActivitiesService,
scope: ProjectScope,
input: { taskId: string; expectedVersion: string; requestId: string },
state: "done" | "open",
) {
const task = await ownedTask(service, scope, input.taskId)
currentOrRetry(task.revision, input.expectedVersion)
return service.change({
taskId: task.taskId,
state,
expectedVersion: input.expectedVersion,
key: keyOf(scope, state, input.requestId),
})
}
export async function listComments(
service: ActivitiesReads,
scope: ProjectScope,
input: { taskId: string; after?: string | undefined },
) {
await ownedTask(service, scope, input.taskId)
const comments = await service.comments({ taskId: input.taskId, ...(input.after ? { after: input.after } : {}) })
await ownedTask(service, scope, input.taskId)
return comments
}
export async function addComment(
service: ActivitiesService,
scope: ProjectScope,
input: { taskId: string; body: string; expectedVersion: string; requestId: string },
) {
const task = await ownedTask(service, scope, input.taskId)
currentOrRetry(task.revision, input.expectedVersion)
return service.comment({
taskId: input.taskId,
body: input.body.trim(),
expectedVersion: input.expectedVersion,
key: keyOf(scope, "comment", input.requestId),
})
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export const en = {
conflict: "The task changed. Reload before editing.",
invalid: "Check the entered values and try again.",
title: "Activities",
intro: "Tasks and discussion for this project.",
open: "Open tasks",
timeline: "Task timeline",
subject: "Subject",
body: "Details",
status: "Status",
create: "Create task",
update: "Save changes",
complete: "Complete task",
reopen: "Reopen task",
comment: "Comment",
addComment: "Add comment",
comments: "Discussion",
busy: "Saving…",
empty: "No tasks yet.",
emptyComments: "No comments yet.",
erased: "Comment removed",
back: "Back to activities",
stateOpen: "Open",
stateDone: "Done",
stateCancelled: "Cancelled",
unavailable: "Activities is temporarily unavailable. Try again later.",
}
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export { en } from "./en.js"
export { lt } from "./lt.js"
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export const lt = {
conflict: "Užduotis pasikeitė. Prieš redaguodami atnaujinkite puslapį.",
invalid: "Patikrinkite įvestas reikšmes ir bandykite dar kartą.",
title: "Veiklos",
intro: "Šio projekto užduotys ir aptarimai.",
open: "Atviros užduotys",
timeline: "Užduočių eiga",
subject: "Tema",
body: "Aprašymas",
status: "Būsena",
create: "Sukurti užduotį",
update: "Išsaugoti pakeitimus",
complete: "Užbaigti užduotį",
reopen: "Atverti užduotį",
comment: "Komentaras",
addComment: "Pridėti komentarą",
comments: "Aptarimas",
busy: "Išsaugoma…",
empty: "Užduočių dar nėra.",
emptyComments: "Komentarų dar nėra.",
erased: "Komentaras pašalintas",
back: "Grįžti į veiklas",
stateOpen: "Atvira",
stateDone: "Baigta",
stateCancelled: "Atšaukta",
unavailable: "Veiklų paslauga laikinai nepasiekiama. Bandykite vėliau.",
}
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export { sdkActivitiesService } from "./adapters/service.js"
export * from "./contract/index.js"
export { ACTIVITIES_SERVICE, type ActivitiesReads, type ActivitiesService } from "./core/ports.js"
export { activitiesModule } from "./module.js"
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { defineModule } from "@conusai-kit/core"
import { ACTIVITIES_PROCEDURES } from "./adapters/procedures.js"
import { en, lt } from "./i18n/index.js"
const read = ["activities.activities:read"]
const write = ["activities.activities:read", "activities.activities:write"]
export const activitiesModule = defineModule({
id: "activities",
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
profiles: ["postgres", "mariadb", "mongodb"],
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy"],
permissions: {
"activities.task.read": "viewer",
"activities.task.write": "editor",
"activities.comment.write": "editor",
},
procedures: ACTIVITIES_PROCEDURES,
serviceRequirements: [
{
transport: "business",
service: "activities",
apiRange: "^1.1.0",
profiles: ["postgres", "mariadb", "mongodb"],
operations: {
"activities.task.list": read,
"activities.task.get": read,
"activities.timeline.list": read,
"activities.comment.list": read,
"activities.task.create": write,
"activities.task.update": write,
"activities.task.complete": write,
"activities.task.reopen": write,
"activities.comment.add": ["activities.activities:read", "activities.comments:write"],
},
configuration: [],
dependencies: [],
dataClasses: ["personal", "operational"],
partitionRequired: true,
remediation: "/docs/business-services.md",
},
],
ui: {
nav: [{ scope: "project", label: "activities.title", href: "activities", permission: "activities.task.read" }],
},
i18n: { en, lt },
})
+193
View File
@@ -0,0 +1,193 @@
"use client"
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import {
FormLayout,
type FormValues,
PageSection,
ResourceList,
TextField,
usePageNavigation,
useResourceList,
} from "@conusai-kit/ui/patterns"
import { useState } from "react"
import type { CommentReceipt, CommentView, TaskReceipt, TaskView } from "../contract/index.js"
import { describeFailure, type Labels } from "./labels.js"
type Page<T> = { rows: T[]; after?: string | undefined }
export function Tasks({
slugs,
seed,
href,
labels,
timeline = false,
}: {
slugs: ProjectSlugs
seed: Page<TaskView>
href: string
labels: Labels
timeline?: boolean
}) {
const client = useDataClient()
const { list } = useResourceList<TaskView>({
seed,
rowId: (task) => task.taskId,
read: async ({ after }) =>
(
await client.call<Page<TaskView>>(timeline ? "activities.timeline.list" : "activities.task.list", {
...slugs,
...(after ? { after } : {}),
})
).value,
})
return (
<ResourceList
{...list}
label={timeline ? labels.timeline : labels.open}
columns={[
{ id: "subject", header: labels.subject, wrap: true },
{
id: "status",
header: labels.status,
cell: (task) =>
task.status === "open"
? labels.stateOpen
: task.status === "done"
? labels.stateDone
: labels.stateCancelled,
},
]}
rowHref={(task) => `${href}/${task.taskId}`}
rowLabel={(task) => task.subject}
empty={{ title: labels.empty }}
/>
)
}
export function TaskForm({
slugs,
task,
href,
labels,
}: {
slugs: ProjectSlugs
task?: TaskView
href: string
labels: Labels
}) {
const navigation = usePageNavigation()
const [requestId] = useState(() => crypto.randomUUID())
const mutation = useWrite<Record<string, unknown>, TaskReceipt>(
task ? "activities.task.update" : "activities.task.create",
)
const submit = async ({ subject, body = "" }: FormValues) => {
const result = await mutation.mutate({
...slugs,
subject,
body,
requestId,
...(task ? { taskId: task.taskId, expectedVersion: task.revision } : {}),
})
navigation.navigate(`${href}/${result.taskId}`)
}
return (
<PageSection title={task ? labels.update : labels.create} variant="card">
<FormLayout
key={requestId}
describeError={(error) => describeFailure(error, labels)}
submitLabel={task ? labels.update : labels.create}
busyLabel={labels.busy}
onSubmit={submit}
>
<TextField name="subject" label={labels.subject} required maxLength={200} defaultValue={task?.subject} />
<TextField name="body" label={labels.body} maxLength={10000} defaultValue={task?.body} />
</FormLayout>
</PageSection>
)
}
export function TaskState({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
const navigation = usePageNavigation()
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const change = useWrite<Record<string, unknown>, TaskReceipt>(
task.status === "done" ? "activities.task.reopen" : "activities.task.complete",
)
if (task.status === "cancelled") return null
const submit = async () => {
await change.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId })
await navigation.refresh()
setRequestId(crypto.randomUUID())
}
return (
<FormLayout
key={requestId}
describeError={(error) => describeFailure(error, labels)}
submitLabel={task.status === "done" ? labels.reopen : labels.complete}
busyLabel={labels.busy}
onSubmit={submit}
/>
)
}
export function Comments({
slugs,
task,
seed,
labels,
}: {
slugs: ProjectSlugs
task: TaskView
seed: Page<CommentView>
labels: Labels
}) {
const client = useDataClient()
const { list } = useResourceList<CommentView>({
seed,
rowId: (comment) => comment.commentId,
read: async ({ after }) =>
(
await client.call<Page<CommentView>>("activities.comment.list", {
...slugs,
taskId: task.taskId,
...(after ? { after } : {}),
})
).value,
})
return (
<ResourceList
{...list}
label={labels.comments}
columns={[
{
id: "body",
header: labels.comment,
wrap: true,
cell: (comment) => (comment.erased ? labels.erased : comment.body),
},
]}
rowLabel={(comment) => (comment.erased ? labels.erased : comment.body)}
empty={{ title: labels.emptyComments }}
/>
)
}
export function CommentForm({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
const navigation = usePageNavigation()
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const add = useWrite<Record<string, unknown>, CommentReceipt>("activities.comment.add")
const submit = async ({ body = "" }: FormValues) => {
await add.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId, body })
await navigation.refresh()
setRequestId(crypto.randomUUID())
}
return (
<PageSection title={labels.addComment}>
<FormLayout
key={requestId}
describeError={(error) => describeFailure(error, labels)}
submitLabel={labels.addComment}
busyLabel={labels.busy}
onSubmit={submit}
>
<TextField name="body" label={labels.comment} required maxLength={10000} />
</FormLayout>
</PageSection>
)
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { serviceNotice } from "@conusai-kit/ui/patterns"
import type { en } from "../i18n/en.js"
export type Messages = { [K in keyof typeof en]: string }
/**
* What the loader hands the views: the module's messages and the kit's refusal sentences by problem code
* (`businessRefusalNotices` of `@conusai-kit/business/refusals`, with this module's wording for a conflict and
* an invalid entry).
*/
export type Labels = Messages & { readonly refusals: Readonly<Record<string, string>> }
/** A remote detail never becomes untranslated or unbounded form text. */
export function describeFailure(error: unknown, labels: Labels): string {
return serviceNotice(error, labels.refusals)?.text ?? labels.unavailable
}
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
DetailLayout,
Page,
PageHeader,
PageSection,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import * as contracts from "../contract/index.js"
import { en } from "../i18n/en.js"
import { CommentForm, Comments, TaskForm, TaskState, Tasks } from "./controls.js"
import type { Labels, Messages } from "./labels.js"
type PageOf<T> = { rows: T[]; after?: string | undefined }
type Data = { labels: Labels } & (
| { kind: "list"; tasks: PageOf<contracts.TaskView>; timeline: PageOf<contracts.TaskView> }
| { kind: "task"; task: contracts.TaskView; comments: PageOf<contracts.CommentView> }
| { kind: "state"; notice: ServiceNotice }
)
export const activitiesPage: ProjectPage<TransportCaller, I18n, Container, Data> = projectPage({
async load({ slugs, segments, caller, i18n }) {
const messages = Object.fromEntries(
Object.keys(en).map((key) => [key, i18n.message(`activities.${key}`)]),
) as Messages
const labels: Labels = {
...messages,
refusals: businessRefusalNotices(i18n.locale, {
conflict: messages.conflict,
revision_conflict: messages.conflict,
invalid: messages.invalid,
}),
}
const [taskId, ...rest] = segments
if (rest.length || (taskId !== undefined && !contracts.activityId.safeParse(taskId).success)) throw notFound()
try {
if (taskId === undefined) {
const [tasks, timeline] = await Promise.all([
caller.call(contracts.taskList, slugs),
caller.call(contracts.timeline, slugs),
])
return { kind: "list", labels, tasks, timeline }
}
const [task, comments] = await Promise.all([
caller.call(contracts.taskGet, { ...slugs, taskId }),
caller.call(contracts.commentList, { ...slugs, taskId }),
])
return { kind: "task", labels, task, comments }
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", labels, notice }
}
},
View({ slugs, data }) {
const labels = data.labels
const href = `/${slugs.org}/${slugs.workspace}/${slugs.project}/activities`
if (data.kind === "state") return <ServiceStatePage title={labels.title} notice={data.notice} />
if (data.kind === "list")
return (
<Page>
<PageHeader title={labels.title} description={labels.intro} />
<DetailLayout
aside={
<Can permission="activities.task.write">
<TaskForm slugs={slugs} href={href} labels={labels} />
</Can>
}
>
<PageSection title={labels.open}>
<Tasks slugs={slugs} seed={data.tasks} href={href} labels={labels} />
</PageSection>
<PageSection title={labels.timeline}>
<Tasks slugs={slugs} seed={data.timeline} href={href} labels={labels} timeline />
</PageSection>
</DetailLayout>
</Page>
)
return (
<Page>
<PageHeader title={data.task.subject} description={data.task.body} />
<a href={href}>{labels.back}</a>
<Can permission="activities.task.write">
<TaskState slugs={slugs} task={data.task} labels={labels} />
<TaskForm slugs={slugs} task={data.task} href={href} labels={labels} />
</Can>
<PageSection title={labels.comments}>
<Comments slugs={slugs} task={data.task} seed={data.comments} labels={labels} />
</PageSection>
<Can permission="activities.comment.write">
<CommentForm slugs={slugs} task={data.task} labels={labels} />
</Can>
</Page>
)
},
})
+146
View File
@@ -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).
+173
View File
@@ -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
}
+103
View File
@@ -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()
}
}
+23
View File
@@ -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)
})
})
+51
View File
@@ -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 }) },
},
}
+215
View File
@@ -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("'", "&#x27;"))
})
})
+59
View File
@@ -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`)
}
})
})
+156
View File
@@ -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"]),
}))
+35
View File
@@ -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" },
})
},
})
}
+135
View File
@@ -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",
},
])
})
})
+313
View File
@@ -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()
})
+201
View File
@@ -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,
})
})
+104
View File
@@ -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("&quot;total&quot;: 4200")
expect(html).toContain("&quot;currency&quot;: &quot;EUR&quot;")
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll(String.fromCharCode(34), "&quot;")
.replaceAll(String.fromCharCode(39), "&#x27;"),
)
expect(uncertain).not.toContain(`>${t("runs.cancel")}<`)
})
})
+45
View File
@@ -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")
})
+353
View File
@@ -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)]
+20
View File
@@ -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]
+15
View File
@@ -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"
+23
View File
@@ -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]
+31
View File
@@ -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"
+215
View File
@@ -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,
]
+39
View File
@@ -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 }
}
+301
View File
@@ -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 }
+78
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export { en } from "./en.js"
export { lt } from "./lt.js"
+84
View File
@@ -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.",
},
}
+20
View File
@@ -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"
+66
View File
@@ -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;
+105
View File
@@ -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);
+23
View File
@@ -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 },
})
+40
View File
@@ -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 },
}
+28
View File
@@ -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() })
+26
View File
@@ -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,
}),
])
}
+112
View File
@@ -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>
)
}
+5
View File
@@ -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"
+77
View File
@@ -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
}
}
+113
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+187
View File
@@ -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>
)
},
})
+87
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
// source, the archive module, and the Archive port bound to the real generated client over the
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
// app turned the module on.
import { businessClient, type Client } from "@conusai-kit/business"
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, OBJECT_STORE, SYSTEM_DATA } from "@conusai-kit/data"
import { files, filesModule } from "@conusai-kit/files"
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
import { type FakeBusinessServices, fakeBusinessServices, fakeObjectStore } from "@conusai-kit/testing"
import { createClient } from "@conusai-sdk/archive/client"
import { sdkArchiveService } from "../adapters/service.js"
import { ARCHIVE_SERVICE } from "../core/ports.js"
import { archiveModule } from "../module.js"
export interface Tenant {
readonly owner: Actor
readonly call: TransportCaller
readonly orgSlug: string
readonly orgId: string
readonly workspaceSlug: string
readonly workspaceId: string
readonly projectSlug: string
readonly projectId: string
readonly slugs: { org: string; workspace: string; project: string }
}
export function archiveApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
const fake: FakeBusinessServices = fakeBusinessServices(options)
const client: Client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
client.setConfig({ fetch: fake.fetch as typeof fetch })
// The files module for its table alone: its procedures have their fixtures in test/isolation.test.ts.
const registry = buildRegistry([tenancyModule, { ...filesModule, procedures: [] }, archiveModule])
const entities = entitiesOf(registry, "postgres")
const data = createMemoryDataSource({ entities })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(OBJECT_STORE, fakeObjectStore())
root.value(ARCHIVE_SERVICE, sdkArchiveService({ client, orgSlug: fake.orgSlug }))
const caller = (actor: Actor): TransportCaller => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
}
const signUp = async (email: string): Promise<Actor> => {
const user = await data.global(users).insert({
issuer: "https://id.example.test",
subject: email,
email,
displayName: email.split("@")[0] ?? email,
locale: "en",
})
return { userId: user.id }
}
/** An organization with one workspace and one project, owned by a new user. */
const tenant = async (slug: string): Promise<Tenant> => {
const owner = await signUp(`owner@${slug}.test`)
const call = caller(owner)
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
const workspace = (await call.callByName("tenancy.workspace.create", {
org: slug,
slug: `ws-${slug}`,
name: `Workspace ${slug}`,
visibility: "org",
})) as { workspaceId: string; slug: string }
const project = (await call.callByName("tenancy.project.create", {
org: slug,
workspace: workspace.slug,
slug: `p-${slug}`,
name: `Project ${slug}`,
moduleKey: "archive",
})) as { projectId: string; slug: string }
return {
owner,
call,
orgSlug: org.slug,
orgId: org.orgId,
workspaceSlug: workspace.slug,
workspaceId: workspace.workspaceId,
projectSlug: project.slug,
projectId: project.projectId,
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
}
}
/** A second user who joins `t`'s organization with the org role `role`. */
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
token: string
}
const actor = await signUp(email)
const call = caller(actor)
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
return call
}
/** A file stored in `t`'s project, as the files module leaves an upload it has finished. */
const storedFile = async (t: Tenant, sha256 = "a".repeat(64), size = 1234): Promise<string> => {
const scope = {
level: "project" as const,
actor: t.owner,
orgId: t.orgId,
workspaceId: t.workspaceId,
projectId: t.projectId,
}
const row = await data
.forScope(scope)
.of(files)
.insert({
status: "stored",
storageKey: `files/${t.workspaceId}/${t.projectId}/${crypto.randomUUID()}`,
filename: "record.pdf",
contentType: "application/pdf",
size,
sha256,
uploadedBy: t.owner.userId,
storedAt: "2026-11-02T10:00:00.000Z",
uploadId: null,
declaredSize: null,
partSize: null,
deletingAt: null,
})
return row.id
}
return { fake, client, registry, data, caller, signUp, tenant, member, storedFile }
}
+146
View File
@@ -0,0 +1,146 @@
// The archive use cases over a recording port: what each asks the service, with no client and no network.
import type { ProjectScope } from "@conusai-kit/core"
import { createMemoryDataSource } from "@conusai-kit/data"
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
import { fakeObjectStore } from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
import { addVersion, listDocuments, registerDocument, requestKey, versionFacts } from "../core/archive.js"
import { type ArchiveService, archiveReadsOf } from "../core/ports.js"
const scope: ProjectScope = {
level: "project",
actor: { userId: "01900000-0000-7000-0000-000000000009" },
orgId: "01900000-0000-7000-0000-000000000001",
workspaceId: "01900000-0000-7000-0000-000000000002",
projectId: "01900000-0000-7000-0000-000000000003",
}
const receipt = { documentId: "doc_1", revision: "1" }
function recording() {
const asked: Array<[string, unknown]> = []
const service: ArchiveService = {
listDocuments: async (query) => {
asked.push(["listDocuments", query])
return { rows: [] }
},
getDocument: async () => {
throw new Error("not asked")
},
listVersions: async () => ({ rows: [] }),
listRetentionClasses: async () => ({ rows: [] }),
listDisposalDue: async () => ({ rows: [] }),
registerDocument: async (input) => {
asked.push(["registerDocument", input])
return receipt
},
addVersion: async (input) => {
asked.push(["addVersion", input])
return receipt
},
setLegalHold: async () => receipt,
disposeDocument: async () => receipt,
createRetentionClass: async () => undefined,
}
return { service, asked }
}
async function storedFile() {
const data = createMemoryDataSource({ entities: [files] })
const deps = { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY }
const row = await data
.forScope(scope)
.of(files)
.insert({
status: "stored",
storageKey: "files/w/p/f",
filename: "record.pdf",
contentType: "application/pdf",
size: 1234,
sha256: "a".repeat(64),
uploadedBy: scope.actor.userId,
storedAt: "2026-11-02T10:00:00.000Z",
uploadId: null,
declaredSize: null,
partSize: null,
deletingAt: null,
})
return { deps, fileId: row.id }
}
describe("the archive use cases", () => {
it("list documents with a trimmed search and the module's page size", async () => {
const { service, asked } = recording()
await listDocuments(service, { q: " invoice " })
await listDocuments(service, { q: " " })
expect(asked).toEqual([
["listDocuments", { q: "invoice", limit: 50 }],
["listDocuments", { limit: 50 }],
])
})
it("take a version's facts from the stored file, with an opaque Blob reference", async () => {
const { deps, fileId } = await storedFile()
expect(await versionFacts(deps, scope, fileId)).toEqual({
sha256: "a".repeat(64),
mediaType: "application/pdf",
size: 1234,
blobRef: `conusai://blob/files/${fileId}`,
})
})
it("register a document with the project's request key and a trimmed title, and no kind unless asked", async () => {
const { deps, fileId } = await storedFile()
const { service, asked } = recording()
await registerDocument(service, deps, scope, {
title: " Signed contract ",
kind: " ",
retentionClass: "contracts",
fileId,
requestId: "r1",
})
expect(asked).toEqual([
[
"registerDocument",
{
title: "Signed contract",
retentionClass: "contracts",
version: await versionFacts(deps, scope, fileId),
key: requestKey(scope, "r1"),
},
],
])
})
it("add a version over the revision the page read", async () => {
const { deps, fileId } = await storedFile()
const { service, asked } = recording()
await addVersion(service, deps, scope, { documentId: "doc_1", revision: "4", fileId, requestId: "r2" })
expect(asked[0]?.[1]).toMatchObject({ documentId: "doc_1", revision: "4", key: requestKey(scope, "r2") })
})
it("refuse a file this project does not hold before the service is asked", async () => {
const { deps } = await storedFile()
const { service, asked } = recording()
await expect(
registerDocument(service, deps, scope, {
title: "Someone else's",
retentionClass: "contracts",
fileId: "01900000-0000-7000-0000-0000000000ff",
requestId: "r3",
}),
).rejects.toMatchObject({ problem: { status: 404 } })
expect(asked).toEqual([])
})
it("hand a read handler a view without a single write", () => {
expect(Object.keys(archiveReadsOf(recording().service)).sort()).toEqual([
"getDocument",
"listDisposalDue",
"listDocuments",
"listRetentionClasses",
"listVersions",
])
})
})
+109
View File
@@ -0,0 +1,109 @@
// Every string a page of the module shows is a message in English and in Lithuanian, and no component of
// the module carries a string of its own.
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { buildRegistry } from "@conusai-kit/core"
import { filesModule } from "@conusai-kit/files"
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
import { tenancyModule } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { archiveModule } from "../module.js"
import {
documentActionsLabels,
documentListLabels,
registerFormLabels,
retentionFormLabels,
retentionLabels,
versionFormLabels,
versionsLabels,
} from "../ui/labels.js"
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
const en = catalogues.en?.archive ?? {}
const lt = catalogues.lt?.archive ?? {}
function keysUsed(): string[] {
const asked: string[] = []
const t = (key: string) => {
asked.push(key)
return key
}
documentListLabels(t, "failed", {})
registerFormLabels(t, "failed", {})
versionFormLabels(t, "failed", {})
versionsLabels(t, 2)
documentActionsLabels(t, "failed", {})
retentionLabels(t)
retentionFormLabels(t, "failed", {})
return asked
}
describe("the archive catalogues", () => {
it("hold the same keys in English and in Lithuanian, none empty", () => {
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
})
it("hold every message the labels ask for, and none that nothing asks for", () => {
const asked = keysUsed()
for (const key of asked) {
expect(en[key as keyof typeof en], `en is missing archive.${key}`).toBeTruthy()
expect(lt[key as keyof typeof lt], `lt is missing archive.${key}`).toBeTruthy()
}
// The pages and the navigation ask for these directly.
const direct = new Set([
"title",
"intro",
"nav.documents",
"nav.retention",
"document.back",
"document.retentionClass",
"document.status",
"document.revision",
"document.created",
"document.hold",
"document.disposedAt",
"document.yes",
"document.no",
"statuses.active",
"statuses.disposed",
"retention.title",
"retention.intro",
"retention.bases.created",
"retention.bases.last_version",
"retention.bases.event",
])
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
})
it("answer the navigation label the manifest names", () => {
expect(archiveModule.ui?.nav?.map((item) => item.label)).toEqual(["archive.title"])
})
it("count versions with the Lithuanian one, few and other forms", () => {
const count = (locale: "en" | "lt", n: number) =>
createI18n(locale, { en: { archive: en }, lt: { archive: lt } }).message("archive.versions.count", { count: n })
expect(count("lt", 1)).toBe("1 versija")
expect(count("lt", 2)).toBe("2 versijos")
expect(count("lt", 10)).toBe("10 versijų")
expect(count("lt", 21)).toBe("21 versija")
expect(count("en", 2)).toBe("2 versions")
})
})
describe("the components", () => {
const dir = new URL("../ui/", import.meta.url).pathname
const sources = readdirSync(dir)
.filter((name) => name.endsWith(".tsx"))
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
it("carry no hard-coded user-facing string and no colour literal", () => {
expect(sources.length).toBeGreaterThanOrEqual(5)
for (const { name, text } of sources) {
expect(lintUiStrings(text, name), name).toEqual([])
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
}
})
})
+62
View File
@@ -0,0 +1,62 @@
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
// another's slugs, which the resolver must refuse before the Archive service is reached.
//
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
// its place whether or not the module is on, and checks only the procedures the app registers, so
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
// same fixtures against the module on its own, on or off.
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
import { businessModulesOn } from "../../index.js"
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
export const archiveIsolationFixtures: Record<string, IsolationFixture> = {
"archive.document.list": { input: (t) => inProjectOf(t) },
"archive.document.get": { input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE }) },
"archive.document.register": {
input: (t) => ({
...inProjectOf(t),
title: "Intruder",
retentionClass: "anything",
fileId: SOMEWHERE,
requestId: SOMEWHERE,
}),
},
"archive.version.add": {
input: (t) => ({
...inProjectOf(t),
documentId: SOMEWHERE,
revision: "1",
fileId: SOMEWHERE,
requestId: SOMEWHERE,
}),
},
"archive.document.hold": {
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", legalHold: true, requestId: SOMEWHERE }),
},
"archive.document.dispose": {
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", requestId: SOMEWHERE }),
},
"archive.retention.list": { input: (t) => inProjectOf(t) },
"archive.retention.create": {
input: (t) => ({ ...inProjectOf(t), code: "intruder", keepFor: "P1Y", from: "created", requestId: SOMEWHERE }),
},
"archive.disposal.list": { input: (t) => inProjectOf(t) },
}
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
export const isolation: {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
} = {
seed: async () => ({}),
fixtures: businessModulesOn.includes("archive") ? archiveIsolationFixtures : {},
}
@@ -0,0 +1,367 @@
// The archive procedures end to end: the caller, the tenancy resolver, the module and the real generated
// client against the business-services fake -- what a member sees, what the service gets, and each
// refusal as the typed state a page shows.
import { describeBusinessRefusal } from "@conusai-kit/business/refusals"
import type { AppError } from "@conusai-kit/core"
import { entitiesOf } from "@conusai-kit/data"
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
import { sdkArchiveService } from "../adapters/service.js"
import { requestKey } from "../core/archive.js"
import { archiveApp, type Tenant } from "./app.js"
import { archiveIsolationFixtures } from "./isolation.js"
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
try {
await promise
} catch (error) {
return error as AppError
}
throw new Error("the call was not refused")
}
type App = ReturnType<typeof archiveApp>
const retention = async (t: Tenant, code = "contracts", keepFor = "P10Y") =>
t.call.callByName("archive.retention.create", {
...t.slugs,
code,
keepFor,
from: "created",
requestId: crypto.randomUUID(),
})
const register = async (app: App, t: Tenant, title = "Signed contract", retentionClass = "contracts") =>
(await t.call.callByName("archive.document.register", {
...t.slugs,
title,
retentionClass,
fileId: await app.storedFile(t),
requestId: crypto.randomUUID(),
})) as { documentId: string; revision: string }
describe("the archive procedures", () => {
it("accepts incidental date-like digits in either UUID component without merging project keys", async () => {
const app = archiveApp()
const service = sdkArchiveService({ client: app.client, orgSlug: app.fake.orgSlug })
const incidental = "0195a3c4-e12a-7b3f-8a1b-39001010000f"
const ordinary = "0195a3c4-e12a-7b3f-8a1b-aaaaaaaaaaaa"
const first = { code: "first", keepFor: "P10Y", from: "created" as const }
const key = requestKey({ projectId: incidental }, ordinary)
expect(key).toBe(`app-project:${incidental}:${ordinary}`)
await service.createRetentionClass({ ...first, key })
await service.createRetentionClass({ ...first, key })
await service.createRetentionClass({
code: "second",
keepFor: "P10Y",
from: "created",
key: requestKey({ projectId: ordinary }, incidental),
})
await service.createRetentionClass({
code: "third",
keepFor: "P10Y",
from: "created",
key: requestKey({ projectId: ordinary }, ordinary),
})
expect(app.fake.archive.retentionClasses.size).toBe(3)
})
it("accepts a request UUID with incidental date-like digits and replays its receipt", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
const request = {
...t.slugs,
code: "contracts",
keepFor: "P10Y",
from: "created",
requestId: "0195a3c4-e12a-7b3f-8a1b-39001010000f",
}
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
expect(app.fake.archive.retentionClasses.size).toBe(1)
})
it("register a document once per request, list it, and read it with its versions", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
await retention(t)
const fileId = await app.storedFile(t)
const request = {
...t.slugs,
title: "Signed contract",
kind: "380",
retentionClass: "contracts",
fileId,
requestId: crypto.randomUUID(),
}
const receipt = (await t.call.callByName("archive.document.register", request)) as {
documentId: string
revision: string
}
expect(receipt.revision).toBe("1")
expect(await t.call.callByName("archive.document.register", request)).toEqual(receipt)
expect(app.fake.archive.documents.size).toBe(1)
const listed = await t.call.callByName("archive.document.list", t.slugs)
expect(listed).toMatchObject({ rows: [{ documentId: receipt.documentId, title: "Signed contract", kind: "380" }] })
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId: receipt.documentId })
expect(read).toMatchObject({
document: { status: "active", retentionClass: "contracts", legalHold: false, revision: "1", disposed: false },
versions: [{ sequence: 1, sha256: "a".repeat(64), mediaType: "application/pdf", size: "1234" }],
})
expect(JSON.parse(JSON.stringify(read))).toEqual(read)
const [version] = [...app.fake.archive.versions.values()]
expect(version?.blob_ref).toBe(`conusai://blob/files/${fileId}`)
})
it("append a version over the revision it read, and refuse a stale one", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
await retention(t)
const { documentId } = await register(app, t)
const added = await t.call.callByName("archive.version.add", {
...t.slugs,
documentId,
revision: "1",
fileId: await app.storedFile(t, "b".repeat(64), 99),
requestId: crypto.randomUUID(),
})
expect(added).toEqual({ documentId, revision: "2" })
const stale = await refusal(
t.call.callByName("archive.version.add", {
...t.slugs,
documentId,
revision: "1",
fileId: await app.storedFile(t, "c".repeat(64), 7),
requestId: crypto.randomUUID(),
}),
)
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
const read = (await t.call.callByName("archive.document.get", { ...t.slugs, documentId })) as {
versions: Array<{ sequence: number; size: string }>
}
expect(read.versions.map((version) => [version.sequence, version.size])).toEqual([
[1, "1234"],
[2, "99"],
])
})
it("keep retention: a class, a hold that blocks disposal, the due list, and disposal when it has ended", async () => {
let now = new Date("2026-11-02T10:00:00Z").getTime()
const app = archiveApp({ clock: () => new Date(now) })
const t = await app.tenant("alpha")
await retention(t, "short", "P1D")
expect(await t.call.callByName("archive.retention.list", t.slugs)).toMatchObject({
rows: [{ code: "short", keepFor: "P1D", from: "created" }],
})
const { documentId } = await register(app, t, "Delivery note", "short")
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
const early = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "1" }))
expect(early.problem).toMatchObject({ status: 409, code: "business_retention_active" })
expect(await t.call.callByName("archive.document.hold", { ...request(), revision: "1", legalHold: true })).toEqual({
documentId,
revision: "2",
})
now += 2 * 24 * 3600 * 1000
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toEqual({ rows: [] })
const held = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "2" }))
expect(held.problem).toMatchObject({ status: 409, code: "business_legal_hold" })
// The person reads the kit's sentence for the refusal, in their language.
expect(describeBusinessRefusal(held, "lt")).toBe(
"Šiam įrašui taikomas teisinis sulaikymas, todėl jo keisti ar ištrinti negalima.",
)
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: false })
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
await t.call.callByName("archive.document.dispose", { ...request(), revision: "3" })
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
expect(read).toMatchObject({ document: { status: "disposed", disposed: true } })
})
it("show the service's refusals as the kit's codes", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
const missing = await refusal(t.call.callByName("archive.document.get", { ...t.slugs, documentId: "nobody" }))
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
const noClass = await refusal(register(app, t, "Signed contract", "nothing"))
expect(noClass.problem).toMatchObject({ status: 404, code: "business_not_found" })
const bad = await refusal(
t.call.callByName("archive.retention.create", {
...t.slugs,
code: "bad",
keepFor: "ten years",
from: "created",
requestId: crypto.randomUUID(),
}),
)
expect(bad.problem).toMatchObject({ status: 400, code: "business_invalid" })
})
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
const app = archiveApp({ archive: false })
const t = await app.tenant("alpha")
const disabled = await refusal(t.call.callByName("archive.document.list", t.slugs))
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
app.fake.enable("archive")
app.fake.revoke("archive.retention:write")
const scoped = await refusal(retention(t))
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
expect(scoped.problem.detail).not.toContain("archive.retention:write")
app.fake.refuseNext(503)
const silent = await refusal(t.call.callByName("archive.document.list", t.slugs))
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
})
it("let a viewer read and refuse their writes before the service is called", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
await retention(t)
const { documentId } = await register(app, t)
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
expect(await viewer.callByName("archive.document.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
const before = app.fake.calls.length
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
for (const [name, input] of [
[
"archive.document.register",
{ title: "No", retentionClass: "contracts", fileId: await app.storedFile(t), requestId: crypto.randomUUID() },
],
["archive.document.hold", { documentId, revision: "1", legalHold: true, requestId: crypto.randomUUID() }],
["archive.document.dispose", { documentId, revision: "1", requestId: crypto.randomUUID() }],
["archive.retention.create", { code: "no", keepFor: "P1Y", from: "created", requestId: crypto.randomUUID() }],
] as const) {
const refused = await refusal(viewer.callByName(name, { ...t.slugs, ...input }))
expect(refused.problem.status, name).toBe(404)
}
expect(app.fake.calls.length).toBe(before)
})
it("let an editor register and keep retention to an administrator", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
await retention(t)
const { documentId } = await register(app, t)
const editor = await app.member(t, "editor@alpha.test", "member")
const registered = await editor.callByName("archive.document.register", {
...t.slugs,
title: "Editor's document",
retentionClass: "contracts",
fileId: await app.storedFile(t, "d".repeat(64)),
requestId: crypto.randomUUID(),
})
expect(registered).toMatchObject({ revision: "1" })
const refused = await refusal(
editor.callByName("archive.document.hold", {
...t.slugs,
documentId,
revision: "1",
legalHold: true,
requestId: crypto.randomUUID(),
}),
)
expect(refused.problem.status).toBe(404)
})
it("need no scope beyond the ones `kit module:on archive` asks for", async () => {
let now = new Date("2026-11-02T10:00:00Z").getTime()
const app = archiveApp({
clock: () => new Date(now),
scopes: [
"archive.documents:read",
"archive.documents:write",
"archive.holds:write",
"archive.retention:read",
"archive.retention:write",
"archive.disposal:read",
"archive.disposal:write",
],
})
const t = await app.tenant("alpha")
await retention(t, "short", "P1D")
const { documentId } = await register(app, t, "Delivery note", "short")
await t.call.callByName("archive.version.add", {
...t.slugs,
documentId,
revision: "1",
fileId: await app.storedFile(t, "b".repeat(64)),
requestId: crypto.randomUUID(),
})
await t.call.callByName("archive.document.list", t.slugs)
await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
await t.call.callByName("archive.retention.list", t.slugs)
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: true })
await t.call.callByName("archive.document.hold", { ...request(), revision: "3", legalHold: false })
now += 2 * 24 * 3600 * 1000
await t.call.callByName("archive.disposal.list", t.slugs)
await t.call.callByName("archive.document.dispose", { ...request(), revision: "4" })
})
it("refuse another organization's file: a document cannot be made of a record its project does not hold", async () => {
const app = archiveApp()
const alpha = await app.tenant("alpha")
const bravo = await app.tenant("bravo")
await retention(bravo)
const theirs = await app.storedFile(alpha)
const before = app.fake.calls.length
const refused = await refusal(
bravo.call.callByName("archive.document.register", {
...bravo.slugs,
title: "Not mine",
retentionClass: "contracts",
fileId: theirs,
requestId: crypto.randomUUID(),
}),
)
expect(refused.problem.status).toBe(404)
expect(app.fake.calls.length).toBe(before)
expect(app.fake.archive.documents.size).toBe(0)
})
it("refuse a record of another platform organization: the token reaches only its own", async () => {
const app = archiveApp()
const t = await app.tenant("alpha")
await retention(t)
const { documentId } = await register(app, t)
const elsewhere = sdkArchiveService({ client: app.client, orgSlug: "another-organization" })
await expect(elsewhere.getDocument(documentId)).rejects.toMatchObject({ problem: { status: 404 } })
})
})
describe("the archive module's tenant isolation", () => {
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
const app = archiveApp()
const tenant = async (slug: string): Promise<IsolationTenant> => {
const t = await app.tenant(slug)
return {
orgSlug: t.orgSlug,
workspaceSlug: t.workspaceSlug,
projectSlug: t.projectSlug,
orgId: t.orgId,
workspaceId: t.workspaceId,
projectId: t.projectId,
userId: t.owner.userId,
email: `owner@${slug}.test`,
extra: {},
}
}
const a = await tenant("alpha")
const b = await tenant("bravo")
const result = await runIsolation({
registry: app.registry,
seed: async () => ({ a, b }),
caller: (actor) => app.caller(actor),
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
fixtures: { ...tenancyIsolationFixtures(), ...archiveIsolationFixtures },
alsoCheck: [...TENANCY_ALSO_CHECK],
exempt: tenancyIsolationExempt,
})
expect(result.failures).toEqual([])
for (const name of Object.keys(archiveIsolationFixtures)) expect(result.passed, name).toContain(name)
expect(app.fake.calls).toEqual([])
})
})
+224
View File
@@ -0,0 +1,224 @@
// The pages and components, rendered the way the shell renders them, from the loader's data: the platform's
// refusals are the page's own states, a control is offered to whoever may use it, every table has a
// caption and scoped headers.
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
import { filesModule } from "@conusai-kit/files"
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
import { tenancyModule } from "@conusai-kit/tenancy"
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
import type { ReactNode } from "react"
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
import { describe, expect, it, vi } from "vitest"
import { modulePages } from "../../pages.js"
import { archiveModule } from "../module.js"
import { archivePage } from "../ui/pages.js"
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
const i18n = createI18n("en", {
en: { archive: catalogues.en?.archive ?? {}, app: { failed: "That did not work: {detail}" } },
})
const lithuanian = createI18n("lt", {
lt: { archive: catalogues.lt?.archive ?? {}, app: { failed: "Nepavyko: {detail}" } },
})
const slugs = { org: "acme", workspace: "ws", project: "p" }
const VIEWER: readonly PermissionKey[] = ["archive.document.read"]
const EDITOR: readonly PermissionKey[] = [...VIEWER, "archive.document.write"]
const ADMIN: readonly PermissionKey[] = [...EDITOR, "archive.retention.manage"]
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
const renderToStaticMarkup = (node: ReactNode) =>
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = ADMIN) =>
renderToStaticMarkup(
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
{node}
</PermissionsProvider>
</DataProvider>,
)
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
({
call: async (contract: { name: string }) => {
const answer = answers[contract.name]
if (answer instanceof Error) throw answer
return answer
},
}) as unknown as TransportCaller
/** 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>, permissions?: readonly PermissionKey[]) => {
const data = await archivePage.load({
slugs,
segments,
search: {},
caller: callerOf(answers),
i18n,
container: new Container(),
})
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
const { View } = archivePage
return inProvider(
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
permissions,
)
}
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
/** Every label of a form names a control the page holds, and every hint is in its control's description. */
const expectLabelled = (html: string, labels: number): void => {
const targets = [...html.matchAll(/<label[^>]* for="([^"]+)"/g)].map((match) => match[1] ?? "")
expect(targets).toHaveLength(labels)
for (const id of targets) expect(html).toMatch(new RegExp(`<(?:input|select|button)[^>]* id="${escaped(id)}"`))
for (const [, id = ""] of html.matchAll(/<p id="([^"]+)" data-slot="field-description"/g))
expect(html).toMatch(new RegExp(`aria-describedby="(?:[^"]* )?${escaped(id)}[ "]`))
}
const document = {
documentId: "doc_1",
title: "Signed contract",
kind: "380",
retentionClass: "contracts",
legalHold: false,
status: "active",
disposed: false,
revision: "2",
createdAt: "2026-11-02T10:00:00.000Z",
lastVersionAt: "2026-11-02T11:00:00.000Z",
disposedAt: null,
}
const version = {
versionId: "dvr_1",
sequence: 1,
sha256: "a".repeat(64),
mediaType: "application/pdf",
size: "1234",
createdAt: "2026-11-02T10:00:00.000Z",
}
const contracts = { code: "contracts", keepFor: "P10Y", from: "created", createdAt: "2026-11-02T10:00:00.000Z" }
const listAnswers = {
"archive.document.list": { rows: [document] },
"archive.retention.list": { rows: [contracts] },
}
const documentAnswers = { "archive.document.get": { document, versions: [version] } }
const retentionAnswers = {
"archive.retention.list": { rows: [contracts] },
"archive.disposal.list": { rows: [document] },
}
describe("the archive page", () => {
it("lists the documents, with the register form for whoever may add one", async () => {
const html = await render([], listAnswers)
expect(html).toMatch(/<h1\b[^>]*>Archive<\/h1>/)
expect(html).toMatch(
/<a\b(?=[^>]*href="\/acme\/ws\/p\/archive\/doc_1")(?=[^>]*aria-label="Open Signed contract")[^>]*>Signed contract<\/a>/,
)
expect(html).toMatch(/<table\b[^>]*aria-label="Documents"/)
expect(html).toMatch(/<option\b[^>]*value="contracts"[^>]*>contracts<\/option>/)
expect(html).toContain('type="file"')
expectLabelled(html, 5)
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
expect(await render([], listAnswers, VIEWER)).not.toContain('type="file"')
})
it("says a document cannot be registered before there is a retention class", async () => {
const html = await render([], { ...listAnswers, "archive.retention.list": { rows: [] } })
expect(html).toContain("Define a retention class first.")
expect(html).not.toContain('type="file"')
})
it("shows a document's versions, with the controls each role may use", async () => {
const html = await render(["doc_1"], documentAnswers)
expect(html).toContain("<h2>Signed contract</h2>")
expect(html).toMatch(/<caption\b[^>]*>Versions<\/caption>/)
expect(html).toMatch(/<th\b[^>]*scope="row"[^>]*>1<\/th><td\b[^>]*>application\/pdf<\/td><td\b[^>]*>1234<\/td>/)
expect(html).toContain("Add a version")
expectLabelled(html, 1)
expect(html).toContain("Place a legal hold")
expect(html).toContain("Dispose")
const editor = await render(["doc_1"], documentAnswers, EDITOR)
expect(editor).toContain("Add a version")
expect(editor).not.toContain("Place a legal hold")
const viewer = await render(["doc_1"], documentAnswers, VIEWER)
expect(viewer).not.toContain("Add a version")
})
it("offers nothing on a disposed document", async () => {
const disposed = { ...document, status: "disposed", disposed: true, disposedAt: "2026-12-01T10:00:00.000Z" }
const html = await render(["doc_1"], { "archive.document.get": { document: disposed, versions: [version] } })
expect(html).toContain("Disposed")
expect(html).not.toContain("Add a version")
expect(html).not.toContain("Place a legal hold")
})
it("lists the retention classes and what is due, with the form for an administrator", async () => {
const html = await render(["retention"], retentionAnswers)
expect(html).toMatch(/<h1\b[^>]*>Retention<\/h1>/)
expect(html).toMatch(
/<th\b[^>]*scope="row"[^>]*>contracts<\/th><td\b[^>]*>P10Y<\/td><td\b[^>]*>When the document is registered<\/td>/,
)
expect(html).toMatch(/<caption\b[^>]*>Disposal due<\/caption>/)
expect(html).toContain("New retention class")
expectLabelled(html, 3)
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
expect(await render(["retention"], retentionAnswers, EDITOR)).not.toContain("New retention class")
})
it.each([
["service_not_enabled", "This service is not enabled for your organization."],
["service_permission_missing", "This app is not allowed to use this service. Ask an administrator to grant it."],
["business_unavailable", "The service did not answer. Try again later."],
])("shows the typed state %s", async (code, text) => {
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 503, code, detail: "why" })
const html = await render([], { ...listAnswers, "archive.document.list": refused })
expect(html).toContain(`data-state="${code}"`)
expect(html).toContain('role="status"')
expect(html).toContain(text)
expect(html).not.toContain("<table")
})
it("shows the same state in Lithuanian", async () => {
const code = "service_not_enabled"
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 404, code })
const data = await archivePage.load({
slugs,
segments: [],
search: {},
caller: callerOf({ ...listAnswers, "archive.document.list": refused }),
i18n: lithuanian,
container: new Container(),
})
expect(data).toMatchObject({
kind: "state",
notice: { code, text: "Ši paslauga jūsų organizacijai neįjungta." },
})
})
it("leaves a document that is not there, and a path it does not have, to the shell", async () => {
const missing = new AppError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
await expect(render(["doc_2"], { "archive.document.get": missing })).rejects.toMatchObject({
problem: { code: "not_found" },
})
await expect(render(["doc_1", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
await expect(render(["retention", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
await expect(render(["%2Fetc"], {})).rejects.toMatchObject({ problem: { status: 404 } })
await expect(render(["%E0%A4%A"], {})).rejects.toMatchObject({ problem: { status: 404 } })
})
it("is listed for the shell", async () => {
await expect(modulePages.archive?.()).resolves.toBe(archivePage)
})
})
// Explicit synthetic admission: module-local fixtures exercise the selected module.
vi.mock("../../inclusion.json", () => ({
default: { schemaVersion: 1, selected: ["archive"], everAdopted: ["archive"] },
}))
+109
View File
@@ -0,0 +1,109 @@
// The handlers of the module's procedures: each takes the service its kind may use and calls one use
// case. No rule lives here.
import { implement } from "@conusai-kit/core"
import { filesDeps } from "@conusai-kit/files"
import {
disposalList,
documentDispose,
documentGet,
documentHold,
documentList,
documentRegister,
retentionCreate,
retentionList,
versionAdd,
} from "../contract/index.js"
import {
addVersion,
createRetentionClass,
disposeDocument,
getDocument,
listDisposalDue,
listDocuments,
listRetentionClasses,
registerDocument,
setLegalHold,
} from "../core/archive.js"
import { archiveServiceOf } from "./service.js"
const paged = (input: { after?: string | undefined; limit?: number | undefined }) => ({
...(input.after === undefined ? {} : { after: input.after }),
...(input.limit === undefined ? {} : { limit: input.limit }),
})
export const documentListImpl = implement(documentList, ({ input, ctx }) =>
listDocuments(archiveServiceOf(ctx.container, ctx.kind), {
...(input.q === undefined ? {} : { q: input.q }),
...paged(input),
}),
)
export const documentGetImpl = implement(documentGet, ({ input, ctx }) =>
getDocument(archiveServiceOf(ctx.container, ctx.kind), input.documentId),
)
export const documentRegisterImpl = implement(documentRegister, ({ input, ctx }) =>
registerDocument(archiveServiceOf(ctx.container, "effect"), filesDeps(ctx.container), ctx.scope, {
title: input.title,
...(input.kind === undefined ? {} : { kind: input.kind }),
retentionClass: input.retentionClass,
fileId: input.fileId,
requestId: input.requestId,
}),
)
export const versionAddImpl = implement(versionAdd, ({ input, ctx }) =>
addVersion(archiveServiceOf(ctx.container, "effect"), filesDeps(ctx.container), ctx.scope, {
documentId: input.documentId,
revision: input.revision,
fileId: input.fileId,
requestId: input.requestId,
}),
)
export const documentHoldImpl = implement(documentHold, ({ input, ctx }) =>
setLegalHold(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
documentId: input.documentId,
revision: input.revision,
legalHold: input.legalHold,
requestId: input.requestId,
}),
)
export const documentDisposeImpl = implement(documentDispose, ({ input, ctx }) =>
disposeDocument(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
documentId: input.documentId,
revision: input.revision,
requestId: input.requestId,
}),
)
export const retentionListImpl = implement(retentionList, ({ input, ctx }) =>
listRetentionClasses(archiveServiceOf(ctx.container, ctx.kind), paged(input)),
)
export const retentionCreateImpl = implement(retentionCreate, ({ input, ctx }) =>
createRetentionClass(archiveServiceOf(ctx.container, "effect"), ctx.scope, {
code: input.code,
keepFor: input.keepFor,
from: input.from,
requestId: input.requestId,
}),
)
export const disposalListImpl = implement(disposalList, ({ input, ctx }) =>
listDisposalDue(archiveServiceOf(ctx.container, ctx.kind), paged(input)),
)
export const ARCHIVE_PROCEDURES = [
documentListImpl,
documentGetImpl,
documentRegisterImpl,
versionAddImpl,
documentHoldImpl,
documentDisposeImpl,
retentionListImpl,
retentionCreateImpl,
disposalListImpl,
]
+204
View File
@@ -0,0 +1,204 @@
// Generated SDK adapter. Production dispatch requires a server-prepared request binding.
import { businessAppError, type Client, requestBusinessBinding, toBusinessError } from "@conusai-kit/business"
import type { Container, ProcedureKind } from "@conusai-kit/core"
import {
archiveAddVersion,
archiveCreateRetentionClass,
archiveDisposeDocument,
archiveFindDocuments,
archiveGetDocument,
archiveListDisposalDue,
archiveListRetentionClasses,
archiveListVersions,
archiveRegisterDocument,
archiveSetLegalHold,
type Document,
type DocumentVersion,
type Receipt,
type RetentionClass,
type VersionInput,
} from "@conusai-sdk/archive"
import { createClient } from "@conusai-sdk/archive/client"
import type { DocumentReceipt, DocumentView, RetentionClassView, VersionFacts, VersionView } from "../contract/index.js"
import { ARCHIVE_SERVICE, type ArchiveReads, type ArchiveService, archiveReadsOf } from "../core/ports.js"
const documentView = (document: Document): DocumentView => ({
documentId: document.id,
title: document.title,
kind: document.kind ?? null,
retentionClass: document.retention_class,
legalHold: document.legal_hold,
status: document.status as DocumentView["status"],
disposed: document.status !== "active" || Boolean(document.erased_at),
revision: String(document.revision),
createdAt: document.created_at,
lastVersionAt: document.last_version_at,
disposedAt: document.disposed_at ?? null,
})
const versionView = (version: DocumentVersion): VersionView => ({
versionId: version.id,
sequence: Number(version.sequence),
sha256: version.sha256,
mediaType: version.media_type,
size: String(version.size),
createdAt: version.created_at,
})
const retentionView = (retention: RetentionClass): RetentionClassView => ({
code: retention.code,
keepFor: retention.keep_for,
from: retention.from as RetentionClassView["from"],
createdAt: retention.created_at,
})
const versionInput = (version: VersionFacts): VersionInput => ({
sha256: version.sha256,
media_type: version.mediaType,
size: BigInt(version.size),
blob_ref: version.blobRef,
})
const receiptView = (receipt: Receipt): DocumentReceipt => ({
documentId: receipt.id,
revision: String(receipt.revision ?? 0),
})
/** What a generated operation answered: its data, or the kit's typed app error for the refusal. */
async function answer<T>(
call: Promise<{ data?: T; error?: unknown; response?: Response }>,
): Promise<Exclude<T, undefined>> {
let result: { data?: T; error?: unknown; response?: Response }
try {
result = await call
} catch (error) {
throw businessAppError(toBusinessError(error, undefined))
}
if (result.error !== undefined || result.data === undefined) {
throw businessAppError(toBusinessError(result.error, result.response))
}
return result.data as Exclude<T, undefined>
}
const pageQuery = (query: { after?: string; limit: number }) => ({
...(query.after === undefined ? {} : { after: query.after }),
limit: BigInt(query.limit),
})
const pageOf = <S, T>(page: { data: S[]; next_after?: string | null }, view: (row: S) => T) => ({
rows: page.data.map(view),
...(page.next_after ? { after: page.next_after } : {}),
})
/** The port over one client, in one organization. */
export function sdkArchiveService(binding: { client: Client; orgSlug: string }): ArchiveService {
const { client } = binding
const org = { org_slug: binding.orgSlug }
return {
async listDocuments(query) {
const page = await answer(
archiveFindDocuments({
client,
path: org,
query: { ...(query.q === undefined ? {} : { q: query.q }), ...pageQuery(query) },
}),
)
return pageOf(page, documentView)
},
async getDocument(documentId) {
return documentView(await answer(archiveGetDocument({ client, path: { ...org, document_id: documentId } })))
},
async listVersions(documentId, query) {
const page = await answer(
archiveListVersions({ client, path: { ...org, document_id: documentId }, query: pageQuery(query) }),
)
return pageOf(page, versionView)
},
async listRetentionClasses(query) {
const page = await answer(archiveListRetentionClasses({ client, path: org, query: pageQuery(query) }))
return pageOf(page, retentionView)
},
async listDisposalDue(query) {
const page = await answer(archiveListDisposalDue({ client, path: org, query: pageQuery(query) }))
return pageOf(page, documentView)
},
async registerDocument(input) {
const receipt = await answer(
archiveRegisterDocument({
client,
path: org,
body: {
title: input.title,
...(input.kind === undefined ? {} : { kind: input.kind }),
retention_class: input.retentionClass,
version: versionInput(input.version),
idempotency_key: input.key,
},
}),
)
return receiptView(receipt)
},
async addVersion(input) {
const receipt = await answer(
archiveAddVersion({
client,
path: { ...org, document_id: input.documentId },
body: {
revision: BigInt(input.revision),
version: versionInput(input.version),
idempotency_key: input.key,
},
}),
)
return receiptView(receipt)
},
async setLegalHold(input) {
const receipt = await answer(
archiveSetLegalHold({
client,
path: { ...org, document_id: input.documentId },
body: { revision: BigInt(input.revision), legal_hold: input.legalHold, idempotency_key: input.key },
}),
)
return receiptView(receipt)
},
async disposeDocument(input) {
const receipt = await answer(
archiveDisposeDocument({
client,
path: { ...org, document_id: input.documentId },
body: { revision: BigInt(input.revision), idempotency_key: input.key },
}),
)
return receiptView(receipt)
},
async createRetentionClass(input) {
await answer(
archiveCreateRetentionClass({
client,
path: org,
body: { code: input.code, keep_for: input.keepFor, from: input.from, idempotency_key: input.key },
}),
)
},
}
}
/**
* The service a handler of `kind` may use: the bound one (the token's view for the kind applies), or
* the current verified request's, narrowed to its reads for a `read` or `write` handler as the token would.
*/
export function archiveServiceOf(container: Container, kind: "effect"): ArchiveService
export function archiveServiceOf(container: Container, kind: ProcedureKind): ArchiveReads
export function archiveServiceOf(container: Container, kind: ProcedureKind): ArchiveReads {
if (container.has(ARCHIVE_SERVICE)) return container.resolve(ARCHIVE_SERVICE) as ArchiveReads
let service: ArchiveService
try {
const binding = requestBusinessBinding(container)
service = sdkArchiveService({ client: binding.client(createClient), orgSlug: binding.orgSlug })
} catch (error) {
throw businessAppError(error)
}
return kind === "effect" ? service : archiveReadsOf(service)
}
+17
View File
@@ -0,0 +1,17 @@
// The module's names, in a file with no import: a browser bundle can take them without the schemas.
/** The platform service this module is a client of (B-wave contract §3.14). */
export const ARCHIVE_SERVICE_NAME = "archive"
/** The app permissions of the module. */
export const ARCHIVE_PERMISSIONS = {
read: "archive.document.read",
write: "archive.document.write",
manage: "archive.retention.manage",
} as const
/** What a retention period counts from, as the service names it. */
export const RETENTION_BASES = ["created", "last_version", "event"] as const
/** The most rows one page of a list asks the service for. */
export const PAGE_LIMIT = 50
+28
View File
@@ -0,0 +1,28 @@
// The public API of the archive module: what a page, another module and the OpenAPI document may import.
// Nothing here reaches core/, adapters/ or a service client.
export {
ARCHIVE_PERMISSIONS,
ARCHIVE_SERVICE_NAME,
PAGE_LIMIT,
RETENTION_BASES,
} from "./constants.js"
export {
type DocumentReceipt,
type DocumentView,
disposalList,
documentDispose,
documentGet,
documentHold,
documentList,
documentRegister,
documentViewSchema,
type RetentionClassView,
retentionClassViewSchema,
retentionCreate,
retentionList,
type VersionFacts,
type VersionView,
versionAdd,
versionViewSchema,
} from "./procedures.js"
+163
View File
@@ -0,0 +1,163 @@
// The procedure contracts of the archive module: schemas and names, no handler and no port.
//
// The module is a client of the platform's Archive service (B-wave contract §3.14): documents, their
// versions and their retention live there, in the deployment's organization, and the bytes in the
// platform's Blob store. A read reads; a change is an `effect` (it calls an external service and writes
// nothing of this app's own). A version is made from a file the project holds, so a procedure names a
// `fileId`, never a hash or a Blob reference of the caller's. The service's int64 fields are their digits
// here, so what a page loads is JSON.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { ARCHIVE_PERMISSIONS, RETENTION_BASES } from "./constants.js"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
const page = { after: z.string().max(128).optional(), limit: z.number().int().min(1).max(200).optional() }
/** What a form generates once per submission, so a retried request applies once. */
const requestId = z.string().uuid()
const documentId = z.string().min(1).max(64)
const fileId = z.string().uuid()
/** A revision as its digits: the service's are 64-bit. */
const revision = z.string().regex(/^\d{1,19}$/)
export const documentViewSchema = z.object({
documentId: z.string(),
title: z.string(),
/** An optional UNTDID 1001 document code. */
kind: z.string().nullable(),
retentionClass: z.string(),
legalHold: z.boolean(),
status: z.enum(["active", "disposed"]),
/** Whether the document is past changing: disposed, or erased. */
disposed: z.boolean(),
revision,
createdAt: z.string(),
lastVersionAt: z.string(),
disposedAt: z.string().nullable(),
})
export type DocumentView = z.output<typeof documentViewSchema>
export const versionViewSchema = z.object({
versionId: z.string(),
sequence: z.number().int(),
sha256: z.string(),
mediaType: z.string(),
/** Bytes, as digits. */
size: z.string(),
createdAt: z.string(),
})
export type VersionView = z.output<typeof versionViewSchema>
export const retentionClassViewSchema = z.object({
code: z.string(),
keepFor: z.string(),
from: z.enum(RETENTION_BASES),
createdAt: z.string(),
})
export type RetentionClassView = z.output<typeof retentionClassViewSchema>
/** A version's immutable facts, as the use cases take them from the stored file. */
export interface VersionFacts {
readonly sha256: string
readonly mediaType: string
readonly size: number
readonly blobRef: string
}
const receipt = z.object({ documentId: z.string(), revision })
export type DocumentReceipt = z.output<typeof receipt>
const documentPage = z.object({ rows: z.array(documentViewSchema), after: z.string().optional() })
export const documentList = procedure("archive.document.list")
.kind("read")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.read)
.input(z.object({ ...inProject, q: z.string().max(200).optional(), ...page }))
.output(documentPage)
.contract()
export const documentGet = procedure("archive.document.get")
.kind("read")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.read)
.input(z.object({ ...inProject, documentId }))
.output(z.object({ document: documentViewSchema, versions: z.array(versionViewSchema) }))
.contract()
export const documentRegister = procedure("archive.document.register")
.kind("effect")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.write)
.input(
z.object({
...inProject,
title: z.string().trim().min(1).max(200),
kind: z
.string()
.regex(/^\d{1,4}$/)
.optional(),
retentionClass: z.string().min(1).max(64),
fileId,
requestId,
}),
)
.output(receipt)
.contract()
export const versionAdd = procedure("archive.version.add")
.kind("effect")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.write)
.input(z.object({ ...inProject, documentId, revision, fileId, requestId }))
.output(receipt)
.contract()
export const documentHold = procedure("archive.document.hold")
.kind("effect")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.manage)
.input(z.object({ ...inProject, documentId, revision, legalHold: z.boolean(), requestId }))
.output(receipt)
.contract()
export const documentDispose = procedure("archive.document.dispose")
.kind("effect")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.manage)
.input(z.object({ ...inProject, documentId, revision, requestId }))
.output(receipt)
.contract()
export const retentionList = procedure("archive.retention.list")
.kind("read")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.read)
.input(z.object({ ...inProject, ...page }))
.output(z.object({ rows: z.array(retentionClassViewSchema), after: z.string().optional() }))
.contract()
export const retentionCreate = procedure("archive.retention.create")
.kind("effect")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.manage)
.input(
z.object({
...inProject,
code: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
/** An ISO 8601 duration such as P10Y; the service reads it. */
keepFor: z.string().min(2).max(64),
from: z.enum(RETENTION_BASES),
requestId,
}),
)
.output(z.object({ code: z.string() }))
.contract()
export const disposalList = procedure("archive.disposal.list")
.kind("read")
.scope("project")
.permission(ARCHIVE_PERMISSIONS.read)
.input(z.object({ ...inProject, ...page }))
.output(documentPage)
.contract()
+139
View File
@@ -0,0 +1,139 @@
// What the archive module does, as pure TypeScript over the port in ports.ts.
//
// Documents are the organization's: every project of the app sees the same ones (a multi-tenant app keeps
// all its tenants' documents in its deployment's one organization; partitioning by app tenant is an open
// owner decision, B-wave contract §11.4). A version is made from a file the project holds: the files
// module's read is the membership check, so another project's or organization's file is simply not there,
// and the facts a version records are the ones the store measured, never the caller's.
import type { ProjectScope } from "@conusai-kit/core"
import { type FilesDeps, getFileStorage } from "@conusai-kit/files"
import {
type DocumentReceipt,
type DocumentView,
PAGE_LIMIT,
type RetentionClassView,
type VersionFacts,
type VersionView,
} from "../contract/index.js"
import type { ArchiveReads, ArchiveService, Page } from "./ports.js"
/** The service's idempotency key of a request: one project's request id never answers another's. */
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
`app-project:${scope.projectId}:${requestId}`
const paged = (query: { after?: string; limit?: number }) => ({
...(query.after === undefined ? {} : { after: query.after }),
limit: query.limit ?? PAGE_LIMIT,
})
/** The facts of a version made from a stored file. The reference names the file, and nothing else of it. */
export async function versionFacts(deps: FilesDeps, scope: ProjectScope, fileId: string): Promise<VersionFacts> {
const { file } = await getFileStorage(deps, scope, fileId)
return {
sha256: file.sha256,
mediaType: file.contentType,
size: file.size,
blobRef: `conusai://blob/files/${file.fileId}`,
}
}
export function listDocuments(
reads: ArchiveReads,
query: { q?: string; after?: string; limit?: number },
): Promise<Page<DocumentView>> {
const q = query.q?.trim()
return reads.listDocuments({ ...(q ? { q } : {}), ...paged(query) })
}
export async function getDocument(
reads: ArchiveReads,
documentId: string,
): Promise<{ document: DocumentView; versions: VersionView[] }> {
const document = await reads.getDocument(documentId)
const versions = await reads.listVersions(documentId, { limit: PAGE_LIMIT })
return { document, versions: versions.rows }
}
export async function registerDocument(
service: ArchiveService,
files: FilesDeps,
scope: ProjectScope,
input: { title: string; kind?: string; retentionClass: string; fileId: string; requestId: string },
): Promise<DocumentReceipt> {
const kind = input.kind?.trim()
return service.registerDocument({
title: input.title.trim(),
...(kind ? { kind } : {}),
retentionClass: input.retentionClass,
version: await versionFacts(files, scope, input.fileId),
key: requestKey(scope, input.requestId),
})
}
export async function addVersion(
service: ArchiveService,
files: FilesDeps,
scope: ProjectScope,
input: { documentId: string; revision: string; fileId: string; requestId: string },
): Promise<DocumentReceipt> {
return service.addVersion({
documentId: input.documentId,
revision: input.revision,
version: await versionFacts(files, scope, input.fileId),
key: requestKey(scope, input.requestId),
})
}
export function setLegalHold(
service: ArchiveService,
scope: ProjectScope,
input: { documentId: string; revision: string; legalHold: boolean; requestId: string },
): Promise<DocumentReceipt> {
return service.setLegalHold({
documentId: input.documentId,
revision: input.revision,
legalHold: input.legalHold,
key: requestKey(scope, input.requestId),
})
}
export function disposeDocument(
service: ArchiveService,
scope: ProjectScope,
input: { documentId: string; revision: string; requestId: string },
): Promise<DocumentReceipt> {
return service.disposeDocument({
documentId: input.documentId,
revision: input.revision,
key: requestKey(scope, input.requestId),
})
}
export function listRetentionClasses(
reads: ArchiveReads,
query: { after?: string; limit?: number },
): Promise<Page<RetentionClassView>> {
return reads.listRetentionClasses(paged(query))
}
export async function createRetentionClass(
service: ArchiveService,
scope: ProjectScope,
input: { code: string; keepFor: string; from: RetentionClassView["from"]; requestId: string },
): Promise<{ code: string }> {
await service.createRetentionClass({
code: input.code,
keepFor: input.keepFor.trim(),
from: input.from,
key: requestKey(scope, input.requestId),
})
return { code: input.code }
}
export function listDisposalDue(
reads: ArchiveReads,
query: { after?: string; limit?: number },
): Promise<Page<DocumentView>> {
return reads.listDisposalDue(paged(query))
}
+66
View File
@@ -0,0 +1,66 @@
// The port the archive module's use cases are written against: the platform's Archive service, in this
// module's own words. adapters/service.ts is the one place that knows `@conusai-sdk/archive` exists, and
// it turns every refusal into the kit's typed app error (`businessAppError`) before core sees it.
import { token } from "@conusai-kit/core"
import type { DocumentReceipt, DocumentView, RetentionClassView, VersionFacts, VersionView } from "../contract/index.js"
export interface Page<T> {
readonly rows: T[]
readonly after?: string
}
/** What a `read` procedure may ask the service: nothing that changes a row. */
export interface ArchiveReads {
listDocuments(query: { q?: string; after?: string; limit: number }): Promise<Page<DocumentView>>
getDocument(documentId: string): Promise<DocumentView>
listVersions(documentId: string, query: { after?: string; limit: number }): Promise<Page<VersionView>>
listRetentionClasses(query: { after?: string; limit: number }): Promise<Page<RetentionClassView>>
listDisposalDue(query: { after?: string; limit: number }): Promise<Page<DocumentView>>
}
export interface ArchiveService extends ArchiveReads {
registerDocument(input: {
title: string
kind?: string
retentionClass: string
version: VersionFacts
key: string
}): Promise<DocumentReceipt>
addVersion(input: {
documentId: string
revision: string
version: VersionFacts
key: string
}): Promise<DocumentReceipt>
setLegalHold(input: {
documentId: string
revision: string
legalHold: boolean
key: string
}): Promise<DocumentReceipt>
disposeDocument(input: { documentId: string; revision: string; key: string }): Promise<DocumentReceipt>
createRetentionClass(input: {
code: string
keepFor: string
from: RetentionClassView["from"]
key: string
}): Promise<void>
}
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
export const archiveReadsOf = (service: ArchiveReads): ArchiveReads => ({
listDocuments: (query) => service.listDocuments(query),
getDocument: (documentId) => service.getDocument(documentId),
listVersions: (documentId, query) => service.listVersions(documentId, query),
listRetentionClasses: (query) => service.listRetentionClasses(query),
listDisposalDue: (query) => service.listDisposalDue(query),
})
/**
* Bound by a composition root or a test that brings its own client (another organization, the
* business-services fake). With nothing bound, the module calls this deployment's service.
*/
export const ARCHIVE_SERVICE = token<ArchiveService>("archive.service", {
views: { read: archiveReadsOf, write: archiveReadsOf },
})
+100
View File
@@ -0,0 +1,100 @@
// English messages of the archive 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: "Archive",
intro:
"Documents your organization keeps, with their immutable versions and how long they are kept, from the platform's Archive service. They are your organization's, shared by every project.",
nav: {
documents: "All documents",
retention: "Retention",
},
statuses: {
active: "Active",
disposed: "Disposed",
},
documents: {
caption: "Documents",
empty: "No documents yet.",
title: "Title",
retentionClass: "Retention class",
status: "Status",
created: "Registered",
open: "Open {title}",
search: "Find a document",
searchSubmit: "Find",
more: "Show more",
},
register: {
heading: "Register a document",
title: "Title",
kind: "Document code",
kindHint: "Optional: a UNTDID 1001 code of up to four digits.",
retentionClass: "Retention class",
file: "File",
submit: "Register",
busy: "Registering",
noClass: "Define a retention class first.",
},
document: {
back: "All documents",
retentionClass: "Retention class",
status: "Status",
revision: "Revision",
created: "Registered",
hold: "Legal hold",
disposedAt: "Disposed on",
yes: "Yes",
no: "No",
},
versions: {
title: "Versions",
count: "{count, plural, one {# version} other {# versions}}",
caption: "Versions",
sequence: "Version",
mediaType: "Type",
size: "Bytes",
hash: "SHA-256",
created: "Added",
},
versionForm: {
heading: "Add a version",
file: "File",
submit: "Add version",
busy: "Adding",
},
actions: {
heading: "Retention",
hold: "Place a legal hold",
release: "Lift the legal hold",
dispose: "Dispose",
disposeConfirm: "Dispose of this document? This cannot be undone.",
busy: "Working",
},
retention: {
title: "Retention",
intro:
"A retention class says how long a document is kept and from when. It never changes: a new policy is a new class.",
caption: "Retention classes",
empty: "No retention classes yet.",
code: "Code",
keepFor: "Kept for",
from: "Counted from",
bases: {
created: "When the document is registered",
last_version: "The last version",
event: "An event you record",
},
dueCaption: "Disposal due",
dueEmpty: "Nothing is due for disposal.",
},
retentionForm: {
heading: "New retention class",
code: "Code",
keepFor: "Kept for",
keepForHint: "An ISO 8601 duration, such as P10Y for ten years or P30D for thirty days.",
from: "Counted from",
submit: "Add class",
busy: "Adding",
},
}
+4
View File
@@ -0,0 +1,4 @@
// The archive module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
export { en } from "./en.js"
export { lt } from "./lt.js"
+104
View File
@@ -0,0 +1,104 @@
// Lietuviški archive 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: "Archyvas",
intro:
"Dokumentai, kuriuos saugo jūsų organizacija, su nekintamomis versijomis ir saugojimo terminais, iš platformos archyvo paslaugos. Jie priklauso jūsų organizacijai ir yra bendri visiems projektams.",
nav: {
documents: "Visi dokumentai",
retention: "Saugojimas",
},
statuses: {
active: "Aktyvus",
disposed: "Sunaikintas",
},
documents: {
caption: "Dokumentai",
empty: "Dokumentų dar nėra.",
title: "Pavadinimas",
retentionClass: "Saugojimo klasė",
status: "Būsena",
created: "Užregistruota",
open: "Atidaryti: {title}",
search: "Rasti dokumentą",
searchSubmit: "Ieškoti",
more: "Rodyti daugiau",
},
register: {
heading: "Registruoti dokumentą",
title: "Pavadinimas",
kind: "Dokumento kodas",
kindHint: "Neprivaloma: UNTDID 1001 kodas iki keturių skaitmenų.",
retentionClass: "Saugojimo klasė",
file: "Failas",
submit: "Registruoti",
busy: "Registruojama",
noClass: "Pirmiausia apibrėžkite saugojimo klasę.",
},
document: {
back: "Visi dokumentai",
retentionClass: "Saugojimo klasė",
status: "Būsena",
revision: "Peržiūra",
created: "Užregistruota",
hold: "Teisinis sulaikymas",
disposedAt: "Sunaikinta",
yes: "Taip",
no: "Ne",
},
versions: {
title: "Versijos",
count: "{count, plural, one {# versija} few {# versijos} other {# versijų}}",
caption: "Versijos",
sequence: "Versija",
mediaType: "Tipas",
size: "Baitai",
hash: "SHA-256",
created: "Pridėta",
},
versionForm: {
heading: "Pridėti versiją",
file: "Failas",
submit: "Pridėti versiją",
busy: "Pridedama",
},
actions: {
heading: "Saugojimas",
hold: "Taikyti teisinį sulaikymą",
release: "Panaikinti teisinį sulaikymą",
dispose: "Sunaikinti",
disposeConfirm: "Sunaikinti šį dokumentą? To atšaukti negalima.",
busy: "Vykdoma",
},
retention: {
title: "Saugojimas",
intro:
"Saugojimo klasė nurodo, kiek laiko dokumentas saugomas ir nuo kada. Ji nesikeičia: nauja politika yra nauja klasė.",
caption: "Saugojimo klasės",
empty: "Saugojimo klasių dar nėra.",
code: "Kodas",
keepFor: "Saugoma",
from: "Skaičiuojama nuo",
bases: {
created: "Dokumento registravimo",
last_version: "Paskutinės versijos",
event: "Jūsų įrašyto įvykio",
},
dueCaption: "Laukia sunaikinimo",
dueEmpty: "Nieko nereikia sunaikinti.",
},
retentionForm: {
heading: "Nauja saugojimo klasė",
code: "Kodas",
keepFor: "Saugoma",
keepForHint: "ISO 8601 trukmė, pvz., P10Y – dešimt metų, P30D – trisdešimt dienų.",
from: "Skaičiuojama nuo",
submit: "Pridėti klasę",
busy: "Pridedama",
},
}
+7
View File
@@ -0,0 +1,7 @@
// The archive module's public entry: its manifest, its contract, and the port an app may bind (a test's
// fake, or a client for another organization). A page imports `./ui`.
export { sdkArchiveService } from "./adapters/service.js"
export * from "./contract/index.js"
export { ARCHIVE_SERVICE, type ArchiveReads, type ArchiveService } from "./core/ports.js"
export { archiveModule } from "./module.js"
+56
View File
@@ -0,0 +1,56 @@
// The archive module's manifest: a client of the platform's Archive business service (B-wave contract
// §3.14, docs/business-services.md). It keeps no table of its own -- documents, their immutable versions
// and their retention are the service's, in the deployment's organization, and the bytes stay in the
// platform's Blob store through the files module -- so it has procedures, permissions, pages and
// messages, and no repository, migration, job or event.
//
// The template ships it off: `kit module:on archive` adds it to the list in modules/index.ts and asks the
// deployment token for the service scopes it needs (`archive.documents:read|write`,
// `archive.holds:write`, `archive.retention:read|write`, `archive.disposal:read|write`) in conusai.json.
import { defineModule } from "@conusai-kit/core"
import { ARCHIVE_PROCEDURES } from "./adapters/procedures.js"
import { en, lt } from "./i18n/index.js"
export const archiveModule = defineModule({
id: "archive",
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
profiles: ["postgres", "mariadb", "mongodb"],
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy", "files"],
permissions: {
"archive.document.read": "viewer",
"archive.document.write": "editor",
// A hold, a disposal and a retention policy change what the organization keeps: an administrative act.
"archive.retention.manage": "admin",
},
procedures: ARCHIVE_PROCEDURES,
serviceRequirements: [
{
transport: "business",
service: "archive",
apiRange: "^1.1.0",
profiles: ["postgres", "mariadb", "mongodb"],
operations: {
"archive.document.list": ["archive.documents:read"],
"archive.document.get": ["archive.documents:read"],
"archive.document.register": ["archive.documents:write"],
"archive.version.add": ["archive.documents:write"],
"archive.document.hold": ["archive.holds:write"],
"archive.document.dispose": ["archive.disposal:write"],
"archive.retention.list": ["archive.retention:read"],
"archive.retention.create": ["archive.retention:write"],
"archive.disposal.list": ["archive.disposal:read"],
},
configuration: [],
dependencies: [],
dataClasses: ["personal", "document"],
partitionRequired: true,
remediation: "/docs/business-services.md",
},
],
// `archive` is the segment the web shell's module mount routes to `archivePage` (ui/pages.tsx).
ui: { nav: [{ scope: "project", label: "archive.title", href: "archive", permission: "archive.document.read" }] },
i18n: { en, lt },
})
+91
View File
@@ -0,0 +1,91 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// What an administrator does to a document's retention: place or lift a legal hold, and dispose of it.
// The service decides whether disposal is allowed (a hold blocks it, and the retention must have ended):
// a refusal is shown here, and a disposal asks first, because it cannot be undone.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { ConfirmDialog, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
import { useState } from "react"
export interface DocumentActionsLabels {
readonly heading: string
readonly hold: string
readonly release: string
readonly dispose: string
readonly disposeConfirm: string
readonly busy: string
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function DocumentActions({
slugs,
documentId,
revision,
legalHold,
labels,
}: {
slugs: ProjectSlugs
documentId: string
revision: string
legalHold: boolean
labels: DocumentActionsLabels
}): ReactElement {
const navigation = usePageNavigation()
const hold = useWrite<Record<string, unknown>, unknown>("archive.document.hold")
const dispose = useWrite<Record<string, unknown>, unknown>("archive.document.dispose")
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const act = async (write: () => Promise<unknown>): Promise<void> => {
setState({ kind: "busy" })
try {
await write()
await navigation.refresh()
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
const request = { ...slugs, documentId, revision, requestId: crypto.randomUUID() }
return (
<section>
<h2>{labels.heading}</h2>
<Button
type="button"
disabled={state.kind === "busy"}
onClick={() => void act(() => hold.mutate({ ...request, legalHold: !legalHold }))}
>
{state.kind === "busy" ? labels.busy : legalHold ? labels.release : labels.hold}
</Button>
<ConfirmDialog
title={labels.dispose}
description={labels.disposeConfirm}
confirmLabel={labels.dispose}
busyLabel={labels.busy}
trigger={
<Button type="button" variant="destructive" disabled={state.kind === "busy" || legalHold}>
{labels.dispose}
</Button>
}
onConfirm={async () => {
await dispose.mutate(request)
await navigation.refresh()
}}
/>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</section>
)
}
+75
View File
@@ -0,0 +1,75 @@
"use client"
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useDataClient } from "@conusai-kit/ui/data"
import { ResourceList, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
import { type ReactElement, useMemo } from "react"
import type { DocumentView } from "../contract/index.js"
export interface DocumentListLabels {
readonly caption: string
readonly empty: string
readonly title: string
readonly retentionClass: string
readonly status: string
readonly created: string
/** `Open {title}`: the link's accessible name. */
readonly open: string
readonly search: string
readonly searchSubmit: string
readonly more: string
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
type Page = { rows: DocumentView[]; after?: string | undefined }
export function DocumentList({
slugs,
seed,
statuses,
listHref,
labels,
locale,
}: {
slugs: ProjectSlugs
seed: Page
locale: string
statuses: Readonly<Record<DocumentView["status"], string>>
listHref: string
labels: DocumentListLabels
}): ReactElement {
const date = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }), [locale])
const client = useDataClient()
const { list } = useResourceList<DocumentView>({
seed,
rowId: (row) => row.documentId,
searchLabel: labels.search,
describeError: (error) =>
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
read: async ({ q, after }) =>
(
await client.call<Page>("archive.document.list", {
...slugs,
...(q ? { q } : {}),
...(after ? { after } : {}),
})
).value,
})
return (
<ResourceList
{...list}
label={labels.caption}
empty={{ title: labels.empty }}
columns={[
{ id: "title", header: labels.title, wrap: true },
{ id: "retentionClass", header: labels.retentionClass },
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
{ id: "createdAt", header: labels.created, cell: (row) => date.format(new Date(row.createdAt)) },
]}
rowHref={(row) => `${listHref}/${encodeURIComponent(row.documentId)}`}
rowLinkLabel={(row) => labels.open.replace("{title}", row.title)}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
// The archive module's pages and components. `archivePage` is what the web shell mounts; the rest is
// exported for an app that composes its own page from the parts.
export { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
export { DocumentList, type DocumentListLabels } from "./document-list.js"
export {
documentActionsLabels,
documentListLabels,
registerFormLabels,
retentionFormLabels,
retentionLabels,
type Translate,
versionFormLabels,
versionsLabels,
} from "./labels.js"
export { type ArchivePage, archivePage, serviceState } from "./pages.js"
export { RegisterForm, type RegisterFormLabels } from "./register-form.js"
export { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
export { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
export { VersionForm, type VersionFormLabels } from "./version-form.js"
export { Versions, type VersionsLabels } from "./versions.js"
+125
View File
@@ -0,0 +1,125 @@
// The labels each component takes, built from a message lookup. Every key this module's pages use is
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
// catalogues and fail on a key either language lacks.
//
// `t` takes a key relative to the module (`documents.title`); the page binds it to
// `i18n.message("archive." + key)`. The app's `app.failed` is passed in already looked up, and so is
// `refusals`, the kit's sentence for each business refusal by problem code (`businessRefusalNotices`).
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
import type { DocumentActionsLabels } from "./document-actions.js"
import type { DocumentListLabels } from "./document-list.js"
import type { RegisterFormLabels } from "./register-form.js"
import type { RetentionLabels } from "./retention-classes.js"
import type { RetentionFormLabels } from "./retention-form.js"
import type { VersionFormLabels } from "./version-form.js"
import type { VersionsLabels } from "./versions.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
/** The kit's sentence for each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
export type Refusals = Readonly<Record<string, string>>
export const baseNames = (t: Translate): Readonly<Record<RetentionClassView["from"], string>> =>
Object.fromEntries(RETENTION_BASES.map((base) => [base, t(`retention.bases.${base}`)])) as Record<
RetentionClassView["from"],
string
>
export function documentListLabels(t: Translate, failed: string, refusals: Refusals): DocumentListLabels {
return {
caption: t("documents.caption"),
empty: t("documents.empty"),
title: t("documents.title"),
retentionClass: t("documents.retentionClass"),
status: t("documents.status"),
created: t("documents.created"),
// The placeholder is the component's to fill, so it is passed through untouched.
open: t("documents.open", { title: "{title}" }),
search: t("documents.search"),
searchSubmit: t("documents.searchSubmit"),
more: t("documents.more"),
failed,
refusals,
}
}
export function registerFormLabels(t: Translate, failed: string, refusals: Refusals): RegisterFormLabels {
return {
heading: t("register.heading"),
title: t("register.title"),
kind: t("register.kind"),
kindHint: t("register.kindHint"),
retentionClass: t("register.retentionClass"),
file: t("register.file"),
submit: t("register.submit"),
busy: t("register.busy"),
noClass: t("register.noClass"),
failed,
refusals,
}
}
export function versionsLabels(t: Translate, count: number): VersionsLabels {
return {
title: t("versions.title"),
count: t("versions.count", { count }),
caption: t("versions.caption"),
sequence: t("versions.sequence"),
mediaType: t("versions.mediaType"),
size: t("versions.size"),
hash: t("versions.hash"),
created: t("versions.created"),
}
}
export function versionFormLabels(t: Translate, failed: string, refusals: Refusals): VersionFormLabels {
return {
heading: t("versionForm.heading"),
file: t("versionForm.file"),
submit: t("versionForm.submit"),
busy: t("versionForm.busy"),
failed,
refusals,
}
}
export function documentActionsLabels(t: Translate, failed: string, refusals: Refusals): DocumentActionsLabels {
return {
heading: t("actions.heading"),
hold: t("actions.hold"),
release: t("actions.release"),
dispose: t("actions.dispose"),
disposeConfirm: t("actions.disposeConfirm"),
busy: t("actions.busy"),
failed,
refusals,
}
}
export function retentionLabels(t: Translate): RetentionLabels {
return {
caption: t("retention.caption"),
empty: t("retention.empty"),
code: t("retention.code"),
keepFor: t("retention.keepFor"),
from: t("retention.from"),
dueCaption: t("retention.dueCaption"),
dueEmpty: t("retention.dueEmpty"),
}
}
export function retentionFormLabels(t: Translate, failed: string, refusals: Refusals): RetentionFormLabels {
return {
heading: t("retentionForm.heading"),
code: t("retentionForm.code"),
keepFor: t("retentionForm.keepFor"),
keepForHint: t("retentionForm.keepForHint"),
from: t("retentionForm.from"),
submit: t("retentionForm.submit"),
busy: t("retentionForm.busy"),
bases: baseNames(t),
failed,
refusals,
}
}
+299
View File
@@ -0,0 +1,299 @@
// The archive module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
// shell runs on the server under `/<org>/<workspace>/<project>/archive`, and the view of what it returned.
//
// /archive the organization's documents, and the form that registers one
// /archive/retention the retention classes, what is due for disposal, and the form that adds a class
// /archive/<documentId> one document: its versions, the form that adds one, its legal hold and disposal
//
// Every read is a procedure through the same caller the API uses, and every label is translated in the
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page.
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n, Locale } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import { PageHeader, ServiceStatePage, serviceNotice } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import {
ARCHIVE_PERMISSIONS,
type DocumentView,
disposalList,
documentGet,
documentList,
type RetentionClassView,
retentionList,
type VersionView,
} from "../contract/index.js"
import { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
import { DocumentList, type DocumentListLabels } from "./document-list.js"
import {
baseNames,
documentActionsLabels,
documentListLabels,
registerFormLabels,
retentionFormLabels,
retentionLabels,
type Translate,
versionFormLabels,
versionsLabels,
} from "./labels.js"
import { RegisterForm, type RegisterFormLabels } from "./register-form.js"
import { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
import { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
import { VersionForm, type VersionFormLabels } from "./version-form.js"
import { Versions, type VersionsLabels } from "./versions.js"
/** A platform refusal as the page shows it: the problem's code and its text in the reader's language. */
export interface ServiceNotice {
readonly code: string
readonly text: string
}
type StatusNames = Readonly<Record<DocumentView["status"], string>>
export interface ArchiveListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly retentionLink: string
readonly form: RegisterFormLabels
readonly list: DocumentListLabels
readonly statuses: StatusNames
readonly classes: readonly RetentionClassView[]
readonly documents: { rows: DocumentView[]; after?: string | undefined }
}
export interface ArchiveDocumentData {
readonly kind: "document"
readonly title: string
readonly back: string
readonly document: DocumentView
readonly status: string
readonly versions: readonly VersionView[]
readonly labels: {
readonly retentionClass: string
readonly status: string
readonly revision: string
readonly created: string
readonly hold: string
readonly held: string
readonly disposedAt: string
readonly versions: VersionsLabels
readonly versionForm: VersionFormLabels
readonly actions: DocumentActionsLabels
}
}
export interface ArchiveRetentionData {
readonly kind: "retention"
readonly title: string
readonly intro: string
readonly back: string
readonly list: RetentionLabels
readonly form: RetentionFormLabels
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
readonly classes: readonly RetentionClassView[]
readonly due: readonly DocumentView[]
}
/** The service cannot answer: the notice in place of the page. */
export interface ArchiveStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type ArchivePageData = ArchiveListData | ArchiveDocumentData | ArchiveRetentionData | ArchiveStateData
export type ArchivePage = ProjectPage<TransportCaller, I18n, Container, ArchivePageData>
/** A document id as the service mints it: one path segment. */
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
/** The document a path segment names; a segment that does not decode names none. */
function documentIdOf(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
throw notFound("no such page")
}
}
/** The notice for a platform refusal, or undefined for any other error (which the shell answers). */
export function serviceState(error: unknown, locale: Locale): ServiceNotice | undefined {
return serviceNotice(error, businessServiceNotices(locale))
}
const statusNames = (t: Translate): StatusNames => ({
active: t("statuses.active"),
disposed: t("statuses.disposed"),
})
export const archivePage: ArchivePage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<ArchivePageData> {
const t: Translate = (key, values) => i18n.message(`archive.${key}`, values)
const failed = i18n.message("app.failed")
const refusals = businessRefusalNotices(i18n.locale)
const title = t("title")
const [segment, ...rest] = segments
if (rest.length > 0) throw notFound("no such page")
const documentId = segment === undefined || segment === "retention" ? undefined : documentIdOf(segment)
if (documentId !== undefined && !DOCUMENT_ID.test(documentId)) throw notFound("no such page")
try {
if (segment === "retention") {
const [classes, due] = await Promise.all([caller.call(retentionList, slugs), caller.call(disposalList, slugs)])
return {
kind: "retention",
title: t("retention.title"),
intro: t("retention.intro"),
back: t("nav.documents"),
list: retentionLabels(t),
form: retentionFormLabels(t, failed, refusals),
bases: baseNames(t),
classes: classes.rows,
due: due.rows,
}
}
if (documentId === undefined) {
const [documents, classes] = await Promise.all([
caller.call(documentList, slugs),
caller.call(retentionList, slugs),
])
return {
kind: "list",
title,
intro: t("intro"),
retentionLink: t("nav.retention"),
form: registerFormLabels(t, failed, refusals),
list: documentListLabels(t, failed, refusals),
statuses: statusNames(t),
classes: classes.rows,
documents,
}
}
const { document, versions } = await caller.call(documentGet, { ...slugs, documentId })
return {
kind: "document",
title,
back: t("document.back"),
document,
status: statusNames(t)[document.status],
versions,
labels: {
retentionClass: t("document.retentionClass"),
status: t("document.status"),
revision: t("document.revision"),
created: t("document.created"),
hold: t("document.hold"),
held: document.legalHold ? t("document.yes") : t("document.no"),
disposedAt: t("document.disposedAt"),
versions: versionsLabels(t, versions.length),
versionForm: versionFormLabels(t, failed, refusals),
actions: documentActionsLabels(t, failed, refusals),
},
}
} catch (error) {
const notice = serviceState(error, i18n.locale)
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data, locale }: ProjectPageViewProps<ArchivePageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/archive`
return data.kind === "state" ? (
<ServiceStatePage title={data.title} notice={data.notice} />
) : (
<>
<PageHeader title={data.title} />
{data.kind === "list" ? (
<>
<p>{data.intro}</p>
<p>
<a href={`${listHref}/retention`}>{data.retentionLink}</a>
</p>
<Can permission={ARCHIVE_PERMISSIONS.write}>
<RegisterForm slugs={slugs} classes={data.classes} listHref={listHref} labels={data.form} />
</Can>
<DocumentList
locale={locale}
slugs={slugs}
seed={data.documents}
statuses={data.statuses}
listHref={listHref}
labels={data.list}
/>
</>
) : data.kind === "retention" ? (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<p>{data.intro}</p>
<Can permission={ARCHIVE_PERMISSIONS.manage}>
<RetentionForm slugs={slugs} labels={data.form} />
</Can>
<RetentionClasses
classes={data.classes}
due={data.due}
bases={data.bases}
listHref={listHref}
labels={data.list}
/>
</>
) : (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<h2>{data.document.title}</h2>
<dl>
<dt>{data.labels.status}</dt>
<dd>{data.status}</dd>
<dt>{data.labels.retentionClass}</dt>
<dd>{data.document.retentionClass}</dd>
<dt>{data.labels.created}</dt>
<dd>{data.document.createdAt.slice(0, 10)}</dd>
<dt>{data.labels.revision}</dt>
<dd>{data.document.revision}</dd>
<dt>{data.labels.hold}</dt>
<dd>{data.labels.held}</dd>
{data.document.disposedAt === null ? null : (
<>
<dt>{data.labels.disposedAt}</dt>
<dd>{data.document.disposedAt.slice(0, 10)}</dd>
</>
)}
</dl>
<Versions versions={data.versions} labels={data.labels.versions} />
{data.document.disposed ? null : (
<>
<Can permission={ARCHIVE_PERMISSIONS.write}>
<VersionForm
slugs={slugs}
documentId={data.document.documentId}
revision={data.document.revision}
labels={data.labels.versionForm}
/>
</Can>
<Can permission={ARCHIVE_PERMISSIONS.manage}>
<DocumentActions
slugs={slugs}
documentId={data.document.documentId}
revision={data.document.revision}
legalHold={data.document.legalHold}
labels={data.labels.actions}
/>
</Can>
</>
)}
</>
)}
</>
)
},
})
+106
View File
@@ -0,0 +1,106 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Registers a document: the browser stores the file through the files module, then asks this module to
// register it, which takes the version's hash, size and type from the stored file. Then it opens the
// document.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
import { SelectField, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
import type { RetentionClassView } from "../contract/index.js"
export interface RegisterFormLabels {
readonly heading: string
readonly title: string
readonly kind: string
readonly kindHint: string
readonly retentionClass: string
readonly file: string
readonly submit: string
readonly busy: string
readonly noClass: string
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function RegisterForm({
slugs,
classes,
listHref,
labels,
}: {
slugs: ProjectSlugs
classes: readonly RetentionClassView[]
listHref: string
labels: RegisterFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const upload = useUpload()
const register = useWrite<Record<string, unknown>, { documentId: string }>("archive.document.register")
const [requestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const file = form.get("file")
if (!(file instanceof File) || file.size === 0) return
const kind = String(form.get("kind") ?? "").trim()
setState({ kind: "busy" })
try {
const stored = await upload(slugs, file)
const created = await register.mutate({
...slugs,
title: String(form.get("title") ?? "").trim(),
...(kind ? { kind } : {}),
retentionClass: String(form.get("retentionClass") ?? ""),
fileId: stored.fileId,
requestId,
})
navigation.navigate(`${listHref}/${encodeURIComponent(created.documentId)}`)
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return classes.length === 0 ? (
<p>{labels.noClass}</p>
) : (
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.title}</FieldLabel>
<Input name="title" type="text" required maxLength={200} />
</Field>
<Field>
<FieldLabel>{labels.kind}</FieldLabel>
<Input name="kind" type="text" pattern="[0-9]{1,4}" />
<FieldDescription>{labels.kindHint}</FieldDescription>
</Field>
<SelectField
name="retentionClass"
label={labels.retentionClass}
required
defaultValue={classes[0]?.code}
options={classes.map((retention) => ({ value: retention.code, label: retention.code }))}
/>
<Field>
<FieldLabel>{labels.file}</FieldLabel>
<Input name="file" type="file" required />
</Field>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// The retention classes, and the documents due for disposal. A server component: it holds no state, and
// every string is passed in.
import type { ReactElement } from "react"
import type { DocumentView, RetentionClassView } from "../contract/index.js"
export interface RetentionLabels {
readonly caption: string
readonly empty: string
readonly code: string
readonly keepFor: string
readonly from: string
readonly dueCaption: string
readonly dueEmpty: string
}
export function RetentionClasses({
classes,
due,
bases,
listHref,
labels,
}: {
classes: readonly RetentionClassView[]
due: readonly DocumentView[]
bases: Readonly<Record<RetentionClassView["from"], string>>
listHref: string
labels: RetentionLabels
}): ReactElement {
return (
<>
{classes.length === 0 ? (
<p>{labels.empty}</p>
) : (
<Table>
<TableCaption>{labels.caption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.code}</TableHead>
<TableHead scope="col">{labels.keepFor}</TableHead>
<TableHead scope="col">{labels.from}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{classes.map((retention) => (
<TableRow key={retention.code}>
<TableHead scope="row">{retention.code}</TableHead>
<TableCell>{retention.keepFor}</TableCell>
<TableCell>{bases[retention.from]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{due.length === 0 ? (
<p>{labels.dueEmpty}</p>
) : (
<Table>
<TableCaption>{labels.dueCaption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.code}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{due.map((document) => (
<TableRow key={document.documentId}>
<TableHead scope="row">
<a href={`${listHref}/${encodeURIComponent(document.documentId)}`}>{document.title}</a>
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Adds a retention class. A class never changes once it exists: a new policy is a new class.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { SelectField, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
export interface RetentionFormLabels {
readonly heading: string
readonly code: string
readonly keepFor: string
readonly keepForHint: string
readonly from: string
readonly submit: string
readonly busy: string
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function RetentionForm({ slugs, labels }: { slugs: ProjectSlugs; labels: RetentionFormLabels }): ReactElement {
const navigation = usePageNavigation()
const create = useWrite<Record<string, unknown>, unknown>("archive.retention.create")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
setState({ kind: "busy" })
try {
await create.mutate({
...slugs,
code: String(form.get("code") ?? "").trim(),
keepFor: String(form.get("keepFor") ?? "").trim(),
from: String(form.get("from") ?? "created"),
requestId,
})
await navigation.refresh()
setRequestId(crypto.randomUUID())
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return (
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.code}</FieldLabel>
<Input name="code" type="text" required maxLength={64} pattern="[A-Za-z0-9_\-]+" />
</Field>
<Field>
<FieldLabel>{labels.keepFor}</FieldLabel>
<Input name="keepFor" type="text" required maxLength={64} />
<FieldDescription>{labels.keepForHint}</FieldDescription>
</Field>
<SelectField
name="from"
label={labels.from}
defaultValue="created"
options={RETENTION_BASES.map((base) => ({ value: base, label: labels.bases[base] }))}
/>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+74
View File
@@ -0,0 +1,74 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Adds a version: the browser stores the file through the files module, then asks this module to append
// it over the revision the page read. A version nobody else has overtaken lands; a stale one is refused.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
import { serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription, Field, FieldLabel, Input } from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
export interface VersionFormLabels {
readonly heading: string
readonly file: string
readonly submit: string
readonly busy: string
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function VersionForm({
slugs,
documentId,
revision,
labels,
}: {
slugs: ProjectSlugs
documentId: string
revision: string
labels: VersionFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const upload = useUpload()
const add = useWrite<Record<string, unknown>, unknown>("archive.version.add")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const file = new FormData(event.currentTarget).get("file")
if (!(file instanceof File) || file.size === 0) return
setState({ kind: "busy" })
try {
const stored = await upload(slugs, file)
await add.mutate({ ...slugs, documentId, revision, fileId: stored.fileId, requestId })
await navigation.refresh()
setRequestId(crypto.randomUUID())
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return (
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.file}</FieldLabel>
<Input name="file" type="file" required />
</Field>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// A document's versions. A server component: it holds no state, and every string is passed in.
import type { ReactElement } from "react"
import type { VersionView } from "../contract/index.js"
export interface VersionsLabels {
readonly title: string
/** How many versions, already filled with the plural form. */
readonly count: string
readonly caption: string
readonly sequence: string
readonly mediaType: string
readonly size: string
readonly hash: string
readonly created: string
}
export function Versions({
versions,
labels,
}: {
versions: readonly VersionView[]
labels: VersionsLabels
}): ReactElement {
return (
<section>
<h2>{labels.title}</h2>
<p>{labels.count}</p>
<Table>
<TableCaption>{labels.caption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.sequence}</TableHead>
<TableHead scope="col">{labels.mediaType}</TableHead>
<TableHead scope="col">{labels.size}</TableHead>
<TableHead scope="col">{labels.hash}</TableHead>
<TableHead scope="col">{labels.created}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versions.map((version) => (
<TableRow key={version.versionId}>
<TableHead scope="row">{version.sequence}</TableHead>
<TableCell>{version.mediaType}</TableCell>
<TableCell>{version.size}</TableCell>
<TableCell>
<code>{version.sha256.slice(0, 12)}</code>
</TableCell>
<TableCell>{version.createdAt.slice(0, 10)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)
}
+54
View File
@@ -0,0 +1,54 @@
// The branding module's messages: both languages, the same keys, every key asked for, and no string
// or colour literal in its components.
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { buildRegistry } from "@conusai-kit/core"
import { lintUiStrings } from "@conusai-kit/i18n"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import { brandFormLabels } from "../ui/labels.js"
// The messages as the app has them: from the manifest, flattened and merged by the kit.
const catalogues = buildRegistry([...appModules]).catalogues
const en = catalogues.en?.branding ?? {}
const lt = catalogues.lt?.branding ?? {}
function keysUsed(): string[] {
const asked: string[] = []
brandFormLabels((key) => {
asked.push(key)
return key
})
return asked
}
describe("the branding catalogues", () => {
it("hold the same keys in English and in Lithuanian", () => {
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
})
it("hold every key the form asks for, and nothing it does not (the page asks for title and intro itself)", () => {
const asked = new Set([...keysUsed(), "title", "intro"])
for (const key of asked) {
expect(en[key as keyof typeof en], `en ${key}`).toBeTruthy()
expect(lt[key as keyof typeof lt], `lt ${key}`).toBeTruthy()
}
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
})
})
describe("the components", () => {
const dir = new URL("../ui/", import.meta.url).pathname
const sources = readdirSync(dir)
.filter((name) => name.endsWith(".tsx"))
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
it("carry no hard-coded user-facing string and no colour literal", () => {
expect(sources.length).toBeGreaterThanOrEqual(2)
for (const { name, text } of sources) {
expect(lintUiStrings(text, name), name).toEqual([])
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
}
})
})
@@ -0,0 +1,66 @@
// Which logo a theme shows when an organization has uploaded one, both, or none: the one rule the
// layout, the settings page, the share image and the email all read (contract/logo.ts).
import { describe, expect, it } from "vitest"
import { logoUrl, resolveLogo, tenantLogo } from "../contract/logo.js"
const light = "/acme/brand/logo/light?v=aaaaaaaaaaaaaaaa"
const dark = "/acme/brand/logo/dark?v=bbbbbbbbbbbbbbbb"
const app = { light: "/app-light.svg", dark: "/app-dark.svg" }
describe("the logo an organization shows on a theme", () => {
it("is its own when it has one for that theme", () => {
const both = { logoLight: light, logoDark: dark }
expect(tenantLogo(both, "light")).toEqual({ source: "tenant", variant: "light", path: light })
expect(tenantLogo(both, "dark")).toEqual({ source: "tenant", variant: "dark", path: dark })
})
it("is the only one it has, on both themes", () => {
expect(tenantLogo({ logoLight: light, logoDark: null }, "dark")).toEqual({
source: "tenant",
variant: "light",
path: light,
})
expect(tenantLogo({ logoLight: null, logoDark: dark }, "light")).toEqual({
source: "tenant",
variant: "dark",
path: dark,
})
expect(tenantLogo({ logoLight: light, logoDark: null }, "light")?.variant).toBe("light")
})
it("is none when it has none", () => {
expect(tenantLogo({ logoLight: null, logoDark: null }, "dark")).toBeNull()
})
})
describe("the logo a share image or an email shows", () => {
it("is the organization's, with the same fallback between its two", () => {
expect(resolveLogo({ logoLight: light, logoDark: null }, app, "dark")).toEqual({
source: "tenant",
variant: "light",
path: light,
})
expect(resolveLogo({ logoLight: light, logoDark: dark }, app, "dark")).toEqual({
source: "tenant",
variant: "dark",
path: dark,
})
})
it("is the app brand's own for that theme when the organization has none, or there is no organization", () => {
expect(resolveLogo({ logoLight: null, logoDark: null }, app, "dark")).toEqual({
source: "app",
variant: "dark",
path: "/app-dark.svg",
})
expect(resolveLogo(null, app, "light")).toEqual({ source: "app", variant: "light", path: "/app-light.svg" })
})
})
describe("the address of a stored size", () => {
it("adds the size after the content tag", () => {
expect(logoUrl(light, "1x")).toBe(`${light}&s=1x`)
expect(logoUrl("/acme/brand/logo/light", "2x")).toBe("/acme/brand/logo/light?s=2x")
})
})
+56
View File
@@ -0,0 +1,56 @@
// The handlers of the branding procedures: each resolves its ports and calls one use case.
import { type Container, implement, validationFailed } from "@conusai-kit/core"
import { DATA_SOURCE, OBJECT_STORE } from "@conusai-kit/data"
import { brandGet, brandSet, logoClear, logoGet, logoSet } from "../contract/index.js"
import { type BrandingDeps, clearLogo, getBrand, readLogo, setBrand, setLogo } from "../core/brand.js"
export const brandingDeps = (container: Container): BrandingDeps => ({
data: container.resolve(DATA_SOURCE),
store: container.resolve(OBJECT_STORE),
})
/** Standard base64 to bytes, refusing anything else as a 422 that names the field. */
function bytesOf(body: string): Uint8Array {
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) {
throw validationFailed([{ path: "body", message: "is not standard base64" }])
}
return Uint8Array.from(atob(body), (char) => char.charCodeAt(0))
}
const toBase64 = (bytes: Uint8Array): string => {
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export const brandGetImpl = implement(brandGet, async ({ ctx }) =>
getBrand(brandingDeps(ctx.container).data, ctx.scope),
)
export const brandSetImpl = implement(brandSet, async ({ input, ctx }) =>
setBrand(brandingDeps(ctx.container).data, ctx.scope, {
displayName: input.displayName,
primary: input.primary,
accent: input.accent,
}),
)
export const logoSetImpl = implement(logoSet, async ({ input, ctx }) =>
setLogo(brandingDeps(ctx.container), ctx.scope, {
variant: input.variant,
contentType: input.contentType,
bytes: bytesOf(input.body),
}),
)
export const logoClearImpl = implement(logoClear, async ({ input, ctx }) =>
clearLogo(brandingDeps(ctx.container), ctx.scope, input.variant),
)
export const logoGetImpl = implement(logoGet, async ({ input, ctx }) => {
const logo = await readLogo(brandingDeps(ctx.container), ctx.scope, input.variant, input.size)
return { contentType: logo.contentType, body: toBase64(logo.bytes) }
})
export const BRANDING_PROCEDURES = [brandGetImpl, brandSetImpl, logoSetImpl, logoClearImpl, logoGetImpl]

Some files were not shown because too many files have changed in this diff Show More