@nuxtagentic/prompt 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +91 -2
  3. package/cloudconfig.ts +165 -0
  4. package/config.ts +33 -0
  5. package/core/decisions.ts +155 -0
  6. package/core/engine.ts +317 -0
  7. package/core/index.ts +3 -0
  8. package/core/planner.ts +67 -0
  9. package/core/types.ts +188 -0
  10. package/core/validation.ts +187 -0
  11. package/docs/search.md +69 -0
  12. package/module.ts +26 -0
  13. package/package.json +56 -3
  14. package/runtime/server/api/agent/cancel.post.ts +17 -0
  15. package/runtime/server/api/agent/execute.post.ts +26 -0
  16. package/runtime/server/api/agent/plan.post.ts +37 -0
  17. package/runtime/server/api/agent.post.ts +60 -0
  18. package/runtime/server/confirmations.ts +104 -0
  19. package/runtime/server/utils/agent-operations.ts +112 -0
  20. package/runtime/server/utils/agent-session.ts +14 -0
  21. package/runtime/server/utils/decision-arguments.ts +363 -0
  22. package/runtime/server/utils/decision-provider.ts +141 -0
  23. package/runtime/server/utils/jev-answers.ts +55 -0
  24. package/runtime/server/utils/jev-dispatch.ts +156 -0
  25. package/runtime/server/utils/jev-inference.ts +87 -0
  26. package/runtime/server/utils/jev-tool-plan.ts +64 -0
  27. package/runtime/server/utils/model-query-condition.ts +59 -0
  28. package/runtime/server/utils/model-query.ts +275 -0
  29. package/runtime/server/utils/prompt-candidates.ts +165 -0
  30. package/runtime/server/utils/prompt-dates.ts +83 -0
  31. package/runtime/shared/agent-tools.ts +50 -0
  32. package/runtime/shared/agent.ts +5 -0
  33. package/runtime/shared/decision-provider.ts +1 -0
  34. package/runtime/shared/prompt-config.ts +27 -0
  35. package/schema.ts +69 -0
  36. package/server-contributions.ts +53 -0
