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