From 5f7492d1209a486c6649c40319a2adcd98cd1e57 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 31 May 2026 19:59:05 +0800 Subject: [PATCH] feat(metadata-admin): approval as a flow node, not a standalone designer (ADR-0019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the Studio flow designer and the runtime record-approval UI to the ADR-0019 model, where an approval is a Flow node (`type: 'approval'`) rather than a standalone "approval process" metadata type. Designer: - Add `approval` to the flow node-type options + add-node palette (teal UserCheck icon/tone, canvas summary). - Hardcode the Approval node config inspector (approvers / behavior / lockRecord / approvalStatusField / gated escalation) mirroring ApprovalNodeConfigSchema. The published @objectstack/spec predates ADR-0019, so the schema can't be imported yet — the config is declared inline. - Retire the standalone approval-process pieces: delete ApprovalStepInspector and ApprovalPreview, drop their inspector/preview/anchor registrations and the engine.inspector.approvalStep.* i18n keys. Validation: - clientValidation: route `workflow` to StateMachineSchema; make the `flow` validator forward-compatible so the published spec's closed FlowNode.type enum no longer false-flags `approval` / `connector_action` (suppress only the `.type` enum mismatch for those node types; every other field still validates). Runtime UI: - Rewrite useRecordApprovals to the node model: drop submit/recall (those endpoints are gone); read /approvals/requests and let a pending approver approve/reject. RecordDetailView surfaces approve/reject actions only when the current user is a pending approver. Verified live in the Studio dev server: approval node adds to the canvas, the inspector renders, the validation banner clears. tsc clean; 75 tests pass. --- package.json | 2 +- packages/app-shell/package.json | 2 +- packages/app-shell/src/hooks/index.ts | 2 +- .../app-shell/src/hooks/useRecordApprovals.ts | 95 ++--- .../app-shell/src/views/RecordDetailView.tsx | 109 +++-- .../src/views/metadata-admin/anchors.ts | 28 +- .../views/metadata-admin/clientValidation.ts | 46 ++- .../src/views/metadata-admin/i18n.ts | 20 - .../inspectors/ApprovalStepInspector.tsx | 110 ----- .../inspectors/flow-node-config.ts | 70 +++- .../views/metadata-admin/inspectors/index.ts | 4 +- .../previews/ApprovalPreview.tsx | 380 ------------------ .../metadata-admin/previews/FlowCanvas.tsx | 7 + .../previews/flow-canvas-parts.tsx | 15 + .../views/metadata-admin/previews/index.ts | 5 +- pnpm-lock.yaml | 36 +- 16 files changed, 250 insertions(+), 681 deletions(-) delete mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/ApprovalStepInspector.tsx delete mode 100644 packages/app-shell/src/views/metadata-admin/previews/ApprovalPreview.tsx diff --git a/package.json b/package.json index 4f29ea853..d20105a31 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@eslint/js": "^10.0.1", - "@objectstack/spec": "^7.2.1", + "@objectstack/spec": "^7.3.0", "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 56ac68b4d..3158949c3 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -44,7 +44,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^7.2.1", + "@objectstack/spec": "^7.3.0", "@monaco-editor/react": "^4.7.0", "@sentry/react": "^10.53.1", "jsonc-parser": "^3.3.1", diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index 7226b8af4..7895766a6 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -13,7 +13,7 @@ export { } from './useNavigationSync'; export { useObjectActions } from './useObjectActions'; export { useRecentItems, type RecentItem } from './useRecentItems'; -export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals'; +export { useRecordApprovals, type ApprovalRequestLite } from './useRecordApprovals'; export { useResponsiveSidebar } from './useResponsiveSidebar'; export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent'; export { diff --git a/packages/app-shell/src/hooks/useRecordApprovals.ts b/packages/app-shell/src/hooks/useRecordApprovals.ts index 62e093052..c06e73036 100644 --- a/packages/app-shell/src/hooks/useRecordApprovals.ts +++ b/packages/app-shell/src/hooks/useRecordApprovals.ts @@ -2,24 +2,24 @@ * useRecordApprovals * * Resolves the approval state for a single record so the detail-view header - * can surface "Submit for Approval" / "Recall" actions and a status badge. + * can surface a status badge and — when the current user is a pending + * approver — "Approve" / "Reject" actions. + * + * Since ADR-0019 an approval is a **flow node** (`type: 'approval'`), not a + * standalone process: the flow opens the request when it reaches the node, + * and a decision resumes the run down its `approve` / `reject` edge. There is + * therefore no manual "submit" or "recall" from the record header — those + * endpoints were removed. This hook reads the record's requests and lets a + * pending approver record a decision. * * Talks directly to the framework REST endpoints under - * `/api/v1/approvals/*`. Fails open: if the approvals plugin is not - * installed (404) or the user has no identity, returns inert state so the - * detail view continues to render normally. + * `/api/v1/approvals/*`. Fails open: if the approvals plugin is not installed + * (404 / 501) or the user has no identity, returns inert state so the detail + * view continues to render normally. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -export interface ApprovalProcessLite { - id: string; - name: string; - label?: string; - object_name: string; - active?: boolean; -} - export interface ApprovalRequestLite { id: string; process_name: string; @@ -36,13 +36,12 @@ export interface ApprovalRequestLite { interface UseRecordApprovalsResult { loading: boolean; available: boolean; - processes: ApprovalProcessLite[]; pendingRequest: ApprovalRequestLite | null; latestRequest: ApprovalRequestLite | null; - canSubmit: boolean; - canRecall: boolean; - submit: (input?: { processName?: string; comment?: string }) => Promise; - recall: (input?: { comment?: string }) => Promise; + /** The current user is among the pending approvers and may record a decision. */ + canDecide: boolean; + approve: (input?: { comment?: string }) => Promise; + reject: (input?: { comment?: string }) => Promise; refresh: () => Promise; } @@ -75,7 +74,6 @@ export function useRecordApprovals( ): UseRecordApprovalsResult { const [loading, setLoading] = useState(false); const [available, setAvailable] = useState(true); - const [processes, setProcesses] = useState([]); const [requests, setRequests] = useState([]); const unavailableRef = useRef(false); @@ -84,19 +82,13 @@ export function useRecordApprovals( if (unavailableRef.current) return; setLoading(true); try { - const [procResp, reqResp] = await Promise.all([ - fetchJson<{ data: ApprovalProcessLite[] }>( - `/approvals/processes?object=${encodeURIComponent(objectName)}&activeOnly=true`, - ), - fetchJson<{ data: ApprovalRequestLite[] }>( - `/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`, - ), - ]); - setProcesses(procResp?.data ?? []); + const reqResp = await fetchJson<{ data: ApprovalRequestLite[] }>( + `/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`, + ); setRequests(reqResp?.data ?? []); setAvailable(true); } catch (err: any) { - if (err?.status === 404) { + if (err?.status === 404 || err?.status === 501) { unavailableRef.current = true; setAvailable(false); } @@ -108,7 +100,6 @@ export function useRecordApprovals( useEffect(() => { if (!objectName || !recordId) { - setProcesses([]); setRequests([]); return; } @@ -130,35 +121,14 @@ export function useRecordApprovals( return sorted[0] ?? null; }, [requests]); - const canSubmit = available && processes.length > 0 && !pendingRequest; - const canRecall = !!pendingRequest && !!currentUserId - && pendingRequest.submitter_id === currentUserId; + const canDecide = !!pendingRequest && !!currentUserId + && (pendingRequest.pending_approvers ?? []).includes(currentUserId); - const submit = useCallback( - async (input?: { processName?: string; comment?: string }) => { - if (!objectName || !recordId) throw new Error('Missing object or record'); - const processName = input?.processName - ?? (processes.length === 1 ? processes[0].name : undefined); - const row = await fetchJson(`/approvals/requests`, { - method: 'POST', - body: JSON.stringify({ - object: objectName, - recordId, - ...(processName ? { processName } : {}), - ...(input?.comment ? { comment: input.comment } : {}), - }), - }); - await refresh(); - return row; - }, - [objectName, recordId, processes, refresh], - ); - - const recall = useCallback( - async (input?: { comment?: string }) => { + const decide = useCallback( + async (decision: 'approve' | 'reject', input?: { comment?: string }) => { if (!pendingRequest) throw new Error('No pending request'); - const out = await fetchJson<{ request: ApprovalRequestLite }>( - `/approvals/requests/${encodeURIComponent(pendingRequest.id)}/recall`, + const out = await fetchJson<{ request?: ApprovalRequestLite }>( + `/approvals/requests/${encodeURIComponent(pendingRequest.id)}/${decision}`, { method: 'POST', body: JSON.stringify({ @@ -168,21 +138,22 @@ export function useRecordApprovals( }, ); await refresh(); - return out.request; + return out?.request; }, [pendingRequest, currentUserId, refresh], ); + const approve = useCallback((input?: { comment?: string }) => decide('approve', input), [decide]); + const reject = useCallback((input?: { comment?: string }) => decide('reject', input), [decide]); + return { loading, available, - processes, pendingRequest, latestRequest, - canSubmit, - canRecall, - submit, - recall, + canDecide, + approve, + reject, refresh, }; } diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 3181abd70..f0602d94b 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -492,9 +492,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }, [authFetch, pureRecordId, objectName]); // ─── Approvals ───────────────────────────────────────────────────── - // Surfaces "Submit for Approval" / "Recall" buttons on the record header - // when an active approval process is registered for this object, and a - // status badge when a request exists. + // Since ADR-0019 an approval is a flow node: the flow opens the request, + // there is no manual submit/recall from the record header. When the current + // user is a pending approver, surface "Approve" / "Reject" on the header and + // a status badge whenever a request exists. const approvals = useRecordApprovals(objectName, pureRecordId, user?.id); // Hold latest approvals snapshot in a ref so the action handler // (memoized once inside ActionRunner) always sees fresh state instead of @@ -508,13 +509,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ? (action.params as Record) : {}; try { - if (target === 'submit_approval') { - await approvalsRef.current.submit({ - processName: params.processName, - comment: params.comment, - }); - } else if (target === 'recall_approval') { - await approvalsRef.current.recall({ comment: params.comment }); + if (target === 'approve_request') { + await approvalsRef.current.approve({ comment: params.comment }); + } else if (target === 'reject_request') { + await approvalsRef.current.reject({ comment: params.comment }); } else { return { success: false, error: `Unknown approval target: ${target}` }; } @@ -1184,57 +1182,44 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }), })); - // Inject approval actions — only when the approvals plugin is - // available and an active process exists for this object. - if (approvals.available && approvals.processes.length > 0) { - if (approvals.canSubmit) { - base.push({ - name: 'submit_approval', - type: 'approval', - target: 'submit_approval', - label: t('approvals.submitForApproval', { defaultValue: 'Submit for Approval' }), - icon: 'send', - variant: 'default', - locations: ['record_header'], - refreshAfter: true, - successMessage: t('approvals.submitSuccess', { defaultValue: 'Approval request submitted' }), - ...(approvals.processes.length === 1 - ? { params: { processName: approvals.processes[0].name } } - : { - collectParams: [{ - name: 'processName', - label: t('approvals.process', { defaultValue: 'Process' }), - type: 'select', - required: true, - options: approvals.processes.map((p) => ({ - value: p.name, - label: p.label || p.name, - })), - }, { - name: 'comment', - label: t('approvals.comment', { defaultValue: 'Comment (optional)' }), - type: 'text', - multiline: true, - }], - }), - }); - } - if (approvals.canRecall) { - base.push({ - name: 'recall_approval', - type: 'approval', - target: 'recall_approval', - label: t('approvals.recall', { defaultValue: 'Recall' }), - icon: 'undo', - variant: 'outline', - locations: ['record_header'], - refreshAfter: true, - confirmText: t('approvals.recallConfirm', { - defaultValue: 'Recall this pending approval request?', - }), - successMessage: t('approvals.recallSuccess', { defaultValue: 'Approval recalled' }), - }); - } + // Inject approval actions — only when the current user is a pending + // approver for this record (ADR-0019: approvals are opened by a flow + // node, so there is no manual submit/recall; an approver records a + // decision that resumes the flow down its approve/reject edge). + if (approvals.available && approvals.canDecide) { + const commentParam = { + name: 'comment', + label: t('approvals.comment', { defaultValue: 'Comment (optional)' }), + type: 'text', + multiline: true, + }; + base.push({ + name: 'approve_request', + type: 'approval', + target: 'approve_request', + label: t('approvals.approve', { defaultValue: 'Approve' }), + icon: 'check', + variant: 'default', + locations: ['record_header'], + refreshAfter: true, + collectParams: [commentParam], + successMessage: t('approvals.approveSuccess', { defaultValue: 'Approved' }), + }); + base.push({ + name: 'reject_request', + type: 'approval', + target: 'reject_request', + label: t('approvals.reject', { defaultValue: 'Reject' }), + icon: 'x', + variant: 'destructive', + locations: ['record_header'], + refreshAfter: true, + confirmText: t('approvals.rejectConfirm', { + defaultValue: 'Reject this approval request?', + }), + collectParams: [commentParam], + successMessage: t('approvals.rejectSuccess', { defaultValue: 'Rejected' }), + }); } return base; @@ -1378,7 +1363,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest, embedded]); + }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest, approvals.latestRequest, embedded]); if (isLoading) { return ; diff --git a/packages/app-shell/src/views/metadata-admin/anchors.ts b/packages/app-shell/src/views/metadata-admin/anchors.ts index d25f4d324..fabc480ff 100644 --- a/packages/app-shell/src/views/metadata-admin/anchors.ts +++ b/packages/app-shell/src/views/metadata-admin/anchors.ts @@ -102,31 +102,9 @@ export function registerBuiltinAnchors(): void { createDefaults: { events: [] }, }); - // approval.object → object (approval processes targeting this object) - registerMetadataResource({ - type: 'approval', - anchors: [{ - anchorType: 'object', - match: anchorByField('object'), - groupLabel: 'Approval Processes', - order: 70, - }], - createFields: ['label', 'name', 'object', 'description'], - createDerive: [ - { from: 'label', to: 'name', transform: 'slugify', untilUserEdits: true }, - ], - createDefaults: { - active: true, - lockRecord: true, - steps: [{ - name: 'step_1', - label: 'First approval', - approvers: [{ type: 'manager', value: 'manager' }], - behavior: 'first_response', - rejectionBehavior: 'reject_process', - }], - }, - }); + // Approval is no longer a standalone metadata type (ADR-0019) — it is a flow + // node (`type: 'approval'`). Approvals therefore surface on an object through + // the Flows it belongs to, not a separate "Approval Processes" group. // page.object → object (auto-generated record pages, etc.) registerMetadataResource({ diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.ts index 44bf47314..58dd9d503 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.ts @@ -16,7 +16,7 @@ * Schemas are loaded lazily — the first call for a given type kicks * off a dynamic `import()` of the relevant spec subpath, then caches * the result. Types we don't have a client-side schema for (e.g. - * `validation`, `role`, `workflow`, etc.) return an empty issue list; + * `validation`, `trigger`, `connector`, etc.) return an empty issue list; * the user still gets server-side diagnostics on save. */ @@ -64,8 +64,11 @@ const LOADERS: Record = { // automation flow: async () => (await import('@objectstack/spec/automation')).FlowSchema as unknown as ZodLikeSchema, - workflow: async () => (await import('@objectstack/spec/automation')).WorkflowRuleSchema as unknown as ZodLikeSchema, - approval: async () => (await import('@objectstack/spec/automation')).ApprovalProcessSchema as unknown as ZodLikeSchema, + workflow: async () => (await import('@objectstack/spec/automation')).StateMachineSchema as unknown as ZodLikeSchema, + // `approval` is no longer a standalone metadata type — it's a flow node + // (`type: 'approval'`, ADR-0019). Its config (ApprovalNodeConfigSchema) is + // validated as part of the enclosing flow; there is no top-level schema, so + // it falls through to server-side validation. webhook: async () => (await import('@objectstack/spec/automation')).WebhookSchema as unknown as ZodLikeSchema, // ai @@ -93,6 +96,25 @@ const LOADERS: Record = { api: async () => (await import('@objectstack/spec/api')).ApiEndpointSchema as unknown as ZodLikeSchema, }; +// Flow node `type` values the running server accepts but the published +// `@objectstack/spec` FlowNodeSchema enum predates. The framework HEAD opened +// FlowNodeSchema.type to a validated string (ADR-0019 P2) and registers these +// as built-in node descriptors, but that spec change is not yet on npm — so the +// published closed enum spuriously flags them. We suppress only the enum +// mismatch on the node's `.type`; every other field is still validated. +// - `approval`: durable-pause approval node (ADR-0019). +// - `connector_action`: deliberate open extension point for connector-provided +// node types — must never be flagged as invalid. +const FORWARD_COMPAT_FLOW_NODE_TYPES = new Set(['approval', 'connector_action']); +const FLOW_NODE_TYPE_ISSUE = /^nodes\.(\d+)\.type$/; + +function nodeTypeAt(draft: unknown, index: number): string | undefined { + const nodes = (draft as { nodes?: unknown })?.nodes; + if (!Array.isArray(nodes)) return undefined; + const node = nodes[index] as { type?: unknown } | undefined; + return typeof node?.type === 'string' ? node.type : undefined; +} + const SCHEMA_CACHE = new Map(); async function getSchemaForType(type: string): Promise { @@ -143,7 +165,23 @@ export async function validateMetadataDraft( const result = schema.safeParse(draft); if (result.success) return { ok: true, issues: [] }; - const issues: SchemaFormIssue[] = (result.error?.issues ?? []).map((i) => ({ + let rawIssues = result.error?.issues ?? []; + // Forward-compat: don't let the published flow schema's closed node-type + // enum reject node types the running server supports (see + // FORWARD_COMPAT_FLOW_NODE_TYPES). Suppress only the `.type` enum mismatch + // for those nodes; all other issues still surface. + if (type === 'flow') { + rawIssues = rawIssues.filter((i) => { + const path = (i.path ?? []).map((seg) => String(seg)).join('.'); + const match = FLOW_NODE_TYPE_ISSUE.exec(path); + if (!match) return true; + const nodeType = nodeTypeAt(draft, Number(match[1])); + return !(nodeType && FORWARD_COMPAT_FLOW_NODE_TYPES.has(nodeType)); + }); + } + if (rawIssues.length === 0) return { ok: true, issues: [] }; + + const issues: SchemaFormIssue[] = rawIssues.map((i) => ({ path: (i.path ?? []).map((seg) => String(seg)).join('.'), message: i.message, })); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 36bd563b7..85da94621 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -325,16 +325,6 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.flowNode.list.remove': 'Remove item', 'engine.inspector.flowNode.list.empty': 'No items yet.', 'engine.inspector.flowNode.remove': 'Remove node', - // Approval step inspector - 'engine.inspector.approvalStep.kind': 'Step', - 'engine.inspector.approvalStep.close': 'Close step', - 'engine.inspector.approvalStep.name': 'Name (machine)', - 'engine.inspector.approvalStep.label': 'Label', - 'engine.inspector.approvalStep.description': 'Description', - 'engine.inspector.approvalStep.behavior': 'Behavior', - 'engine.inspector.approvalStep.entryCriteria': 'Entry criteria (CEL)', - 'engine.inspector.approvalStep.rejectionBehavior': 'Rejection behavior', - 'engine.inspector.approvalStep.remove': 'Remove step', // Workflow action inspector 'engine.inspector.workflowAction.kind': 'Action', 'engine.inspector.workflowAction.close': 'Close action', @@ -656,16 +646,6 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.flowNode.list.remove': '删除项', 'engine.inspector.flowNode.list.empty': '暂无项。', 'engine.inspector.flowNode.remove': '删除节点', - // Approval step inspector - 'engine.inspector.approvalStep.kind': '审批步骤', - 'engine.inspector.approvalStep.close': '关闭步骤', - 'engine.inspector.approvalStep.name': '机器名', - 'engine.inspector.approvalStep.label': '标签', - 'engine.inspector.approvalStep.description': '描述', - 'engine.inspector.approvalStep.behavior': '审批策略', - 'engine.inspector.approvalStep.entryCriteria': '进入条件(CEL)', - 'engine.inspector.approvalStep.rejectionBehavior': '拒绝行为', - 'engine.inspector.approvalStep.remove': '删除步骤', // Workflow action inspector 'engine.inspector.workflowAction.kind': '动作', 'engine.inspector.workflowAction.close': '关闭动作', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ApprovalStepInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ApprovalStepInspector.tsx deleted file mode 100644 index d1d808caf..000000000 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ApprovalStepInspector.tsx +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ApprovalStepInspector — scoped editor for the selected approval step. - * - * Selection shape: { kind: 'step', id: } - * Patches: draft.steps[i] = {...step, ...updates} - */ - -import * as React from 'react'; -import type { MetadataInspectorProps } from '../inspector-registry'; -import { t } from '../i18n'; -import { - InspectorShell, - InspectorReorderButtons, - InspectorTextField, - InspectorSelectField, - InspectorRemoveButton, - InspectorEmptyState, - spliceArray, - moveArray, -} from './_shared'; - -interface ApprovalStep { - name?: string; - label?: string; - description?: string; - behavior?: 'unanimous' | 'first'; - entryCriteria?: string | { source?: string }; - rejectionBehavior?: 'back_to_previous' | 'reject_process'; - [k: string]: unknown; -} - -const BEHAVIORS = [ - { value: 'unanimous', label: 'unanimous (all approve)' }, - { value: 'first', label: 'first response wins' }, -]; - -const REJECTION = [ - { value: 'back_to_previous', label: 'back to previous step' }, - { value: 'reject_process', label: 'reject entire process' }, -]; - -function celOf(v: ApprovalStep['entryCriteria']): string { - if (v == null) return ''; - if (typeof v === 'string') return v; - return v.source ?? ''; -} - -export function ApprovalStepInspector({ selection, draft, onPatch, onClearSelection, onSelectionChange, locale, readOnly }: MetadataInspectorProps) { - const steps = Array.isArray((draft as any).steps) ? (draft as any).steps as ApprovalStep[] : []; - // Lookup by name, or by "steps[i]" pseudo-id (assigned when name is empty). - const index = (() => { - const m = /^steps\[(\d+)\]$/.exec(selection.id); - if (m) return Number(m[1]); - return steps.findIndex((s) => s?.name === selection.id); - })(); - const step = index >= 0 && index < steps.length ? steps[index] : null; - - if (!step) { - return ( - - - - ); - } - - const patch = (updates: Partial) => { - onPatch({ steps: spliceArray(steps, index, { ...step, ...updates }) }); - }; - - const remove = () => { - onPatch({ steps: spliceArray(steps, index, null) }); - onClearSelection(); - }; - - const move = (to: number) => { - onPatch({ steps: moveArray(steps, index, to) }); - // Re-select by name if available (stable across position), else by new index. - const id = step.name || `steps[${to}]`; - onSelectionChange?.({ kind: 'step', id, label: step.label || step.name || `Step ${to + 1}` }); - }; - - return ( - - } - footer={} - > - patch({ name: v })} disabled={readOnly} mono /> - patch({ label: v })} disabled={readOnly} /> - patch({ description: v })} disabled={readOnly} /> - patch({ behavior: v as ApprovalStep['behavior'] })} disabled={readOnly} /> - patch({ entryCriteria: v })} disabled={readOnly} mono /> - patch({ rejectionBehavior: v as ApprovalStep['rejectionBehavior'] })} disabled={readOnly} /> - - ); -} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index 1500de132..711e2f298 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -261,6 +261,65 @@ const FLOW_NODE_CONFIG: Record = { ], }), ], + // Approval node (ADR-0019). The node opens an approval request on entry, + // suspends the run, and resumes down its `approve` / `reject` out-edge once a + // decision is recorded. Config mirrors `@objectstack/spec` + // ApprovalNodeConfigSchema; entry criteria and on-approve / on-reject actions + // are NOT here — they live on the graph (the edge into this node, and the + // nodes wired to its `approve` / `reject` out-edges). + approval: [ + cfg('approvers', 'Approvers', 'objectList', { + help: 'Who may act on this step. Wire the node’s out-edges with labels "approve" and "reject".', + columns: [ + { + key: 'type', + label: 'Type', + kind: 'select', + options: [ + { value: 'user', label: 'User' }, + { value: 'role', label: 'Role' }, + { value: 'team', label: 'Team' }, + { value: 'department', label: 'Department' }, + { value: 'manager', label: 'Manager' }, + { value: 'field', label: 'Field' }, + { value: 'queue', label: 'Queue' }, + ], + }, + { key: 'value', label: 'Value', kind: 'text', placeholder: 'user id / role / field — per type' }, + ], + }), + cfg('behavior', 'Behavior', 'select', { + options: [ + { value: 'first_response', label: 'First response wins' }, + { value: 'unanimous', label: 'Unanimous (all approve)' }, + ], + defaultValue: 'first_response', + help: 'How multiple approvers combine.', + }), + cfg('lockRecord', 'Lock record', 'boolean', { + help: 'Lock the triggering record from edits while this node is pending.', + }), + cfg('approvalStatusField', 'Status field', 'text', { + placeholder: 'approval_status', + help: 'Business-object field to mirror request status onto (pending/approved/rejected). Should be readonly.', + }), + // Per-node SLA escalation (spec ApprovalEscalationSchema, nested under + // config.escalation). Sub-fields reveal once escalation is enabled. + { id: 'escalation.enabled', path: ['config', 'escalation', 'enabled'], label: 'SLA escalation', kind: 'boolean', defaultValue: 'false', help: 'Escalate when a decision is not recorded within the timeout.' }, + { id: 'escalation.timeoutHours', path: ['config', 'escalation', 'timeoutHours'], label: 'Timeout (hours)', kind: 'number', placeholder: '24', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, + { + id: 'escalation.action', path: ['config', 'escalation', 'action'], label: 'On timeout', kind: 'select', defaultValue: 'notify', + options: [ + { value: 'notify', label: 'Notify' }, + { value: 'reassign', label: 'Reassign' }, + { value: 'auto_approve', label: 'Auto-approve' }, + { value: 'auto_reject', label: 'Auto-reject' }, + ], + showWhen: { field: 'escalation.enabled', equals: ['true'] }, + }, + { id: 'escalation.escalateTo', path: ['config', 'escalation', 'escalateTo'], label: 'Escalate to', kind: 'text', placeholder: 'user id / role / manager level', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, + { id: 'escalation.notifySubmitter', path: ['config', 'escalation', 'notifySubmitter'], label: 'Notify submitter', kind: 'boolean', showWhen: { field: 'escalation.enabled', equals: ['true'] } }, + ], wait: [ at('waitEventConfig', 'eventType', 'Wait for', 'select', { options: [ @@ -366,7 +425,6 @@ const TYPE_ALIASES: Record = { service_task: 'connector_action', script_task: 'script', notification: 'connector_action', - approval: 'screen', signal: 'boundary_event', webhook: 'connector_action', for_each: 'loop', @@ -397,7 +455,10 @@ export function getFieldValue(node: Record | null | undefined, * only config-rooted fields suppress an Advanced key. */ export function configKeyOf(field: FlowConfigField): string | undefined { - return field.path.length === 2 && field.path[0] === 'config' ? field.path[1] : undefined; + // Any config-rooted field claims its first config segment — so nested groups + // (e.g. `['config','escalation','enabled']`) all claim `escalation`, keeping + // the whole block out of the Advanced editor. + return field.path.length >= 2 && field.path[0] === 'config' ? field.path[1] : undefined; } /** @@ -417,7 +478,9 @@ export function isFieldVisible( const controller = fields.find((f) => f.id === field.showWhen!.field); if (!controller) return false; const raw = getFieldValue(node, controller); - const value = raw === undefined || raw === null || raw === '' ? controller.defaultValue : raw; + const resolved = raw === undefined || raw === null || raw === '' ? controller.defaultValue : raw; + // Boolean controllers (e.g. `escalation.enabled`) compare against 'true'/'false'. + const value = typeof resolved === 'boolean' ? String(resolved) : resolved; return typeof value === 'string' && field.showWhen.equals.includes(value); } @@ -434,6 +497,7 @@ export const FLOW_NODE_TYPE_OPTIONS = [ 'http_request', 'script', 'screen', + 'approval', 'wait', 'subflow', 'connector_action', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts index 1ffbac33d..501832cfb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts @@ -9,7 +9,6 @@ import { registerMetadataInspector } from '../inspector-registry'; import { registerMetadataDefaultInspector } from '../default-inspector-registry'; import { DashboardWidgetInspector } from './DashboardWidgetInspector'; import { FlowNodeInspector } from './FlowNodeInspector'; -import { ApprovalStepInspector } from './ApprovalStepInspector'; import { WorkflowActionInspector } from './WorkflowActionInspector'; import { AppNavInspector } from './AppNavInspector'; import { ViewInspector, ViewDefaultInspector } from './ViewInspector'; @@ -19,8 +18,9 @@ import { ObjectFieldInspector } from './ObjectFieldInspector'; export function registerBuiltinInspectors(): void { registerMetadataInspector('dashboard', DashboardWidgetInspector); + // Approval is authored as a flow node (`type: 'approval'`) since ADR-0019 — + // edited through FlowNodeInspector, not a standalone step inspector. registerMetadataInspector('flow', FlowNodeInspector); - registerMetadataInspector('approval', ApprovalStepInspector); registerMetadataInspector('workflow', WorkflowActionInspector); registerMetadataInspector('app', AppNavInspector); registerMetadataInspector('view', ViewInspector); diff --git a/packages/app-shell/src/views/metadata-admin/previews/ApprovalPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ApprovalPreview.tsx deleted file mode 100644 index cb62afd9d..000000000 --- a/packages/app-shell/src/views/metadata-admin/previews/ApprovalPreview.tsx +++ /dev/null @@ -1,380 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ApprovalPreview — read-only summary of an Approval Process draft. - * - * Renders the lifecycle the way operators reason about it: - * - * 1. Envelope: target object, lock-record toggle, status field, - * entry criteria (CEL), escalation summary. - * 2. Global hooks strip — onSubmit / onFinalApprove / onFinalReject - * / onRecall — collapsed to action-count chips so the eye can - * jump straight to the chain. - * 3. Vertical chain of steps. Each step shows: - * • Numbered marker. - * • Label + machine name + behavior pill. - * • Approvers (typed chips: user/role/field/manager/…). - * • Entry criteria CEL. - * • Approve / Reject action counts with expandable rows. - * • Rejection behavior (back-to-previous vs reject-process). - * - * Steps render top-down with arrows between to mirror the way the - * runtime actually walks them. Escalation gets its own callout - * because it cuts across all pending steps. - */ - -import * as React from 'react'; -import { - AlarmClock, - ArrowDown, - CheckCircle2, - CircleAlert, - Filter, - Lock, - PlayCircle, - Plus, - Power, - RotateCcw, - ShieldAlert, - Undo2, - UserCog, - Users, - XCircle, -} from 'lucide-react'; -import type { MetadataPreviewProps } from '../preview-registry'; -import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell'; -import { uniqueId, appendArray } from '../inspectors/_shared'; -import { t as tr } from '../i18n'; - -interface Approver { - type?: string; - value?: string; -} - -interface ApprovalAction { - type?: string; - name?: string; - config?: Record; -} - -interface ApprovalStep { - name?: string; - label?: string; - description?: string; - entryCriteria?: string | { source?: string }; - approvers?: Approver[]; - behavior?: string; - rejectionBehavior?: string; - onApprove?: ApprovalAction[]; - onReject?: ApprovalAction[]; -} - -interface Escalation { - enabled?: boolean; - timeoutHours?: number; - action?: string; - escalateTo?: string; - notifySubmitter?: boolean; -} - -function celText(c: unknown): string | undefined { - if (!c) return undefined; - if (typeof c === 'string') return c; - if (typeof c === 'object' && typeof (c as any).source === 'string') return (c as any).source; - return undefined; -} - -export function ApprovalPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps) { - const d = draft as Record; - const object = String(d.object ?? ''); - const active = !!d.active; - const lockRecord = d.lockRecord !== false; - const statusField = (d.approvalStatusField as string | undefined) || undefined; - const entryCriteria = celText(d.entryCriteria); - const steps: ApprovalStep[] = Array.isArray(d.steps) ? (d.steps as ApprovalStep[]) : []; - const escalation = d.escalation as Escalation | undefined; - const globalHooks = { - onSubmit: Array.isArray(d.onSubmit) ? (d.onSubmit as ApprovalAction[]) : [], - onFinalApprove: Array.isArray(d.onFinalApprove) ? (d.onFinalApprove as ApprovalAction[]) : [], - onFinalReject: Array.isArray(d.onFinalReject) ? (d.onFinalReject as ApprovalAction[]) : [], - onRecall: Array.isArray(d.onRecall) ? (d.onRecall as ApprovalAction[]) : [], - }; - - const designMode = !!(editing && onSelectionChange); - const canEdit = designMode && !!onPatch; - const selectedId = selection && selection.kind === 'step' ? selection.id : null; - const selectStep = (s: ApprovalStep, i: number) => { - const id = s.name || `steps[${i}]`; - onSelectionChange?.({ kind: 'step', id, label: s.label || s.name || `Step ${i + 1}` }); - }; - - const handleAddStep = React.useCallback(() => { - if (!canEdit) return; - const existingNames = steps.map((s) => s.name).filter(Boolean) as string[]; - const name = uniqueId('step', existingNames); - const newStep: ApprovalStep = { name, label: 'New step', approvers: [] }; - const next = appendArray(steps, newStep); - onPatch!({ steps: next }); - onSelectionChange?.({ kind: 'step', id: name, label: newStep.label || name }); - }, [canEdit, steps, onPatch, onSelectionChange]); - - if (steps.length === 0 && !object) { - return ( - - {canEdit ? ( -
- -
- ) : ( - Set the target object and at least one step to see the approval chain. - )} -
- ); - } - - return ( - - -
- {/* Envelope */} -
-
- - - - {statusField && } -
- {entryCriteria && ( -
- - Entry: - {entryCriteria} -
- )} -
- - {/* Escalation callout */} - {escalation?.enabled && ( -
-
- SLA escalation enabled -
-
- After {escalation.timeoutHours}h on a pending step →{' '} - {escalation.action ?? 'notify'} - {escalation.escalateTo && <> → {escalation.escalateTo}} - {escalation.notifySubmitter && (notify submitter)} -
-
- )} - - {/* Steps chain */} - {steps.length === 0 ? ( - No steps defined yet. - ) : ( -
    - {steps.map((step, i) => ( - - selectStep(step, i) : undefined} - selected={selectedId != null && (step.name === selectedId || `steps[${i}]` === selectedId)} - /> - {i < steps.length - 1 && ( -
  1. - -
  2. - )} -
    - ))} -
- )} - {canEdit && ( - - )} - - {/* Global hooks */} -
-
- Global Hooks -
-
- - - - -
-
-
-
-
- ); -} - -function StepRow({ step, index, onClick, selected }: { step: ApprovalStep; index: number; onClick?: () => void; selected?: boolean }) { - const approvers = step.approvers ?? []; - const entry = celText(step.entryCriteria); - return ( -
  • { e.stopPropagation(); onClick(); } : undefined} - > -
    - - {index + 1} - -
    -
    - {step.label || step.name || `Step ${index + 1}`} - {step.name && {step.name}} - {step.behavior && ( - - {step.behavior === 'unanimous' ? 'all must approve' : 'first response wins'} - - )} -
    - {step.description && ( -
    {step.description}
    - )} - {entry && ( -
    - - When: - {entry} -
    - )} -
    - - Approvers: - {approvers.length === 0 ? ( - none - ) : ( -
    - {approvers.map((a, i) => ( - - - {a.value || '?'} - {a.type && {a.type}} - - ))} -
    - )} -
    -
    -
    - {/* Step hooks */} - {((step.onApprove?.length ?? 0) > 0 || (step.onReject?.length ?? 0) > 0 || step.rejectionBehavior) && ( -
    - {step.onApprove && step.onApprove.length > 0 && ( - - )} - {step.onReject && step.onReject.length > 0 && ( - - )} - {step.rejectionBehavior && ( -
    - {step.rejectionBehavior === 'back_to_previous' ? ( - - ) : ( - - )} - - Rejection: {step.rejectionBehavior} - -
    - )} -
    - )} -
  • - ); -} - -function ActionLine({ - icon: Icon, - tone, - label, - actions, -}: { - icon: React.ComponentType<{ className?: string }>; - tone: 'green' | 'red'; - label: string; - actions: ApprovalAction[]; -}) { - const cls = tone === 'green' ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'; - return ( -
    - - {label}: -
    - {actions.map((a, i) => ( - - {a.type ?? 'action'} - {a.name && · {a.name}} - - ))} -
    -
    - ); -} - -function Hook({ - icon: Icon, - label, - actions, - tone = 'gray', -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - actions: ApprovalAction[]; - tone?: 'gray' | 'green' | 'red'; -}) { - const cls = - tone === 'green' - ? 'text-emerald-700 dark:text-emerald-400' - : tone === 'red' - ? 'text-red-700 dark:text-red-400' - : 'text-muted-foreground'; - return ( - - - {label} - {actions.length} - - ); -} - -function Pill({ - icon: Icon, - label, - tone = 'gray', - mono = false, -}: { - icon?: React.ComponentType<{ className?: string }>; - label: string; - tone?: 'gray' | 'green' | 'amber'; - mono?: boolean; -}) { - const cls = - tone === 'green' ? 'text-emerald-700 dark:text-emerald-400' : tone === 'amber' ? 'text-amber-700 dark:text-amber-400' : 'text-foreground'; - return ( - - {Icon && } - {label} - - ); -} diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index 623504aef..5f6240e69 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -533,6 +533,13 @@ function nodeSummary(node: FlowNode): string | undefined { if (node.type === 'script') { return pick('actionType') || pick('template') || (c && c.script ? 'code' : undefined); } + if (node.type === 'approval') { + const approvers = c?.approvers; + const n = Array.isArray(approvers) ? approvers.length : 0; + const behavior = pick('behavior'); + if (n > 0) return `${n} approver${n === 1 ? '' : 's'}${behavior === 'unanimous' ? ' · all' : ''}`; + return behavior || undefined; + } return ( pick('objectName') || block('connectorConfig', 'actionId') || diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx index 53c6bcb91..4fc10007b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx @@ -24,6 +24,7 @@ import { Plus, Repeat, TimerReset, + UserCheck, Variable, Workflow, Zap, @@ -69,6 +70,8 @@ export function nodeIcon(type: string): LucideIcon { case 'screen': case 'user_task': return MonitorSmartphone; + case 'approval': + return UserCheck; case 'connector_action': case 'service_task': return Plug; @@ -141,6 +144,11 @@ const TONES: Record = { accent: 'border-l-fuchsia-500', label: 'text-fuchsia-600 dark:text-fuchsia-400', }, + approval: { + icon: 'text-teal-600 dark:text-teal-400', + accent: 'border-l-teal-500', + label: 'text-teal-600 dark:text-teal-400', + }, }; export function nodeTone(type: string): NodeTone { @@ -166,6 +174,8 @@ export function nodeTone(type: string): NodeTone { case 'subflow': case 'flow': return TONES.subflow; + case 'approval': + return TONES.approval; case 'create_record': case 'update_record': case 'delete_record': @@ -208,6 +218,7 @@ export const NODE_PALETTE: PaletteItem[] = [ { type: 'http_request', label: 'HTTP request', hint: 'Call an external API' }, { type: 'connector_action', label: 'Connector', hint: 'Run an integration action' }, { type: 'script', label: 'Script', hint: 'Run custom code' }, + { type: 'approval', label: 'Approval', hint: 'Pause for a human decision' }, { type: 'subflow', label: 'Subflow', hint: 'Invoke another flow' }, { type: 'wait', label: 'Wait', hint: 'Pause for an event or timer' }, { type: 'end', label: 'End', hint: 'Terminate the flow' }, @@ -238,6 +249,10 @@ export function defaultNodeExtras(type: string): Record { return { connectorConfig: { connectorId: '', actionId: '', input: {} } }; case 'boundary_event': return { boundaryConfig: { attachedToNodeId: '', eventType: 'error', interrupting: true } }; + case 'approval': + // Seed a node-model approval: at least one approver + spec defaults. The + // author wires the out-edges with labels `approve` / `reject`. + return { config: { approvers: [{ type: 'manager' }], behavior: 'first_response', lockRecord: true } }; case 'http_request': return { config: { method: 'GET' } }; case 'script': diff --git a/packages/app-shell/src/views/metadata-admin/previews/index.ts b/packages/app-shell/src/views/metadata-admin/previews/index.ts index d9f2e90e4..8985be99a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/index.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/index.ts @@ -23,7 +23,6 @@ import { AgentPreview } from './AgentPreview'; import { ToolPreview } from './ToolPreview'; import { PermissionPreview } from './PermissionPreview'; import { ActionPreview } from './ActionPreview'; -import { ApprovalPreview } from './ApprovalPreview'; import { JobPreview } from './JobPreview'; import { TranslationPreview } from './TranslationPreview'; import { RolePreview } from './RolePreview'; @@ -47,9 +46,11 @@ export function registerBuiltinPreviews(): void { registerMetadataPreview('email_template', EmailTemplatePreview); registerMetadataPreview('translation', TranslationPreview); // Automation + // Approval is a flow node (`type: 'approval'`) since ADR-0019 — it renders on + // the Flow canvas with its `approve` / `reject` branches; no standalone + // approval-process preview. registerMetadataPreview('flow', FlowPreview); registerMetadataPreview('workflow', WorkflowPreview); - registerMetadataPreview('approval', ApprovalPreview); registerMetadataPreview('job', JobPreview); // AI registerMetadataPreview('agent', AgentPreview); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 066af1ece..e27c60557 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) '@objectstack/spec': - specifier: ^7.2.1 - version: 7.2.1(ai@6.0.191(zod@4.4.3)) + specifier: ^7.3.0 + version: 7.3.0(ai@6.0.191(zod@4.4.3)) '@playwright/test': specifier: ^1.60.0 version: 1.60.0 @@ -693,8 +693,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^7.2.1 - version: 7.2.1(ai@6.0.191(zod@4.4.3)) + specifier: ^7.3.0 + version: 7.3.0(ai@6.0.191(zod@4.4.3)) '@sentry/react': specifier: ^10.53.1 version: 10.53.1(react@19.2.6) @@ -780,7 +780,7 @@ importers: version: 9.0.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -801,7 +801,7 @@ importers: version: 4.1.1 vite: specifier: ^8.0.14 - version: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) + version: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) devDependencies: '@types/express': specifier: ^4.17.25 @@ -820,7 +820,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/collaboration: dependencies: @@ -1079,7 +1079,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/data-objectstack: dependencies: @@ -3882,6 +3882,15 @@ packages: ai: optional: true + '@objectstack/spec@7.3.0': + resolution: {integrity: sha512-yoa721aRJ+9w59PpKeDW4odNGk1Quy2OhOz0RwqTHTMxX3hC+iyufcLxu5C7k0VPbE/pM51yZ+fXsvxNBi246w==} + engines: {node: '>=18.0.0'} + peerDependencies: + ai: ^6.0.0 + peerDependenciesMeta: + ai: + optional: true + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -11777,6 +11786,12 @@ snapshots: optionalDependencies: ai: 6.0.191(zod@4.4.3) + '@objectstack/spec@7.3.0(ai@6.0.191(zod@4.4.3))': + dependencies: + zod: 4.4.3 + optionalDependencies: + ai: 6.0.191(zod@4.4.3) + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -13587,6 +13602,11 @@ snapshots: optionalDependencies: maplibre-gl: 5.24.0 + '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) + '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1