@@ -0,0 +1,187 @@
1
+ import {
2
+ OperationError,
3
+ type Condition,
4
+ type Field,
5
+ type Filters,
6
+ type ResourceInfo,
7
+ type Row,
8
+ } from './types'
9
+
10
+ export function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ }
13
+
14
+ export function isValidId(value: unknown): value is number {
15
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
16
+ }
17
+
18
+ const textKinds = new Set(['text', 'textarea', 'email', 'url', 'select', 'date', 'datetime'])
19
+ const numericKinds = new Set(['number', 'float', 'relation'])
20
+ const textOperators = ['like', 'not_contains', 'starts_with', 'ends_with']
21
+ const rangeOperators = ['gt', 'gte', 'lt', 'lte']
22
+ const emptyOperators = ['is_empty', 'is_not_empty']
23
+ const setOperators = ['is_any_of', 'is_none_of']
24
+ const operators = new Set([
25
+ 'eq',
26
+ 'neq',
27
+ ...textOperators,
28
+ ...rangeOperators,
29
+ ...emptyOperators,
30
+ ...setOperators,
31
+ ])
32
+
33
+ function matchesField(value: unknown, kind: string) {
34
+ if (value === null) {
35
+ return true
36
+ }
37
+ if (kind === 'boolean') {
38
+ return typeof value === 'boolean'
39
+ }
40
+ if (numericKinds.has(kind)) {
41
+ return typeof value === 'number' && Number.isFinite(value)
42
+ }
43
+ return textKinds.has(kind) && typeof value === 'string' && value.length <= 2000
44
+ }
45
+
46
+ function validDate(value: unknown, kind: string) {
47
+ if (value === null) {
48
+ return true
49
+ }
50
+ if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
51
+ return false
52
+ }
53
+ const canonical = new Date(value).toISOString()
54
+ return kind === 'datetime'
55
+ ? canonical === value
56
+ : /^\d{4}-\d{2}-\d{2}$/.test(value) && canonical.slice(0, 10) === value
57
+ }
58
+
59
+ function validateCondition(raw: Record<string, unknown>, field: Field | undefined): Condition {
60
+ if (
61
+ raw.type !== 'condition' ||
62
+ !field ||
63
+ typeof raw.operator !== 'string' ||
64
+ !operators.has(raw.operator) ||
65
+ ['json', 'multiselect'].includes(field.kind) ||
66
+ Object.keys(raw).some((key) => !['type', 'field', 'operator', 'value'].includes(key))
67
+ ) {
68
+ throw new OperationError(422, 'The filter contains an unavailable field or operator.')
69
+ }
70
+ const operator = raw.operator as Condition['operator']
71
+ const value = raw.value
72
+ const values = Array.isArray(value) ? value : [value]
73
+ if (setOperators.includes(operator)) {
74
+ if (
75
+ !Array.isArray(value) ||
76
+ !value.length ||
77
+ value.length > 25 ||
78
+ value.some(
79
+ (item) => item === null || typeof item === 'boolean' || !matchesField(item, field.kind),
80
+ )
81
+ ) {
82
+ throw new OperationError(422, 'Invalid filter alternatives.')
83
+ }
84
+ } else if (!matchesField(value, field.kind)) {
85
+ throw new OperationError(422, 'Invalid filter value.')
86
+ }
87
+ if (emptyOperators.includes(operator) && value !== null) {
88
+ throw new OperationError(422, 'Empty-value filters must use null.')
89
+ }
90
+ if (
91
+ ['date', 'datetime'].includes(field.kind) &&
92
+ values.some((item) => !validDate(item, field.kind))
93
+ ) {
94
+ throw new OperationError(422, 'Use a valid date or canonical UTC timestamp in filters.')
95
+ }
96
+ if (
97
+ textOperators.includes(operator) &&
98
+ (!textKinds.has(field.kind) ||
99
+ typeof value !== 'string' ||
100
+ !value.trim() ||
101
+ /[%_\\]/.test(value))
102
+ ) {
103
+ throw new OperationError(422, 'Use literal, non-empty text to match records.')
104
+ }
105
+ if (
106
+ rangeOperators.includes(operator) &&
107
+ ((!numericKinds.has(field.kind) && !['date', 'datetime'].includes(field.kind)) ||
108
+ value === null)
109
+ ) {
110
+ throw new OperationError(422, 'This field cannot use a range comparison.')
111
+ }
112
+ if (
113
+ field.options?.length &&
114
+ !emptyOperators.includes(operator) &&
115
+ values.some((item) => !field.options!.includes(String(item)))
116
+ ) {
117
+ throw new OperationError(422, 'Unknown field option.')
118
+ }
119
+ return { type: 'condition', field: field.key, operator, value: value as Condition['value'] }
120
+ }
121
+
122
+ /** Never drop an invalid constraint: doing so could broaden a mutation. */
123
+ export function validateFilters(value: unknown, resource: ResourceInfo): Filters {
124
+ const fields = new Map(resource.fields.map((field) => [field.key, field]))
125
+ let count = 0
126
+ function inspect(raw: unknown, depth: number): Condition | Filters {
127
+ if (++count > 60 || depth > 6 || !isRecord(raw)) {
128
+ throw new OperationError(422, 'The filter is too complex.')
129
+ }
130
+ if (raw.type !== 'group') {
131
+ return validateCondition(
132
+ raw,
133
+ typeof raw.field === 'string' ? fields.get(raw.field) : undefined,
134
+ )
135
+ }
136
+ if (
137
+ (raw.logic !== 'and' && raw.logic !== 'or') ||
138
+ !Array.isArray(raw.children) ||
139
+ Object.keys(raw).some((key) => !['type', 'logic', 'children'].includes(key))
140
+ ) {
141
+ throw new OperationError(422, 'Invalid filter group.')
142
+ }
143
+ if (depth && !raw.children.length) {
144
+ throw new OperationError(422, 'An empty nested filter is not supported.')
145
+ }
146
+ return {
147
+ type: 'group',
148
+ logic: raw.logic,
149
+ children: raw.children.map((node) => inspect(node, depth + 1)),
150
+ }
151
+ }
152
+ const filters = inspect(value, 0)
153
+ if (filters.type !== 'group') {
154
+ throw new OperationError(422, 'Expected a filter group.')
155
+ }
156
+ return filters
157
+ }
158
+
159
+ export function toDisplayRow(row: Row, resource: ResourceInfo) {
160
+ const result: Row = { id: row.id }
161
+ for (const key of resource.displayFields) {
162
+ const value = row[key]
163
+ // Public scalar evidence only. No private rows or large document payloads in confirmations.
164
+ if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
165
+ result[key] = value
166
+ }
167
+ }
168
+ return result
169
+ }
170
+
171
+ function canonical(value: unknown): string {
172
+ if (Array.isArray(value)) {
173
+ return `[${value.map(canonical).join(',')}]`
174
+ }
175
+ if (isRecord(value)) {
176
+ return `{${Object.keys(value)
177
+ .sort()
178
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
179
+ .join(',')}}`
180
+ }
181
+ return JSON.stringify(value) ?? 'null'
182
+ }
183
+ export async function computeFingerprint(row: Row): Promise<string> {
184
+ const bytes = new TextEncoder().encode(canonical(row))
185
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
186
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
187
+ }
package/docs/search.md ADDED
@@ -0,0 +1,69 @@
1
+ # How search works
2
+
3
+ The workspace combines local query parsing, authorized retrieval, and optional
4
+ provider decisions. Each feature owns its query syntax and data source. Agent
5
+ routes natural-language requests using metadata from the installed modules.
6
+
7
+ ## Follow a query
8
+
9
+ 1. Workspace recognizes a short keyword search or asks registered feature parsers
10
+ to interpret structured input.
11
+ 2. A local match opens its collection and passes a validated query to that
12
+ feature's data source. No provider call is needed.
13
+ 3. Submitted natural language goes to Agent. The selected provider chooses a tool
14
+ and schema-derived arguments; the application validates and compiles them.
15
+ 4. The feature retrieves authorized records through its own data source.
16
+ 5. Selecting a result changes URL state. Proposed mutations go through a separate
17
+ server-stored review before execution.
18
+
19
+ Short keyword searches cover the installed searchable collections. Fully quoting
20
+ an input forces literal keyword interpretation. Inputs with intent words or
21
+ structured-query punctuation stay available to feature parsers and Agent.
22
+ [`getDashboardSearchText`](../../workspace/runtime/shared/dashboard-search.ts) owns
23
+ that distinction; [`useWorkspacePrompt`](../../../app/composables/useWorkspacePrompt.ts)
24
+ coordinates parsing, provider requests, cancellation, and retries.
25
+
26
+ ## Register a model collection
27
+
28
+ Define the model in its owning package's `schema.ts` and include it in the host's
29
+ `cloud.config.ts`. Create a view with
30
+ [`defineModelView`](../../data/runtime/shared/model-views.ts) and publish it through
31
+ that module's `runtime/shared/agent.ts` metadata.
32
+
33
+ | View option | Controls |
34
+ | -------------------------------- | --------------------------------------------- |
35
+ | `id`, `label`, `icon`, `aliases` | Source identity and recognized names |
36
+ | `columns` | Visible and selectable columns |
37
+ | `search`, `filter`, `sort` | Allowed fields for those operations |
38
+ | `actions` | Available create, update, and delete controls |
39
+ | `prompt.fields`, `prompt.values` | Domain vocabulary for schema-based decisions |
40
+
41
+ Field types, defaults, and select options come from the schema. Server-only fields
42
+ remain excluded from public forms and assignment. The owning feature supplies
43
+ its collection UI and registers it with the workspace.
44
+
45
+ Kit generates metadata from installed modules. There is no central
46
+ `model-ui.config.ts` or handwritten list of every query sentence.
47
+
48
+ ## What the provider decides
49
+
50
+ [`createDispatcher`](../runtime/server/utils/jev-dispatch.ts) builds closed choices
51
+ from registered tools and model views. Explicit resource context can skip routing.
52
+ When request limits permit, routing and argument decisions share a call; larger
53
+ requests first select a resource, then ask only about its schema.
54
+
55
+ [`prepareCandidates`](../runtime/server/utils/prompt-candidates.ts) preserves
56
+ literal values from the input. Schema-based decisions select fields, comparisons,
57
+ values, and AND/OR structure. The query compiler validates the result before
58
+ building CloudConfig filters. Required mutation fields are checked by the planner.
59
+ Neither provider generates executable SQL or arbitrary JSON arguments.
60
+
61
+ TypeSafe AI and OpenAI use the same decision contract through
62
+ [`useDecisionProvider`](../runtime/server/utils/decision-provider.ts). Resource
63
+ routing needs a configured provider key; deterministic local queries continue
64
+ without one. The [Agent guide](../README.md) describes configuration and writes.
65
+
66
+ [`config.ts`](../config.ts) contains interpretation instructions, candidate limits,
67
+ request budgets, deadlines, retries, and reranking policy. Optional metrics record
68
+ request timing and size without logging prompt or answer text. Literal names keep
69
+ their spelling; schema vocabulary can accept known aliases and spelling variants.
package/module.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { createResolver, defineNuxtModule } from '@nuxt/kit'
2
+ import { registerWorkspaceModule } from '@nuxtagentic/kit'
3
+ import { defu } from 'defu'
4
+
5
+ export default defineNuxtModule({
6
+ meta: { name: '@nuxtagentic/prompt' },
7
+ moduleDependencies: {
8
+ '@nuxtagentic/data': {},
9
+ },
10
+ setup(_options, nuxt) {
11
+ const resolver = createResolver(import.meta.url)
12
+ registerWorkspaceModule(
13
+ {
14
+ id: 'prompt',
15
+ runtime: resolver.resolve('./runtime'),
16
+ metadata: resolver.resolve('./runtime/shared/agent.ts'),
17
+ },
18
+ nuxt,
19
+ )
20
+ nuxt.options.runtimeConfig = defu(nuxt.options.runtimeConfig, {
21
+ decisionProvider: 'typesafeAI',
22
+ typesafe: { apiKey: '' },
23
+ openai: { apiKey: '', decisionModel: 'gpt-6-luna' },
24
+ })
25
+ },
26
+ })
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@nuxtagentic/prompt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "module.ts",
7
+ "runtime",
8
+ "schema.ts",
9
+ "core",
10
+ "cloudconfig.ts",
11
+ "config.ts",
12
+ "server-contributions.ts",
13
+ "docs",
14
+ "README.md",
15
+ "LICENSE.md"
16
+ ],
17
+ "exports": {
18
+ ".": "./module.ts",
19
+ "./package.json": "./package.json",
20
+ "./schema": "./schema.ts",
21
+ "./cloudconfig": "./cloudconfig.ts",
22
+ "./config": "./config.ts",
23
+ "./server-contributions": "./server-contributions.ts",
24
+ "./core": "./core/index.ts",
25
+ "./core/*": "./core/*.ts",
26
+ "./runtime/*.vue": "./runtime/*.vue",
27
+ "./runtime/*.css": "./runtime/*.css",
28
+ "./runtime/*.json": "./runtime/*.json",
29
+ "./runtime/*.svg": "./runtime/*.svg",
30
+ "./runtime/*": "./runtime/*.ts"
31
+ },
32
+ "dependencies": {
33
+ "@cloudconfig/core": "0.2.0-beta.2",
34
+ "@nuxt/kit": "4.6.1",
35
+ "@typesafe-ai/sdk": "^0.6.0",
36
+ "h3": "1.15.11",
37
+ "defu": "6.1.7",
38
+ "@nuxtagentic/kit": "0.1.0",
39
+ "@nuxtagentic/data": "0.1.0"
40
+ },
41
+ "peerDependencies": {
42
+ "nuxt": "^4.6.1"
43
+ },
44
+ "devDependencies": {
45
+ "tsx": "^4.23.15"
46
+ },
47
+ "cloudconfig": {
48
+ "models": "agentOperationsModels"
49
+ },
50
+ "license": "SEE LICENSE IN LICENSE.md",
51
+ "description": "Prompt planning, query decisions and reviewed mutations for Nuxt Agentic.",
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "registry": "https://registry.npmjs.org/"
55
+ },
56
+ "engines": {
57
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
58
+ }
6
59
  }
@@ -0,0 +1,17 @@
1
+ import { defineEventHandler, readBody, createError } from 'h3'
2
+ import { throwAgentOperationError, useAgentOperationHost } from '../../utils/agent-operations'
3
+ import { isRecord } from '../../../../core/validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const host = await useAgentOperationHost(event)
7
+ const body = await readBody<unknown>(event)
8
+ if (!isRecord(body) || typeof body.key !== 'string' || !/^[0-9a-f-]{36}$/.test(body.key)) {
9
+ throw createError({ statusCode: 400, statusMessage: 'Choose a valid confirmation.' })
10
+ }
11
+ try {
12
+ await host.engine.cancel(body.key, host.context)
13
+ return { cancelled: true }
14
+ } catch (cause) {
15
+ throwAgentOperationError(cause)
16
+ }
17
+ })
@@ -0,0 +1,26 @@
1
+ import { defineEventHandler, readBody, createError } from 'h3'
2
+ import { throwAgentOperationError, useAgentOperationHost } from '../../utils/agent-operations'
3
+ import { isRecord, isValidId } from '../../../../core/validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const host = await useAgentOperationHost(event)
7
+ const body = await readBody<unknown>(event)
8
+ // No operation, resource, predicate or write payload is accepted from the browser.
9
+ if (
10
+ !isRecord(body) ||
11
+ Object.keys(body).some((key) => !['key', 'selectedIds'].includes(key)) ||
12
+ typeof body.key !== 'string' ||
13
+ !/^[0-9a-f-]{36}$/.test(body.key) ||
14
+ !Array.isArray(body.selectedIds) ||
15
+ body.selectedIds.length > 25 ||
16
+ !body.selectedIds.every(isValidId)
17
+ ) {
18
+ throw createError({ statusCode: 400, statusMessage: 'Confirm the exact reviewed records.' })
19
+ }
20
+ const { key, selectedIds } = body
21
+ try {
22
+ return await host.engine.execute(key, selectedIds, host.context)
23
+ } catch (cause) {
24
+ throwAgentOperationError(cause)
25
+ }
26
+ })
@@ -0,0 +1,37 @@
1
+ import { defineEventHandler, readBody, createError } from 'h3'
2
+ import { throwAgentOperationError, useAgentOperationHost } from '../../utils/agent-operations'
3
+ import { isRecord, isValidId } from '../../../../core/validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const host = await useAgentOperationHost(event)
7
+ const body = await readBody<unknown>(event)
8
+ if (
9
+ !isRecord(body) ||
10
+ typeof body.prompt !== 'string' ||
11
+ !body.prompt.trim() ||
12
+ body.prompt.length > 2000 ||
13
+ (body.resource !== undefined && typeof body.resource !== 'string') ||
14
+ (body.selection !== undefined && !isValidId(body.selection))
15
+ ) {
16
+ throw createError({
17
+ statusCode: 400,
18
+ statusMessage: 'Provide an action, resource and valid optional selection.',
19
+ })
20
+ }
21
+ const { prompt, resource: resourceId, selection } = body
22
+ const resource = host.resources.find(
23
+ (resource) => resource.id === resourceId || resource.aliases.includes(String(resourceId)),
24
+ )
25
+ if (resourceId && !resource) {
26
+ throw createError({ statusCode: 422, statusMessage: 'This resource is unavailable.' })
27
+ }
28
+ try {
29
+ return await host.engine.plan(prompt.trim(), {
30
+ ...host.context,
31
+ ...(resource ? { resource: resource.id } : {}),
32
+ ...(selection !== undefined ? { selection } : {}),
33
+ })
34
+ } catch (cause) {
35
+ throwAgentOperationError(cause)
36
+ }
37
+ })
@@ -0,0 +1,60 @@
1
+ import { createError, defineEventHandler, readValidatedBody } from 'h3'
2
+ import { getWorkspaceMetadata } from '#workspace/server'
3
+ import { JEV_MODELS } from '../../shared/agent-tools'
4
+ import { MODEL_QUERY_CONFIG } from '../../../config'
5
+ import { createDispatcher } from '../utils/jev-dispatch'
6
+ import { useJevInference } from '../utils/jev-inference'
7
+ import { useAgentSession } from '../utils/agent-session'
8
+
9
+ const MAX_PROMPT_LENGTH = 2000
10
+ interface PromptBody {
11
+ prompt: string
12
+ model?: string
13
+ resource?: string
14
+ }
15
+
16
+ /**
17
+ * POST /api/agent — turns a natural-language prompt into a local tool call.
18
+ *
19
+ * The active provider only decides; the browser runs the tool. The API key stays on the server,
20
+ * and only signed-in users can spend it.
21
+ */
22
+ export default defineEventHandler(async (event) => {
23
+ const body = await readValidatedBody<PromptBody, typeof event>(
24
+ event,
25
+ (body: unknown): body is PromptBody =>
26
+ typeof body === 'object' &&
27
+ body !== null &&
28
+ 'prompt' in body &&
29
+ typeof body.prompt === 'string' &&
30
+ body.prompt.trim().length > 0 &&
31
+ body.prompt.length <= MAX_PROMPT_LENGTH &&
32
+ (!('resource' in body) || body.resource === undefined || typeof body.resource === 'string'),
33
+ )
34
+ const prompt = body.prompt.trim()
35
+ const model =
36
+ JEV_MODELS.find((candidate) => candidate.id === body.model)?.id ?? MODEL_QUERY_CONFIG.model
37
+
38
+ const session = await useAgentSession(event)
39
+ if (!session?.user) {
40
+ throw createError({ statusCode: 401, statusMessage: 'Sign in to use the agent.' })
41
+ }
42
+
43
+ const metadata = getWorkspaceMetadata(event.context.workspaceModules as string[] | undefined)
44
+ const dispatchPrompt = createDispatcher({
45
+ tools: metadata.AGENT_TOOLS,
46
+ views: metadata.MODEL_VIEWS,
47
+ query: MODEL_QUERY_CONFIG,
48
+ })
49
+
50
+ const inference = useJevInference(
51
+ event,
52
+ MODEL_QUERY_CONFIG.inference,
53
+ MODEL_QUERY_CONFIG.logMetrics,
54
+ )
55
+ try {
56
+ return await dispatchPrompt(prompt, inference.infer, { model, resource: body.resource })
57
+ } finally {
58
+ inference.close()
59
+ }
60
+ })
@@ -0,0 +1,104 @@
1
+ import { CloudConfigClientError } from '@cloudconfig/core/client'
2
+ import type { ServerClient } from '@cloudconfig/core/server'
3
+ import type { AgentOperationsConfig } from '../../schema'
4
+ import {
5
+ OperationError,
6
+ type Confirmation,
7
+ type ConfirmationStore,
8
+ type Context,
9
+ type PreparedMutation,
10
+ type Receipt,
11
+ } from '../../core/index'
12
+
13
+ export function createConfirmationStore(
14
+ api: ServerClient<AgentOperationsConfig>,
15
+ owner: Context,
16
+ ): ConfirmationStore {
17
+ type Stored = Awaited<ReturnType<typeof api.agentOperation.get>>
18
+ const recordIds = new Map<string, number>()
19
+ function view(row: Stored): Confirmation {
20
+ const { key, ownerId, sessionId, expiresAt, state, plan, result, version } = row
21
+ return {
22
+ key,
23
+ actorId: String(ownerId),
24
+ sessionId,
25
+ expiresAt: Date.parse(expiresAt),
26
+ state,
27
+ plan: plan as PreparedMutation,
28
+ ...(result ? { result: result as Receipt } : {}),
29
+ revision: version,
30
+ }
31
+ }
32
+ async function transition(
33
+ confirmation: Confirmation,
34
+ state: 'executing' | 'completed' | 'failed' | 'cancelled',
35
+ revision: number,
36
+ result?: Receipt,
37
+ ) {
38
+ const id = recordIds.get(confirmation.key)
39
+ if (id === undefined) {
40
+ throw new OperationError(404, 'This confirmation is unavailable.')
41
+ }
42
+ try {
43
+ await api.agentOperation.update(id, {
44
+ state,
45
+ version: revision + 1,
46
+ expectedVersion: revision,
47
+ ...(result ? { result } : {}),
48
+ })
49
+ } catch (cause) {
50
+ if (cause instanceof CloudConfigClientError && [400, 409].includes(cause.status)) {
51
+ throw new OperationError(
52
+ 409,
53
+ 'This action was already claimed or changed. Check the results before trying again.',
54
+ )
55
+ }
56
+ throw cause
57
+ }
58
+ }
59
+ return {
60
+ async create(plan, context) {
61
+ const row = await api.agentOperation.create({
62
+ key: crypto.randomUUID(),
63
+ ownerId: Number(owner.actorId),
64
+ sessionId: context.sessionId,
65
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
66
+ state: 'pending',
67
+ plan,
68
+ version: 0,
69
+ expectedVersion: -1,
70
+ })
71
+ recordIds.set(row.key, row.id)
72
+ return view(row)
73
+ },
74
+ async get(key, context) {
75
+ const result = await api.agentOperation.list({
76
+ page: 1,
77
+ pageSize: 1,
78
+ filters: {
79
+ type: 'group',
80
+ logic: 'and',
81
+ children: [
82
+ { type: 'condition', field: 'key', operator: 'eq', value: key },
83
+ { type: 'condition', field: 'sessionId', operator: 'eq', value: context.sessionId },
84
+ ],
85
+ },
86
+ })
87
+ const row = result.data[0]
88
+ if (!row) {
89
+ throw new OperationError(404, 'This confirmation is unavailable.')
90
+ }
91
+ recordIds.set(row.key, row.id)
92
+ return view(row)
93
+ },
94
+ claim: (confirmation) => transition(confirmation, 'executing', confirmation.revision),
95
+ finish: (confirmation, result) =>
96
+ transition(
97
+ confirmation,
98
+ result.success ? 'completed' : 'failed',
99
+ confirmation.revision + 1,
100
+ result,
101
+ ),
102
+ cancel: (confirmation) => transition(confirmation, 'cancelled', confirmation.revision),
103
+ }
104
+ }